openvibers/openvibe.zone Commits
OpenVibe.Zone 0.1.0: private S3-compatible storage zones for OpenVibe projects
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++# OpenVibe.Zone — environment (production: /etc/openvibe/zone.env). Names only; never commit values.++++NODE_ENV=production++PORT=5050++HOST=127.0.0.1++BASE_URL=https://openvibe.zone++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh zone: 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:zone:++# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.++#ZONE_LIMITS_MINUTE=120++#ZONE_LIMITS_HOUR=3000++++# OpenVibe.Network: SSO (OAuth client `zone`, PKCE S256) and its JWKS; app tokens for audience openvibe.zone (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=zone++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.zone/auth/callback++# ZONE_AUDIENCE=openvibe.zone++++# optional: defaults to true in production++# COOKIE_SECURE=true++++# OpenVibe.Media: Zone provisions each zone there and asks it for usage, deletion progress and access keys (loopback,++# with Zone's own client-credentials token: grants media.zone.provision and media.access_key.manage).++MEDIA_INTERNAL_URL=http://127.0.0.1:4100++# The S3 endpoint the console and the docs name (s3.openvibe.media once its DNS record exists).++ZONE_S3_ENDPOINT=https://s3.openvibe.media++# The zones one project environment may hold besides its default.++#ZONE_MAX_PER_ENV=20nnew file mode 100644@@@ -0,0 +1,18 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server scripts test++ contracts-service: zone++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.log++*.pem++*.key++!server/data/++node_modulesnnew file mode 100644@@@ -0,0 +1,14 @@++# Changelog++++All notable changes to OpenVibe.Zone.++++## 0.1.0 — 2026-10-14++++- First release (ADR-031 amendment 2026-10-02, contracts 0.173.0).++- **Zones:** named object zones per project environment.++ - Each environment has a default zone.++ - Names are never reused.++ - Every change reaches OpenVibe.Media by revision; a zone is active only once Media has it.++ - Deletion is recursive, refuses held objects and completes once Media reports nothing left.++- **API:** for developer apps and project members, plus the resource index and control for OpenVibe.Services.++- **Console:** at openvibe.zone, with each project's zones, their usage, and S3 access keys issued through Media. A secret is shown once.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,86 @@++# OpenVibe.Zone++++Private S3-compatible storage zones for OpenVibe projects, at https://openvibe.zone. The design is ADR-031 in++OpenVibe.Contracts, with its amendment of 2026-10-02.++++A **zone** is a named object zone of one project environment, sandbox or production. Any S3 tool reaches it as a++bucket at `https://s3.openvibe.media`, path style.++++**Zone owns** the zone record:++- its name, which is never reused;++- its environment, state, description and per-zone quota;++- the control API and the console.++++**OpenVibe.Media owns** everything inside a zone:++- the objects, their bytes and replicas;++- the S3 and native data APIs;++- the access keys;++- quota enforcement, usage counting and the one deletion path.++++Zone never lists keys and never holds bytes.++++## What it does++++- **Default zones.** Every project environment has a `default` zone. Its namespace is the environment root, so it holds the environment's existing objects. Zone creates it the first time the environment is used, and it cannot be deleted on its own.++- **Named zones.** Created by name (3 to 63 of `a-z`, `0-9` and `-`), with an optional quota.++ - A name belongs to one zone of its environment for ever: `zone.name_taken` while the zone lives, `zone.name_retired` after it is deleted. So a URL signed for a bucket can never reach a different zone.++ - Named zones are private.++- **Provisioning by revision.** Every change raises the zone's revision and is pushed to Media (`PUT /internal/zones/:id`, capability `media.zone.provision`). Media applies only a higher revision.++ - A zone becomes `active` only after Media has it.++ - The job (`zones.tick`, every 15 s) pushes whatever Media has not acknowledged yet, for example while Media restarts.++- **Deletion.** A zone that still holds objects answers `zone.not_empty`, unless `recursive=true` is sent. A zone with an object under a retention hold answers `zone.held`.++ - Once deletion starts, Media answers `NoSuchBucket` at once, then tombstones and purges every object.++ - When Media reports nothing left, the job marks the zone `deleted`.++ - Media revokes keys scoped only to that zone.++- **Access keys.** The console issues, lists and revokes S3 keys through Media's API (`media.access_key.manage`), for a person who administers the project. A secret is shown once, on the page that issued it.++- **Services.** OpenVibe.Services reaches Zone through two first-party routes:++ - the resource index, `GET /api/v1/resources` (`zone.resource.read`);++ - control, `POST /api/v1/resources/control` (`zone.resource.control`, action `delete`).++++## API++++Base `https://openvibe.zone/api/v1`. A caller is either:++- a developer app of the project, with the route's capability for audience `openvibe.zone` (a sandbox app reaches only the sandbox), or++- a member of the project: any role reads, and admins and owners change.++++| Route | Capability |++|---|---|++| `POST /projects/:project_id/zones` (Idempotency-Key) | `zone.zone.create` |++| `GET /projects/:project_id/zones` | `zone.zone.list` |++| `GET /zones/:zone_id` | `zone.zone.read` |++| `PATCH /zones/:zone_id` | `zone.zone.manage` |++| `DELETE /zones/:zone_id?recursive=true` | `zone.zone.delete` |++| `GET /zones/:zone_id/usage` | `zone.usage.read` |++| `GET /resources`, `GET /resources/:ovrn` | `zone.resource.read` (first-party) |++| `POST /resources/control` | `zone.resource.control` (first-party) |++++## Run++++```sh++npm ci++npm test # PGlite; npm run test:pg on the PostgreSQL containers++npm run dev++```++++**Production:**++- unit `openvibe-zone.service` on 127.0.0.1:5050, database `ov_zone`, env `/etc/openvibe/zone.env`;++- nginx vhost `deploy/nginx/openvibe.zone.conf` on openvibe.zone's own certificate;++- deployed with `sudo ovhost deploy zone`.++++**Grants it needs:**++- `media.zone.provision` and `media.access_key.manage` (audience `openvibe.media`);++- `network.project.member.read`.++++## Layout++++```++server/zone/zones.js zone records, names, revisions, provisioning, deletion, the job++server/zone/clients.js Media (provision, report, keys) and Network (roles, a person's projects)++server/http/api.js /api/v1++server/http/pages.js the console and docs; discovery.js robots.txt, sitemap.xml, llms.txt++migrations/ the schema++```++++## License++++AGPL-3.0, see LICENSE.nnew file mode 100644@@@ -0,0 +1,21 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,++openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable++contact details are at `/.well-known/security.txt` on each site.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,46 @@++{++ "repository": "OpenVibers/OpenVibe.Zone",++ "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": 5050,++ "domain": "openvibe.zone",++ "contracts": "openvibe-contracts v0.173.0",++ "sdk": "openvibe-sdk v0.44.0",++ "shared": "openvibe-shared v4.1.4",++ "packages": [++ "openvibe-contracts v0.173.0",++ "openvibe-sdk v0.44.0",++ "openvibe-shared v4.1.4"++ ],++ "summary": "OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects (ADR-031 amendment). Zone owns the zone record and its control API; OpenVibe.Media owns the objects, bytes, keys, quotas and the S3 surface at s3.openvibe.media.",++ "works": [++ "Default zone per project environment, created on first use",++ "Named zones: create (Idempotency-Key), list, read with live usage, update description and quota, delete (recursive, refuses held objects)",++ "Names never reused (name_taken, name_retired)",++ "Provisioning in Media by revision; provisioning → active once Media acknowledges; a 15 s job retries and finishes deletions",++ "Console: projects, zones, usage, S3 access keys issued through Media (secret shown once)",++ "Resource index and control (delete) for OpenVibe.Services"++ ],++ "notYet": [++ "Per-zone usage series (Media's hourly rollups with the zone's resource name)",++ "api.openvibe.zone and s3.openvibe.media DNS records (owner)",++ "Multipart uploads and CopyObject on Media's S3 surface"++ ],++ "capabilitiesRegistered": true,++ "serviceManifestRegistered": true,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "Default zone per project environment, created on first use",++ "Named zones: create (Idempotency-Key), list, read with live usage, update description and quota, delete (recursive, refuses held objects)",++ "Names never reused (name_taken, name_retired)",++ "Provisioning in Media by revision; provisioning → active once Media acknowledges; a 15 s job retries and finishes deletions",++ "Console: projects, zones, usage, S3 access keys issued through Media (secret shown once)",++ "Resource index and control (delete) for OpenVibe.Services"++ ],++ "deployment": "openvibe-ovh: unit openvibe-zone.service on 127.0.0.1:5050 (git checkout /opt/openvibe.zone, env /etc/openvibe/zone.env, database ov_zone)",++ "updated": "2026-10-14"++}nnew file mode 100644@@@ -0,0 +1,106 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.zone — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.zone.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5050, systemd unit openvibe-zone) renders every page and serves++## the API; nginx terminates TLS with openvibe.zone's own certificate (openvibe.zone and *.openvibe.zone: a repo vhost++## uses the certificate that really covers its name, or Cloudflare answers 526), rate-limits sign-in and the API, keeps++## /internal/ off the internet, and compresses text. api.openvibe.zone is the same site once its DNS record exists;++## www.openvibe.zone redirects to the apex. The bytes of a zone never pass here: they go to s3.openvibe.media.++##++## 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=zone_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=zone_api:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.zone api.openvibe.zone www.openvibe.zone;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.zone$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.zone;++ ssl_certificate /etc/letsencrypt/live/openvibe.zone/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.zone/privkey.pem;++ return 301 https://openvibe.zone$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.zone api.openvibe.zone;++++ ssl_certificate /etc/letsencrypt/live/openvibe.zone/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.zone/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.zone.access.log;++ error_log /var/log/nginx/openvibe.zone.error.log;++++ # The API's JSON body cap is 32 KB and a console form 8 KB; 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=zone_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5050;++ }++++ location /auth/ {++ limit_req zone=zone_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5050;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=zone_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5050;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5050/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5050/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:5050;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,30 @@++[Unit]++Description=OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects.++# Network (sign-in, JWKS) should be up first, but OpenVibe.Zone 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/openvibe.zone++EnvironmentFile=/etc/openvibe/zone.env++Environment=NODE_ENV=production++Environment=PORT=5050++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.zone/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.Zone's own database is PostgreSQL on the host's data role; the code tree stays read-only.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,46 @@++-- phase: expand++-- OpenVibe.Zone (ADR-031 amendment 2026-10-02): named object zones per project environment. Zone owns the record;++-- OpenVibe.Media owns everything inside a zone (objects, bytes, keys, quotas, the deletion path) and keeps a++-- projection of each record, pushed with a higher revision on every change (pushed_revision is what Media has).++++CREATE TABLE zone_zones (++ id text COLLATE "C" PRIMARY KEY, -- zon_<ULID>, never reused++ project_id text COLLATE "C" NOT NULL,++ env text COLLATE "C" NOT NULL CHECK (env IN ('sandbox', 'production')),++ name text COLLATE "C" NOT NULL,++ is_default boolean NOT NULL DEFAULT false,++ description text,++ state text COLLATE "C" NOT NULL CHECK (state IN ('provisioning', 'active', 'deleting', 'deleted')),++ quota_bytes bigint,++ revision integer NOT NULL,++ pushed_revision integer NOT NULL DEFAULT 0,++ created_at bigint NOT NULL,++ updated_at bigint,++ deletion_requested_at bigint,++ deletion_completed_at bigint,++ -- A name belongs to one zone of its environment for ever, deleted or not.++ UNIQUE (project_id, env, name)++);++CREATE INDEX zone_zones_project ON zone_zones (project_id, env, id);++CREATE INDEX zone_zones_pending ON zone_zones (id) WHERE pushed_revision < revision OR state = 'deleting';++++-- The first answer to each create (caller, Idempotency-Key), returned again for a repeat.++CREATE TABLE zone_idempotency (++ caller text COLLATE "C" NOT NULL,++ idempotency_key text COLLATE "C" NOT NULL,++ digest text COLLATE "C" NOT NULL,++ zone_id text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ PRIMARY KEY (caller, idempotency_key)++);++++-- ADR-048 control requests from OpenVibe.Services: the first answer per (caller, project, idempotency_key).++CREATE TABLE zone_control_log (++ caller text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ idempotency_key text COLLATE "C" NOT NULL,++ digest text COLLATE "C" NOT NULL,++ response jsonb NOT NULL,++ created_at bigint NOT NULL,++ PRIMARY KEY (caller, project_id, idempotency_key)++);nnew file mode 100644@@@ -0,0 +1,1401 @@++{++ "name": "openvibe-zone",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-zone",++ "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.173.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.4",++ "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.173.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",++ "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",++ "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.4",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.4",++ "integrity": "sha512-tw1/uDkSj/js+mQjrabMGcZ6mfKRIBQLMd8SvCac2E2RAKiralwDKR+qNZL6oB8qB5ILEx6s8fJylQwVZr6poA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-zone",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Zone: private S3-compatible storage zones for OpenVibe projects (ADR-031)",++ "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": "ZONE_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.173.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.4",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,82 @@++/* OpenVibe.Zone — 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; }++++/* Zone */++.zn-section { margin: 28px 0; }++.zn-lede { font-size: 1.08rem; color: var(--text-secondary, #96a7c2); max-width: 820px; }++.zn-muted { color: var(--text-muted, #7386a3); }++.zn-list { padding-left: 1.2rem; max-width: 860px; }++.zn-list li { margin: 6px 0; }++.zn-code { max-width: 860px; }++.zn-button { display: inline-block; padding: 8px 14px; border-radius: 10px; font-weight: 600; text-decoration: none; border: 1px solid var(--border-light, #2c3d5c);++ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }++.zn-button:hover { border-color: var(--accent, #3b82f6); }++.zn-primary { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); border-color: transparent; }++.zn-danger { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }++.zn-cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 14px; }++.zn-card { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 16px; background: var(--bg-card, #131c2e); }++.zn-card h2 { font-size: 1.1rem; margin: .2rem 0; }++.zn-table { margin: 8px 0 12px; }++.zn-section .zn-table { display: block; overflow-x: auto; }++.zn-n { text-align: right; white-space: nowrap; }++.zn-badge { font-size: .72rem; padding: 0 7px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }++.zn-revoked { opacity: .55; }++.zn-form { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; margin: 10px 0; }++.zn-form label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; color: var(--text-secondary, #96a7c2); }++.zn-form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 6px 10px; margin: 0; flex-basis: 100%; }++.zn-form .zn-check { flex-direction: row; align-items: center; display: inline-flex; margin-right: 12px; }++.zn-form input, .zn-form select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); min-width: 0; }++details summary { cursor: pointer; color: var(--error, #ef4444); }++details form { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; max-width: 340px; }++details form label { display: flex; flex-direction: column; gap: 4px; }++details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }++.zn-notice { border-left: 3px solid var(--success, #22c55e); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }++.zn-error { border-left: 3px solid var(--error, #ef4444); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }++.zn-secret { border: 1px solid var(--warning, #f59e0b); border-radius: 12px; padding: 12px 16px; margin: 14px 0; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }++.zn-secret dt { color: var(--text-secondary, #96a7c2); font-size: .85rem; margin-top: 6px; }++.zn-secret dd { margin: 0; }++.zn-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }++@media (max-width: 600px) { .zn-form { flex-direction: column; align-items: stretch; } }nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++++/**++ * OpenVibe.Zone — Express app factory; server/index.js listens, tests build their own instance with a temp database++ * and a mock Network.++ *++ * /, /projects, /p/:project, /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 zone/zones.js (the zone records, provisioned in OpenVibe.Media) 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 { createClients } = require('./zone/clients');++const { createZones } = require('./zone/zones');++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 and Media), log, limitsNow, callerLimits (false: count nobody,++ * tests only), valkey, clients (stand-ins for Media and Network, 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 clients = opts.clients || createClients({ config, fetchImpl });++ const zones = createZones({ s, clients, config, log });++ const ctx = { config, s, keys, sso, principal, log, clients, zones };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'zone', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'zone', 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());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-zone', 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: 'zone', service: 'zone', host: 'openvibe.zone', name: 'OpenVibe.Zone', 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 ─────────────────────────────────────────────────++ // No CORS: a project's zones are managed from this site or by a server with its app token. Per address for the++ // public; a first-party service calls over loopback, unproxied, and is counted per caller instead.++ 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.ZONE_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.ZONE_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.Zone]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured++ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline++ * against it: the person's session token (sign-in).++ *++ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a++ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module++ * is a thin shim so the rest of OpenVibe.Zone (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.Zone] 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.Zone] 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.Zone] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ /**++ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for++ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no++ * key is loaded; a token that names no kid gets the first key.++ */++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `zone`.++ *++ * 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 (zone_at) no script can read: with it, a script++ * could act as them. The refresh token (zone_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 = 'zone_at';++const REFRESH_COOKIE = 'zone_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'zone_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.Zone 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.Zone] 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.Zone?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared++ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,++ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/**++ * OpenVibe.Zone configuration. Every value comes from the environment (production: /etc/openvibe/zone.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, 5050);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.zone' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'zone',++ 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.ZONE_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.ZONE_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 (ZONE_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ZONE_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:zone:' },++++ // 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.ZONE_AUDIENCE || 'openvibe.zone',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'zone',++ 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.Media: Zone provisions each zone there and asks it for usage and deletion progress (loopback, with++ // Zone's own service token); the S3 endpoint is what the docs and the console tell people to use.++ media: {++ internalUrl: trim(env.MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),++ s3Endpoint: trim(env.ZONE_S3_ENDPOINT || 'https://s3.openvibe.media'),++ },++ // The zones one project environment may hold besides its default.++ maxZonesPerEnv: Math.max(1, int(env.ZONE_MAX_PER_ENV, 20)),++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * OpenVibe.Zone'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 zone)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Zone] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'zone', 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: 'zone-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'zone', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx(() => fn()),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,218 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Zone's API (capabilities: openvibe-contracts manifests/capabilities/zone.*; ADR-031 amendment).++ *++ * GET /ping public { ok }++ * POST /projects/:project/zones zone.zone.create zone.object-zone-create-request@1, Idempotency-Key → 201++ * GET /projects/:project/zones zone.zone.list ?env&include_deleted&cursor&limit → zone.object-zone-list@1++ * GET /zones/:zone zone.zone.read zone.object-zone@1 with Media's live usage++ * PATCH /zones/:zone zone.zone.manage description, quota_bytes++ * DELETE /zones/:zone?recursive=true zone.zone.delete 202 zone.object-zone@1 (deleting)++ * GET /zones/:zone/usage zone.usage.read zone.object-zone-usage@1++ * GET /resources[?project&kind&cursor&limit] zone.resource.read first-party: common.resource-list-result@1++ * GET /resources/:ovrn zone.resource.read first-party: common.resource-summary@1++ * POST /resources/control zone.resource.control first-party: delete, for a project admin++ *++ * Who may act on a project: a developer app of that project holding the route's capability (a sandbox app only in the++ * sandbox), or a person who is a member of it (any role reads; admins and owners change). Anyone else is told the++ * project or zone does not exist.++ */++const crypto = require('crypto');++const express = require('express');++const contracts = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { ZoneError } = require('../zone/zones');++const { ClientError } = require('../zone/clients');++++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ADMIN = new Set(['admin', 'owner']);++const KIND = 'zone.object-zone';++++function createApi(ctx) {++ const { principal, zones, clients, limits, config } = ctx;++ const r = asyncRouter();++ const json = express.json({ limit: '32kb' });++ const send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const wrap = (fn) => async (req, res) => {++ try { await fn(req, res); } catch (err) {++ if (err instanceof ZoneError || err instanceof ClientError) return send(req, res, err.status, err.code, err.detail || err.message);++ throw err;++ }++ };++ r.use(principal.middleware);++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ // Every route counts against the caller's read numbers first; the writes and the reads that ask Media add their own.++ r.use(limits.reads('zone.read'));++++ /** May this caller act on `project` (read, or `write`)? Throws 404/403 as a ZoneError. */++ async function access(req, project, { write = false, env = null } = {}) {++ const p = req.principal;++ const unknown = () => { throw new ZoneError(404, 'zone.unknown_project', 'no such project'); };++ if (!PROJECT_RE.test(String(project || ''))) unknown();++ if (p.kind === 'app') {++ if (p.project !== project) unknown();++ if (env && p.claims.env === 'sandbox' && env !== 'sandbox') throw new ZoneError(403, 'zone.sandbox_only', 'a sandbox app reaches only the sandbox environment');++ return p.requester;++ }++ if (p.kind === 'user') {++ const role = await clients.memberRole(project, p.requester.slice('user:'.length));++ if (!role) unknown();++ if (write && !ADMIN.has(role)) throw new ZoneError(403, 'zone.not_admin', 'only an admin or owner of the project changes its zones');++ return p.requester;++ }++ throw new ZoneError(401, 'token.required', 'Sign in at openvibe.zone, or send a Network token');++ }++++ async function zoneFor(req, { write = false } = {}) {++ if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');++ const row = await zones.byId(req.params.zone);++ if (!row) throw new ZoneError(404, 'zone.unknown', 'no such zone');++ try { await access(req, row.project_id, { write, env: row.env }); } catch (err) {++ // Another project's zone, and a production zone to a sandbox app, do not exist for this caller.++ if (err.code === 'zone.unknown_project' || err.code === 'zone.sandbox_only') throw new ZoneError(404, 'zone.unknown', 'no such zone');++ throw err;++ }++ return row;++ }++++ r.get('/ping', (req, res) => res.json({ ok: true, service: 'zone' }));++++ r.post('/projects/:project/zones', principal.requireCapability('zone.zone.create'), limits.budget('zone.write'), json, wrap(async (req, res) => {++ const body = req.body || {};++ const caller = await access(req, req.params.project, { write: true, env: body.env });++ const key = req.get('idempotency-key');++ if (key !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(key)) return send(req, res, 400, 'zone.bad_request', 'Idempotency-Key is 8 to 128 of A-Z a-z 0-9 _ . : -');++ const out = await zones.create(req.params.project, body, { caller, idempotencyKey: key || null });++ res.status(201).json(out.zone);++ }));++++ r.get('/projects/:project/zones', principal.requireCapability('zone.zone.list'), wrap(async (req, res) => {++ await access(req, req.params.project, { env: req.query.env || null });++ const q = { ...req.query };++ if (req.principal.kind === 'app' && req.principal.claims.env === 'sandbox') q.env = 'sandbox';++ for (const env of q.env ? [q.env] : ['sandbox', 'production']) if (['sandbox', 'production'].includes(env)) await zones.ensureDefault(req.params.project, env);++ res.json(await zones.list(req.params.project, q));++ }));++++ r.get('/zones/:zone', principal.requireCapability('zone.zone.read'), limits.budget('zone.lookup'), wrap(async (req, res) => {++ await zoneFor(req);++ res.json((await zones.get(req.params.zone, { withUsage: true })).zone);++ }));++++ r.patch('/zones/:zone', principal.requireCapability('zone.zone.manage'), limits.budget('zone.write'), json, wrap(async (req, res) => {++ await zoneFor(req, { write: true });++ res.json(await zones.update(req.params.zone, req.body || {}));++ }));++++ r.delete('/zones/:zone', principal.requireCapability('zone.zone.delete'), limits.budget('zone.write'), wrap(async (req, res) => {++ await zoneFor(req, { write: true });++ const v = contracts.validate('zone.object-zone-delete-query@1', { ...req.query });++ if (!v.valid) return send(req, res, 400, 'zone.bad_request', 'recursive is true or false');++ const recursive = req.query.recursive === 'true';++ res.status(202).json(await zones.remove(req.params.zone, { recursive }));++ }));++++ r.get('/zones/:zone/usage', principal.requireCapability('zone.usage.read'), limits.budget('zone.lookup'), wrap(async (req, res) => {++ await zoneFor(req);++ res.json(await zones.usage(req.params.zone));++ }));++++ // ── ADR-048: the resource index and control, for OpenVibe.Services (first-party service tokens only) ──++ const firstParty = (cap) => [principal.requireCapability(cap), (req, res, next) => {++ const p = req.principal;++ if (p.kind === 'app' && /^service:/.test(p.requester)) return next();++ return send(req, res, 403, 'capability.denied', `${cap} is first-party`);++ }];++ const summary = (row) => {++ const z = zones.record(row);++ return {++ id: row.id, kind: KIND, service: 'zone', project_id: row.project_id, ovrn: z.ovrn,++ name: `${row.name} (${row.env})`, state: row.state, created_at: z.created_at,++ ...(z.updated_at ? { updated_at: z.updated_at } : {}),++ links: { console: `${config.baseUrl}/p/${row.project_id}#${row.id}`, docs: `${config.baseUrl}/docs` },++ };++ };++ r.get('/resources', ...firstParty('zone.resource.read'), wrap(async (req, res) => {++ const q = req.query;++ const project = typeof q.project === 'string' && q.project ? q.project : null;++ if (project && !PROJECT_RE.test(project)) return send(req, res, 400, 'resources.bad_query', 'project must be a prj_ id');++ if (q.kind && q.kind !== KIND) return res.json({ resources: [], next_cursor: null });++ if (q.owner) return res.json({ resources: [], next_cursor: null }); // zones belong to projects, not people++ const limit = q.limit ? Number(q.limit) : 100;++ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) return send(req, res, 400, 'resources.bad_query', 'limit must be an integer 1-1000');++ let after = '';++ if (q.cursor) {++ try { after = JSON.parse(Buffer.from(String(q.cursor), 'base64url').toString('utf8'))[1]; } catch { after = null; }++ if (!ZONE_RE.test(String(after || ''))) return send(req, res, 400, 'resources.bad_query', 'cursor is not one this index issued');++ }++ const rows = await ctx.s.db.many(`SELECT * FROM zone_zones WHERE state <> 'deleted' AND ($1::text IS NULL OR project_id = $1) AND id > $2++ ORDER BY id LIMIT $3`, [project, after, limit + 1]);++ const page = rows.slice(0, limit);++ res.json({ resources: page.map(summary), next_cursor: rows.length > limit ? Buffer.from(JSON.stringify([KIND, page[page.length - 1].id])).toString('base64url') : null });++ }));++ r.get('/resources/:ovrn', ...firstParty('zone.resource.read'), wrap(async (req, res) => {++ const parsed = contracts.resources.parse(String(req.params.ovrn || ''));++ const row = parsed && parsed.service === 'zone' && parsed.type === 'object-zone' ? await zones.byId(parsed.id) : null;++ if (!row || row.state === 'deleted' || row.project_id !== parsed.project_id) return send(req, res, 404, 'resources.unknown_resource', `no resource named ${req.params.ovrn}`);++ res.json(summary(row));++ }));++++ const canonical = (v) => (Array.isArray(v) ? `[${v.map(canonical).join(',')}]`++ : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`++ : JSON.stringify(v));++ const digestOf = (request) => crypto.createHash('sha256').update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');++ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });++ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: new Date(ctx.s.now()).toISOString(), ...extra });++++ async function decide(request) {++ const parsed = contracts.resources.parse(request.resource || '');++ if (!parsed || parsed.service !== 'zone' || parsed.type !== 'object-zone' || !ZONE_RE.test(parsed.id)) {++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no zone named ${request.resource || request.resource_kind}`) });++ }++ if (request.action !== 'delete') return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a zone takes delete, not ${request.action}`) });++ const person = request.on_behalf_of;++ if (!person || person.type !== 'user' || !USER_RE.test(String(person.id || ''))) return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person acting') });++ const row = await zones.byId(parsed.id);++ const inProject = !!row && row.state !== 'deleted' && row.project_id === parsed.project_id && parsed.project_id === request.project_id;++ let role = null;++ if (inProject) {++ try { role = await clients.memberRole(row.project_id, person.id); } catch {++ return result(request, 'failed', { problem: problem(503, 'identity.unavailable', 'Network could not say whether this person administers the project; nothing was changed') });++ }++ }++ if (!inProject || !ADMIN.has(role)) return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no zone named ${request.resource} that this person may delete`) });++ if (row.is_default) return result(request, 'refused', { problem: problem(409, 'zone.default', 'the default zone goes only with its environment') });++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the zone and every object in it, with no restore; its name is never used again' } });++ try {++ const z = await zones.remove(row.id, { recursive: true });++ return result(request, 'done', { result: { status: z.state, changed: true } });++ } catch (err) {++ if (err instanceof ZoneError) return result(request, 'refused', { problem: problem(err.status, err.code, err.detail || err.message) });++ if (err instanceof ClientError) return result(request, 'failed', { problem: problem(503, err.code, err.message) });++ throw err;++ }++ }++ r.post('/resources/control', ...firstParty('zone.resource.control'), json, wrap(async (req, res) => {++ const request = req.body;++ const check = contracts.resources.checkControlRequest(request || {});++ if (!check.valid) return send(req, res, 400, 'control.bad_request', JSON.stringify(check.errors).slice(0, 500));++ const caller = req.principal.requester;++ const digest = digestOf(request);++ const seen = await ctx.s.db.maybe('SELECT digest, response FROM zone_control_log WHERE caller = $1 AND project_id = $2 AND idempotency_key = $3', [caller, request.project_id, request.idempotency_key]);++ if (seen) {++ if (seen.digest !== digest) return send(req, res, 409, 'control.idempotency_conflict', 'this idempotency_key was used for another request');++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);++ }++ const answer = await decide(request);++ await ctx.s.db.exec(`INSERT INTO zone_control_log (caller, project_id, idempotency_key, digest, response, created_at)++ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), ctx.s.now()]);++ res.json(answer);++ }));++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * Per-caller rate limits at OpenVibe.Zone'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 zone_rate_limited_total{limit,window}. Reads take ZONE_LIMITS_MINUTE /++ * ZONE_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.++ *++ * 'zone.thing.create': { minute: 6, hour: 60 },++ */++const BUDGETS = {++ // Reads that ask Media for a zone's live usage.++ 'zone.lookup': { minute: 120, hour: 3000 },++ // Creating, changing and deleting zones and access keys: each one is a call to Media.++ 'zone.write': { minute: 30, hour: 300 },++};++++function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {++ const refused = registry++ ? registry.counter({ name: 'zone_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const limiter = createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor: enabled ? caller : () => null,++ now,++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),++ onLimited(e) {++ // The caller is a subject, a principal or an address, never a token.++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name) => {++ const limit = limiter(name);++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());++ };++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));++ limiter.budget = (name) => {++ const m = budgets.get(name);++ if (!m) throw new Error(`limits: no budget named ${name}`);++ return m;++ };++ return limiter;++}++++module.exports = { createCallerLimits, caller, BUDGETS };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.zone, 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 (home, docs, updates) are for search engines and AI++ * crawlers; sign-in, a person's projects and the API are not.++ */++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.Zone';++const DESCRIPTION = 'OpenVibe.Zone: private S3-compatible storage zones for your OpenVibe projects.';++const DISALLOW = ['/auth/', '/api/', '/projects', '/p/'];++++const PAGE_TEXT = {++ '/': ['OpenVibe.Zone home', 'Private storage zones for each project and environment, reached with any S3 tool and scoped access keys.'],++ '/docs': ['Using OpenVibe.Zone', 'The S3 endpoint and its operations, access keys, and the zone API.'],++ '/updates': ['What shipped on OpenVibe.Zone', '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: 'weekly', priority: 1.0 },++ { path: '/docs', changefreq: 'monthly', priority: 0.6 },++ { path: '/updates', changefreq: 'daily', priority: 0.4 },++];++function createDiscoveryRoutes(ctx) {++ const { config } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# openvibe.zone: the public pages are for search and AI crawlers; sign-in, projects and the API are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: DESCRIPTION,++ details: `A zone is an S3 bucket of one OpenVibe project environment (sandbox or production). Zones are made here or `++ + `with the API; access keys read, write or both and may be limited to some zones; any S3 tool reaches them at `++ + `${config.media.s3Endpoint} (path style, SigV4). A zone's name is never reused. Every page is server-rendered.`,++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Zone', url: abs('/'), note: 'what a zone is, and the tools that reach one' },++ { title: 'Using OpenVibe.Zone', url: abs('/docs'), note: 'the S3 endpoint, access keys and the API' },++ { title: 'What shipped on OpenVibe.Zone', url: abs('/updates') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'Full text for language models', url: abs('/llms-full.txt') },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ { title: 'Elsewhere', links: [++ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'projects, apps and every resource' },++ { title: 'OpenVibe.Media', url: 'https://openvibe.media', note: 'the objects inside every zone' },++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ const pages = publicPages().map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME, summary: 'Every public page of OpenVibe.Zone, one line each.', base: site, maxBytes: 64 * 1024,++ sections: [{ title: 'Pages', pages }],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }))));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,307 @@++'use strict';++++/**++ * OpenVibe.Zone's pages, server-rendered and usable without JavaScript:++ *++ * / what a zone is, how to use one with any S3 tool++ * /projects a signed-in person's projects (from Network, with their own token)++ * /p/:project one project: its zones per environment with their usage, and its S3 access keys;++ * admins and owners create and delete zones, issue and revoke keys (forms below)++ * POST /p/:project/zones create a zone++ * POST /p/:project/zones/:zone/delete delete a zone and every object in it (the name must be typed)++ * POST /p/:project/keys issue an access key (its secret is shown on the page it returns, once)++ * POST /p/:project/keys/:key/revoke revoke a key++ * /docs the API and S3 usage++ * /updates the update log++ *++ * Every form is a same-origin POST from a signed-in person; Zone and Media both check that the person administers++ * the project. A key's secret is in the response to its issue only: never stored here, never logged.++ */++const express = require('express');++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 { sameOrigin } = require('./principal');++const { html, raw } = require('../render/html');++const { send } = require('../render/layout');++const { ZoneError, RESERVED } = require('../zone/zones');++const { ClientError } = require('../zone/clients');++++const SITE_NAME = 'OpenVibe.Zone';++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;++const KEY_RE = /^ovk_[0-9A-HJKMNP-TV-Z]{26}$/;++const ADMIN = new Set(['admin', 'owner']);++++function bytes(n) {++ const v = Number(n) || 0;++ if (v < 1024) return `${v} B`;++ const units = ['KB', 'MB', 'GB', 'TB'];++ let x = v / 1024, i = 0;++ while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; }++ return `${x >= 10 ? Math.round(x) : x.toFixed(1)} ${units[i]}`;++}++const plural = (n, one, many) => `${Number(n).toLocaleString('en-US')} ${n === 1 ? one : many}`;++const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');++++function createPageRoutes(ctx) {++ const { config, clients, zones } = ctx;++ const r = asyncRouter();++ const form = express.urlencoded({ extended: false, limit: '8kb' });++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const person = (req) => (req.viewer && req.viewer.kind === 'user' && /^usr_/.test(String(req.viewer.subject || '')) ? req.viewer : null);++ const s3 = config.media.s3Endpoint;++++ const signInPage = (req, res, next) => page(req, res, {++ title: 'Sign in', body: html`<h1>Sign in to manage your zones</h1>++<p>Your zones belong to your OpenVibe projects. Sign in with your OpenVibe account to see them.</p>++<p><a class="zn-button zn-primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p>`,++ }, 401);++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', (req, res) => {++ const me = person(req);++ const hero = showcase.hero({++ eyebrow: `${SITE_NAME} · part of OpenVibe`,++ title: 'Private storage zones',++ accent: 'S3 buckets for your projects.',++ lede: 'Named zones for each project and environment. The AWS SDKs, rclone and backup tools reach them over S3, with access keys you scope to a zone and quotas you set. Every object is an OpenVibe.Media object, readable through its API too.',++ actions: me++ ? [{ label: 'Your projects', href: '/projects', primary: true }, { label: 'How to use it', href: '/docs' }]++ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fprojects', primary: true }, { label: 'How to use it', href: '/docs' }],++ note: 'Each project includes 1 GB in production and 100 MB in its sandbox at no charge.',++ });++ page(req, res, {++ index: true, cache: me ? null : PUBLIC_CACHE, jsonLd: homeJsonLd(config), styles: [showcase.STYLESHEET],++ body: html`${raw(hero)}++${raw(showcase.steps({++ title: 'From nothing to a bucket',++ items: [++ { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },++ { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },++ { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },++ { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },++ ],++ }))}++<section class="zn-section" aria-labelledby="zn-try"><h2 id="zn-try">With the tools you have</h2>++<pre class="zn-code"><code>aws configure set aws_access_key_id ovk_…++aws configure set aws_secret_access_key …++aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive++aws --endpoint-url ${s3} s3 ls s3://assets/</code></pre>++<pre class="zn-code"><code>rclone config create oz s3 provider Other endpoint ${s3} \\++ access_key_id ovk_… secret_access_key … no_check_bucket true++rclone sync ./backups oz:backups</code></pre>++<p class="zn-muted">Objects go up in one request (multipart uploads are not supported yet), up to 100 MB each.</p>++</section>++<section class="zn-section" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>++<ul class="zn-list">++<li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li>++<li><strong>Names that stay yours.</strong> A zone's name is never given to another zone, even after it is deleted, so a URL signed for one bucket can never reach a different one.</li>++<li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li>++<li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li>++<li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>++</ul></section>`,++ });++ });++++ // ── A person's projects ──────────────────────────────────++ r.get('/projects', async (req, res) => {++ const me = person(req);++ if (!me) return signInPage(req, res, '/projects');++ let projects;++ try { projects = (await clients.projectsOf(me.token)).filter((p) => !p.archived_at); } catch (err) {++ return page(req, res, { title: 'Your projects', body: html`<h1>Your projects</h1><p class="zn-error">Your projects could not be read just now (${err.message}). Try again in a moment.</p>` }, 503);++ }++ page(req, res, {++ title: 'Your projects',++ body: html`<h1>Your projects</h1>++${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li class="zn-card"><h2><a href="/p/${p.id}">${p.name}</a></h2>++<p class="zn-muted">${p.role || 'member'} · ${(p.environments || ['sandbox']).join(' and ')}</p></li>`)}</ul>`++ : html`<p>You are not in any project yet. Make one on <a href="https://openvibe.services/">OpenVibe.Services</a>, then come back to give it zones.</p>`}`,++ });++ });++++ // ── One project ──────────────────────────────────────────++ async function projectView(req, res, project, { notice = null, secret = null, error = null } = {}) {++ const me = person(req);++ if (!me) return signInPage(req, res, `/p/${project}`);++ if (!PROJECT_RE.test(project)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such project.</p>` }, 404);++ let role, projects;++ try {++ projects = await clients.projectsOf(me.token);++ role = await clients.memberRole(project, me.subject);++ } catch (err) {++ return page(req, res, { title: 'Project', body: html`<h1>Project</h1><p class="zn-error">Network could not be asked about this project (${err.message}). Try again in a moment.</p>` }, 503);++ }++ const p = projects.find((x) => x.id === project);++ if (!p || !role) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such project of yours. <a href="/projects">Your projects</a>.</p>` }, 404);++ const admin = ADMIN.has(role);++ const envs = p.environments && p.environments.length ? p.environments : ['sandbox'];++ for (const env of envs) await zones.ensureDefault(project, env);++ const list = (await zones.list(project, {})).zones;++ const usage = new Map();++ await Promise.all(list.filter((z) => z.state !== 'deleted').map(async (z) => {++ const rep = await clients.report(z.id).catch(() => null);++ if (rep) usage.set(z.id, rep);++ }));++ let keys = [];++ let keysError = null;++ if (admin) { try { keys = await clients.listKeys(project, me.subject); } catch (err) { keysError = err.message; } }++ const zoneRow = (z) => {++ const rep = usage.get(z.id);++ const u = rep && rep.usage;++ return html`<tr id="${z.id}"><td><code>${z.name}</code>${z.default ? html` <span class="zn-badge">default</span>` : ''}</td><td>${z.state}</td>++<td class="zn-n">${u ? `${bytes(u.stored_bytes)} · ${plural(u.objects, 'object', 'objects')}` : '—'}</td>++<td class="zn-n">${z.quota_bytes ? bytes(z.quota_bytes) : (z.default ? 'environment' : '—')}</td><td>${day(z.created_at)}</td>++<td>${admin && !z.default && z.state === 'active' ? html`<details><summary>Delete</summary><form method="post" action="/p/${project}/zones/${z.id}/delete">++<p>This deletes every object in <code>${z.name}</code>, with no undo, and the name can never be used again.</p>++<label>Type the zone's name to confirm <input name="confirm" required autocomplete="off" pattern="${z.name}"></label>++<button class="zn-button zn-danger" type="submit">Delete ${z.name}</button></form></details>` : ''}</td></tr>`;++ };++ const envSection = (env) => {++ const mine = list.filter((z) => z.env === env);++ return html`<section class="zn-section" aria-labelledby="zn-env-${env}"><h2 id="zn-env-${env}">${env === 'production' ? 'Production' : 'Sandbox'}</h2>++<table class="zn-table"><thead><tr><th scope="col">Zone (bucket)</th><th scope="col">State</th><th scope="col" class="zn-n">Stored</th><th scope="col" class="zn-n">Quota</th><th scope="col">Created</th><th scope="col"><span class="zn-sr">Actions</span></th></tr></thead>++<tbody>${mine.map(zoneRow)}</tbody></table>++${admin ? html`<form class="zn-form" method="post" action="/p/${project}/zones"><input type="hidden" name="env" value="${env}">++<label>New zone <input name="name" required minlength="3" maxlength="63" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="assets" autocomplete="off"></label>++<label>Quota (MB, optional) <input name="quota_mb" type="number" min="1" step="1" inputmode="numeric"></label>++<button class="zn-button" type="submit">Create zone</button></form>` : ''}</section>`;++ };++ const keysSection = admin ? html`<section class="zn-section" aria-labelledby="zn-keys"><h2 id="zn-keys">S3 access keys</h2>++${keysError ? html`<p class="zn-error">The keys could not be read just now (${keysError}).</p>` : ''}++${keys.length ? html`<table class="zn-table"><thead><tr><th scope="col">Name</th><th scope="col">Key id</th><th scope="col">Environment</th><th scope="col">Can</th><th scope="col">Zones</th><th scope="col">Last used</th><th scope="col"><span class="zn-sr">Actions</span></th></tr></thead>++<tbody>${keys.map((k) => html`<tr class="${k.revoked_at ? 'zn-revoked' : ''}"><td>${k.name}</td><td><code>${k.id}</code></td><td>${k.env}</td><td>${k.permissions}</td>++<td>${k.zones.length ? k.zones.map((id) => (list.find((z) => z.id === id) || {}).name || id).join(', ') : 'every zone'}</td><td>${day(k.last_used_at)}</td>++<td>${k.revoked_at ? `revoked ${day(k.revoked_at)}` : html`<form method="post" action="/p/${project}/keys/${k.id}/revoke"><button class="zn-button zn-danger" type="submit">Revoke</button></form>`}</td></tr>`)}</tbody></table>`++ : html`<p class="zn-muted">No keys yet.</p>`}++<form class="zn-form" method="post" action="/p/${project}/keys">++<label>Name <input name="name" required maxlength="80" placeholder="ci deploys" autocomplete="off"></label>++<label>Environment <select name="env">${envs.map((e) => html`<option value="${e}">${e}</option>`)}</select></label>++<label>Can <select name="permissions"><option value="read-write">read and write</option><option value="read">read</option><option value="write">write</option></select></label>++<fieldset><legend>Zones (none ticked: every zone of the environment)</legend>${list.filter((z) => z.state === 'active').map((z) => html`<label class="zn-check"><input type="checkbox" name="zones" value="${z.id}"> ${z.name} (${z.env})</label>`)}</fieldset>++<button class="zn-button" type="submit">Issue key</button></form></section>` : html`<p class="zn-muted">Only an admin or owner of this project manages its zones and keys.</p>`;++ page(req, res, {++ title: `${p.name}: zones`,++ body: html`<nav class="crumbs" aria-label="Breadcrumbs"><a href="/projects">Your projects</a> / ${p.name}</nav>++<h1>${p.name}</h1>++${notice ? html`<p class="zn-notice" role="status">${notice}</p>` : ''}++${error ? html`<p class="zn-error" role="alert">${error}</p>` : ''}++${secret ? html`<div class="zn-secret" role="alert"><h2>Your new key</h2><p>Copy the secret now: it is shown only once.</p>++<dl><dt>Access key id</dt><dd><code>${secret.key.id}</code></dd><dt>Secret access key</dt><dd><code>${secret.secret}</code></dd><dt>Endpoint</dt><dd><code>${s3}</code> (path style, region <code>us-east-1</code>)</dd></dl></div>` : ''}++${envs.map(envSection)}++${keysSection}++<p class="zn-muted">Endpoint: <code>${s3}</code>. <a href="/docs">How to use it</a>.</p>`,++ cache: null,++ }, error ? 400 : 200);++ }++ r.get('/p/:project', (req, res) => projectView(req, res, req.params.project));++++ /** A form post: signed in, same origin, an admin; then the action, then the project page again. */++ function action(fn) {++ return [form, async (req, res) => {++ const project = req.params.project;++ const me = person(req);++ if (!me) return signInPage(req, res, `/p/${project}`);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Refused', body: html`<h1>Refused</h1><p>A change must come from this site's own page.</p>` }, 403);++ if (!PROJECT_RE.test(project)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);++ try {++ const role = await clients.memberRole(project, me.subject);++ if (!ADMIN.has(role)) return page(req, res, { title: 'Refused', body: html`<h1>Refused</h1><p>Only an admin or owner of this project changes its zones and keys.</p>` }, 403);++ const out = await fn(req, me, project);++ return await projectView(req, res, project, out);++ } catch (err) {++ if (err instanceof ZoneError || err instanceof ClientError) return await projectView(req, res, project, { error: err.detail || err.message });++ throw err;++ }++ }];++ }++++ r.post('/p/:project/zones', ...action(async (req, me, project) => {++ const b = req.body || {};++ const name = String(b.name || '').trim().toLowerCase();++ if (RESERVED.has(name)) throw new ZoneError(400, 'zone.name_reserved', `${name} is reserved`);++ const quotaMb = b.quota_mb ? Number(b.quota_mb) : null;++ if (quotaMb !== null && !(Number.isInteger(quotaMb) && quotaMb >= 1)) throw new ZoneError(400, 'zone.bad_request', 'the quota is a whole number of MB');++ const out = await zones.create(project, { env: b.env, name, ...(quotaMb ? { quota_bytes: quotaMb * 1024 * 1024 } : {}) }, { caller: `user:${me.subject}` });++ return { notice: `Zone ${out.zone.name} is ${out.zone.state === 'active' ? 'ready' : 'being set up'} in ${out.zone.env}.` };++ }));++++ r.post('/p/:project/zones/:zone/delete', ...action(async (req, me, project) => {++ if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');++ const row = await zones.byId(req.params.zone);++ if (!row || row.project_id !== project) throw new ZoneError(404, 'zone.unknown', 'no such zone');++ if (String((req.body || {}).confirm || '') !== row.name) throw new ZoneError(400, 'zone.confirm', `type ${row.name} to delete it`);++ await zones.remove(row.id, { recursive: true });++ return { notice: `Zone ${row.name} is being deleted: it answers "no such bucket" already, and its objects are being erased.` };++ }));++++ r.post('/p/:project/keys', ...action(async (req, me, project) => {++ const b = req.body || {};++ const picked = [].concat(b.zones || []).filter((z) => ZONE_RE.test(String(z)));++ const out = await clients.issueKey(project, { env: b.env, name: String(b.name || '').trim(), permissions: b.permissions || 'read-write', zones: picked, on_behalf_of: me.subject });++ return { notice: `Key ${out.key.name} is issued.`, secret: out };++ }));++++ r.post('/p/:project/keys/:key/revoke', ...action(async (req, me, project) => {++ if (!KEY_RE.test(req.params.key)) throw new ZoneError(404, 'zone.unknown_key', 'no such key');++ const k = await clients.revokeKey(project, req.params.key, me.subject);++ return { notice: `Key ${k.name} is revoked: every request signed with it is refused from now on.` };++ }));++++ // ── Docs ─────────────────────────────────────────────────++ r.get('/docs', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'Using OpenVibe.Zone',++ description: 'Create zones, issue S3 access keys and reach your buckets with the AWS SDKs, rclone and any S3 tool: the OpenVibe.Zone guide and API.',++ body: html`<h1>Using OpenVibe.Zone</h1>++<p class="zn-lede">A zone is an S3 bucket of one project environment. You make zones here or with the API, issue access keys, and point any S3 tool at <code>${s3}</code>.</p>++<h2 id="s3">The S3 endpoint</h2>++<ul class="zn-list">++<li><strong>Endpoint:</strong> <code>${s3}</code>, path style (<code>/<zone>/<key></code>), region <code>us-east-1</code>, Signature Version 4.</li>++<li><strong>Operations:</strong>++ <ul>++ <li>objects: PutObject, GetObject (ranges), HeadObject, DeleteObject, DeleteObjects;</li>++ <li>listing: ListObjectsV2 and ListObjects (prefix, delimiter, pages), ListBuckets, HeadBucket;</li>++ <li>presigned GET and PUT URLs, valid for 7 days at most.</li>++ </ul></li>++<li><strong>Not yet supported:</strong> multipart uploads, CopyObject, ACLs, policies and versioning. Set your tool to upload in one request (AWS CLI: <code>aws configure set default.s3.multipart_threshold 100MB</code>; rclone: <code>upload_cutoff 100M</code>).</li>++<li><strong>Size:</strong> one object is at most 100 MB through this endpoint.</li>++<li><strong>Privacy:</strong> zones are private. Every request is signed. A presigned URL reaches only its own key, until it expires or its key is revoked.</li>++</ul>++<h2 id="keys">Access keys</h2>++<p>A key belongs to one project environment.</p>++<ul class="zn-list">++<li>It may read, write or both.</li>++<li>It may be limited to some zones; with none named, it reaches every zone of the environment.</li>++<li>Its secret is shown once, when it is issued. Revoking a key takes effect at once.</li>++<li>A key limited to one zone is revoked when that zone is deleted.</li>++</ul>++<h2 id="api">The API</h2>++<p>Base address: <code>${config.baseUrl}/api/v1</code>. Authenticate with a Network token: a developer app of the project (audience <code>openvibe.zone</code>, holding the route's capability; a sandbox app reaches only the sandbox), or a person who is a member of the project. Errors are RFC 9457 problem+json with a stable <code>code</code>.</p>++<ul class="zn-list">++<li><code>POST /projects/:project_id/zones</code> (<code>zone.zone.create</code>): <code>{ env, name, description?, quota_bytes? }</code> with an <code>Idempotency-Key</code> header. Answers 201 with the zone, or 409 with <code>zone.name_taken</code> or <code>zone.name_retired</code>.</li>++<li><code>GET /projects/:project_id/zones?env=&include_deleted=&cursor=&limit=</code> (<code>zone.zone.list</code>).</li>++<li><code>GET /zones/:zone_id</code> (<code>zone.zone.read</code>): the zone with its stored bytes and objects.</li>++<li><code>PATCH /zones/:zone_id</code> (<code>zone.zone.manage</code>): <code>{ description?, quota_bytes? }</code>.</li>++<li><code>DELETE /zones/:zone_id?recursive=true</code> (<code>zone.zone.delete</code>). Without <code>recursive</code>, a zone that still holds objects answers <code>zone.not_empty</code>. A zone holding an object under a retention hold answers <code>zone.held</code>.</li>++<li><code>GET /zones/:zone_id/usage</code> (<code>zone.usage.read</code>).</li>++</ul>++<p>The objects of a zone are OpenVibe.Media objects. Media's own API (<code>/api/v2/:project_id/objects</code>, with <code>namespace</code> set to the zone's id) reads and writes the same objects.</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: 'zone', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),++ }));++++ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──++ r.use(createDiscoveryRoutes(ctx));++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,117 @@++'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.zone; 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/zone.*). 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 = ['zone.zone.create', 'zone.zone.list', 'zone.zone.read', 'zone.zone.manage', 'zone.zone.delete', 'zone.usage.read',++ 'zone.resource.read', 'zone.resource.control'];++++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'] };++ // A developer app's sandbox token is accepted: it acts on its project's sandbox zones only (http/api.js).++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });++ 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.Zone'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 openvibe.zone, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.zone).', 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 openvibe.zone 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.Zone's capabilities`);++ return function readGuard(req, res, next) {++ const p = req.principal;++ if (p.kind !== 'app') return next();++ const decision = capabilities.check(p.claims, cap);++ if (decision.allowed) return next();++ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });++ };++ }++++ return { middleware, resolve, requireCapability: require_, requireRead };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the++ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'put', 'patch', 'delete']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * OpenVibe.Zone — process entry. `node server/index.js`++ * Listens on PORT (5050) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the zone job stops, the JWKS++ * refresher stops and the store closes. Exported so a test can inject `exit` and `signals: false`. `extra` is what++ * start() adds (the job).++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Zone', 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.Zone] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ });++ server.keepAliveTimeout = 65_000;++ ctx.keys.client.start();++ // The zone job (zone/zones.js tick): pushes what Media has not acknowledged and finishes deletions, every 15 s.++ ctx.zones.start();++ createLifecycle({ server, ctx, extra: [() => ctx.zones.stop()] });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Zone] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * db required a real round trip to OpenVibe.Zone'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: 'zone',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => {++ out += render(v) + strings[i + 1];++ });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++/** A <table> from headers and rows of cells (cells are already html or plain values). */++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */++function problemBox(p, { title = 'The request failed' } = {}) {++ if (!p) return '';++ return html`<div class="problem" role="alert"><strong>${title}</strong>++<p>${p.detail || 'No detail was given.'}</p>++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };nnew file mode 100644@@@ -0,0 +1,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.Zone';++const TAGLINE = 'Private S3-compatible storage';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++// The product's own navigation: one entry per public page it serves.++const NAV = [++ { label: 'Zone', href: '/' },++ { label: 'Your projects', href: '/projects' },++ { label: 'Docs', 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: 'zone',++ 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: 'zone', 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: 'zone' })}++<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="zone@${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: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++function render(v) { return require('./html').render(v); }++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ // A page rendered for a signed-in person names them in the account bar: never shared caches.++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++++/**++ * A deliberately small Markdown renderer for text a caller supplied or a model wrote. Everything is escaped first; only++ * headings, paragraphs, lists, fenced code, tables, bold, italics, inline code and links are recognised. Links are kept++ * only when http(s) or relative, and never vouched for: rel="nofollow ugc noopener", opened in a new tab.++ */++const { esc } = require('./html');++++function inline(s) {++ let out = esc(s);++ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');++ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');++ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');++ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {++ const h = href.replace(/&/g, '&');++ if (/^https?:\/\//.test(h)) return `<a href="${esc(h)}" rel="nofollow ugc noopener" target="_blank">${text}</a>`;++ if (/^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;++ return text;++ });++ // Bare links the model wrote out, outside an <a> already made above.++ out = out.replace(/(^|[\s(])(https?:\/\/[^\s<)"']+[^\s<)"'.,;:!?])/g, (m, pre, url) => `${pre}<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`);++ return out;++}++++const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());++const isSeparator = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);++++function markdown(src, { headingOffset = 1 } = {}) {++ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');++ const out = [];++ let para = [];++ let list = null; // { type: 'ul' | 'ol', items: [] }++ let fence = null;++ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };++ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };++ for (let i = 0; i < lines.length; i++) {++ const line = lines[i];++ if (fence) {++ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);++ continue;++ }++ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }++ // A table: a header row, a separator row, then rows while lines start with |.++ if (/^\s*\|/.test(line) && i + 1 < lines.length && isSeparator(lines[i + 1])) {++ flushPara(); flushList();++ const head = cells(line);++ const rows = [];++ i += 2;++ while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }++ i--;++ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);++ continue;++ }++ const h = line.match(/^(#{1,6})\s+(.*)$/);++ if (h) {++ flushPara(); flushList();++ const level = Math.min(6, h[1].length + headingOffset);++ out.push(`<h${level}>${inline(h[2])}</h${level}>`);++ continue;++ }++ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);++ if (li) {++ flushPara();++ const type = li[1] ? 'ol' : 'ul';++ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }++ list.items.push(li[2]);++ continue;++ }++ if (!line.trim()) { flushPara(); flushList(); continue; }++ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }++ flushList();++ para.push(line.trim());++ }++ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);++ flushPara(); flushList();++ return out.join('\n');++}++++module.exports = { markdown, inline };nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++++/**++ * Zone's calls to the two services it depends on, each with Zone's own client-credentials token (never a person's,++ * except where a person's own projects are listed):++ *++ * OpenVibe.Media (audience openvibe.media):++ * provision(record) PUT /internal/zones/:id media.zone.provision → { applied, state, usage, deletion }++ * report(zoneId) GET /internal/zones/:id → { state, usage, deletion } (null when Media has none)++ * issueKey(project, body) POST /api/v1/projects/:p/access-keys media.access_key.manage → { key, secret }++ * listKeys(project, person) GET /api/v1/projects/:p/access-keys?on_behalf_of=++ * revokeKey(project, keyId, person) DELETE …/access-keys/:id?on_behalf_of=++ *++ * OpenVibe.Network:++ * memberRole(project, subject) GET /internal/projects/:p/members/:s network.project.member.read → role | null++ * projectsOf(personToken) GET /api/v1/projects with the person's own token → their projects++ *++ * A failure is a ClientError with the status and problem code the other service answered (or 503 when it did not).++ */++const { serviceAuth } = require('openvibe-contracts');++++const TIMEOUT_MS = 10_000;++++class ClientError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++++function createClients({ config, fetchImpl = globalThis.fetch }) {++ const tokenUrl = `${config.networkInternalUrl}/oauth/token`;++ const secret = config.oauth.clientSecret;++ const mediaTokens = secret ? serviceAuth.createTokenClient({++ tokenUrl, clientId: config.oauth.clientId, clientSecret: secret, audience: 'openvibe.media',++ scope: 'media.zone.provision media.access_key.manage', fetchImpl,++ }) : null;++ const networkTokens = secret ? serviceAuth.createTokenClient({++ tokenUrl, clientId: config.oauth.clientId, clientSecret: secret, audience: 'openvibe.network',++ scope: 'network.project.member.read', fetchImpl,++ }) : null;++++ async function call(tokens, base, method, path, body) {++ if (!tokens) throw new ClientError(503, 'zone.unconfigured', 'OV_OAUTH_CLIENT_SECRET is not set: Zone cannot call its services');++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ } catch (err) {++ throw new ClientError(503, 'zone.dependency_unavailable', `${base} did not answer: ${err.message}`);++ }++ const data = await res.json().catch(() => null);++ if (!res.ok) throw new ClientError(res.status, (data && data.code) || `http_${res.status}`, (data && (data.detail || data.error)) || `answered ${res.status}`);++ return data;++ }++ const media = (method, path, body) => call(mediaTokens, config.media.internalUrl, method, path, body);++ const q = (person) => `on_behalf_of=${encodeURIComponent(person)}`;++++ return {++ provision: (record) => media('PUT', `/internal/zones/${record.id}`, record),++ async report(zoneId) {++ try { return await media('GET', `/internal/zones/${zoneId}`); } catch (err) {++ if (err.status === 404) return null;++ throw err;++ }++ },++ issueKey: (project, body) => media('POST', `/api/v1/projects/${project}/access-keys`, body),++ listKeys: async (project, person) => (await media('GET', `/api/v1/projects/${project}/access-keys?${q(person)}`)).keys,++ revokeKey: (project, keyId, person) => media('DELETE', `/api/v1/projects/${project}/access-keys/${encodeURIComponent(keyId)}?${q(person)}`),++++ async memberRole(project, subject) {++ try {++ const m = await call(networkTokens, config.networkInternalUrl, 'GET', `/internal/projects/${project}/members/${subject}`);++ return m && m.project_id === project && m.subject === subject && m.archived === false ? m.role : null;++ } catch (err) {++ if (err.status === 404) return null;++ throw err;++ }++ },++ async projectsOf(personToken) {++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/api/v1/projects`, { headers: { Accept: 'application/json', Authorization: `Bearer ${personToken}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ } catch (err) { throw new ClientError(503, 'zone.dependency_unavailable', `Network did not answer: ${err.message}`); }++ const data = await res.json().catch(() => null);++ if (!res.ok) throw new ClientError(res.status, (data && data.code) || `http_${res.status}`, (data && data.detail) || `answered ${res.status}`);++ return (data && data.projects) || [];++ },++ };++}++++module.exports = { createClients, ClientError };nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++++/**++ * OpenVibe.Zone's zones (ADR-031 amendment 2026-10-02). A zone is a named object zone of one project environment; this++ * module owns its record and keeps OpenVibe.Media's projection of it current (clients.provision, by revision).++ *++ * ensureDefault(project, env) the environment's `default` zone, created with the first use of it++ * create(project, body, { caller, idempotencyKey }) → zone.object-zone@1 (provisioning until Media acknowledged)++ * get(id) / list(project, query) → zone.object-zone@1 / zone.object-zone-list@1++ * update(id, body) description and quota_bytes (the default zone's quota is the environment's)++ * remove(id, { recursive }) → the zone in state deleting; Media tombstones and purges its objects++ * usage(id) → zone.object-zone-usage@1 (Media's live snapshot; Zone never counts)++ * tick() pushes every change Media has not acknowledged, and marks a deleting zone++ * deleted once Media reports nothing left (the job runs it every 15 s)++ *++ * A name belongs to one zone of its environment for ever (409 zone.name_taken while it lives, zone.name_retired once++ * it is deleting or deleted), so a request signed for a bucket can only ever reach the zone it was signed for.++ */++const crypto = require('crypto');++const contracts = require('openvibe-contracts');++++const RESERVED = new Set(['default', 'metrics']);++const PAGE = 50;++++class ZoneError 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 ZoneError(status, code, detail); };++const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());++++function createZones({ s, clients, config, log = console }) {++ const { db } = s;++++ /** The zone.object-zone@1 of a row (usage when given; deletion progress while it is deleting or deleted). */++ function record(row, { usage = null, deletion = null } = {}) {++ const z = {++ id: row.id, project_id: row.project_id, env: row.env, name: row.name, default: !!row.is_default,++ description: row.description ?? null, access: row.is_default ? 'per-object' : 'private', state: row.state,++ quota_bytes: row.quota_bytes == null ? null : Number(row.quota_bytes), revision: Number(row.revision),++ created_at: iso(row.created_at), updated_at: iso(row.updated_at),++ };++ Object.assign(z, contracts.zones.identity(z));++ if (usage) z.usage = { stored_bytes: usage.stored_bytes, objects: usage.objects, as_of: usage.as_of };++ if (row.state === 'deleting' || row.state === 'deleted') {++ z.deletion = {++ requested_at: iso(row.deletion_requested_at),++ objects_remaining: row.state === 'deleted' ? 0 : (deletion ? deletion.objects_remaining : 0),++ held_objects: row.state === 'deleted' ? 0 : (deletion ? deletion.held_objects : 0),++ completed_at: row.state === 'deleted' ? iso(row.deletion_completed_at) : null,++ };++ }++ const check = contracts.zones.checkObjectZone(z);++ if (!check.valid) throw new Error(`zone ${row.id} does not make a valid record: ${JSON.stringify(check.errors).slice(0, 300)}`);++ return z;++ }++++ const byId = async (id) => await db.maybe('SELECT * FROM zone_zones WHERE id = $1', [String(id || '')]);++++ /** Push a row's current record to Media; pushed_revision follows what Media acknowledged. false when Media refused or is down. */++ async function push(row) {++ try {++ const out = await clients.provision(record(row));++ await db.exec('UPDATE zone_zones SET pushed_revision = GREATEST(pushed_revision, $2) WHERE id = $1', [row.id, row.revision]);++ return out;++ } catch (err) {++ log.warn(`[Zone] Media has not taken ${row.id} r${row.revision} yet (${err.code || err.message}); retried by the job`);++ return null;++ }++ }++++ /** provisioning → active once Media holds the zone (two revisions: Media first learns of it, then serves it). */++ async function activate(row) {++ if (row.state !== 'provisioning') return row;++ if (row.pushed_revision < row.revision && !await push(row)) return row;++ const next = await db.maybe(`UPDATE zone_zones SET state = 'active', revision = revision + 1, updated_at = $2++ WHERE id = $1 AND state = 'provisioning' RETURNING *`, [row.id, s.now()]);++ if (!next) return await byId(row.id);++ await push(next);++ return await byId(row.id);++ }++++ async function insert(project, env, name, { isDefault = false, description = null, quota = null }) {++ const id = s.newId('zon');++ await db.exec(`INSERT INTO zone_zones (id, project_id, env, name, is_default, description, state, quota_bytes, revision, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, 1, $8)`, [id, project, env, name, isDefault, description, quota, s.now()]);++ return await activate(await byId(id));++ }++++ async function ensureDefault(project, env) {++ const cur = await db.maybe("SELECT * FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = 'default'", [project, env]);++ if (cur) return cur.state === 'provisioning' ? await activate(cur) : cur;++ try { return await insert(project, env, 'default', { isDefault: true }); } catch (err) {++ // Two first uses raced: the other one made it.++ const again = await db.maybe("SELECT * FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = 'default'", [project, env]);++ if (again) return again;++ throw err;++ }++ }++++ async function create(project, body = {}, { caller, idempotencyKey = null } = {}) {++ const v = contracts.validate('zone.object-zone-create-request@1', body);++ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));++ if (RESERVED.has(body.name)) fail(400, 'zone.name_reserved', `${body.name} is reserved`);++ const digest = crypto.createHash('sha256').update(JSON.stringify([project, body.env, body.name, body.description ?? null, body.quota_bytes ?? null])).digest('hex');++ if (idempotencyKey) {++ const seen = await db.maybe('SELECT digest, zone_id FROM zone_idempotency WHERE caller = $1 AND idempotency_key = $2', [caller, idempotencyKey]);++ if (seen) {++ if (seen.digest !== digest) fail(409, 'zone.idempotency_conflict', 'this Idempotency-Key was used for another request');++ return { zone: record(await byId(seen.zone_id)), repeat: true };++ }++ }++ await ensureDefault(project, body.env);++ const taken = await db.maybe('SELECT state FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = $3', [project, body.env, body.name]);++ if (taken) fail(409, ['deleting', 'deleted'].includes(taken.state) ? 'zone.name_retired' : 'zone.name_taken',++ ['deleting', 'deleted'].includes(taken.state) ? `${body.name} was a zone of this environment; a name is never given to a second zone` : `${body.name} is a zone of this environment already`);++ const count = Number(await db.value("SELECT COUNT(*) FROM zone_zones WHERE project_id = $1 AND env = $2 AND state <> 'deleted' AND NOT is_default", [project, body.env]));++ if (count >= config.maxZonesPerEnv) fail(403, 'zone.quota_exceeded', `an environment holds at most ${config.maxZonesPerEnv} zones besides its default`);++ let row;++ try {++ row = await insert(project, body.env, body.name, { description: body.description ?? null, quota: body.quota_bytes ?? null });++ } catch (err) {++ if (/unique|duplicate/i.test(String(err.message))) fail(409, 'zone.name_taken', `${body.name} is a zone of this environment already`);++ throw err;++ }++ if (idempotencyKey) {++ await db.exec('INSERT INTO zone_idempotency (caller, idempotency_key, digest, zone_id, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING',++ [caller, idempotencyKey, digest, row.id, s.now()]);++ }++ return { zone: record(row), repeat: false };++ }++++ async function get(id, { withUsage = false } = {}) {++ const row = await byId(id);++ if (!row) fail(404, 'zone.unknown', 'no such zone');++ if (!withUsage) return { row, zone: record(row) };++ const rep = row.state === 'deleted' ? null : await clients.report(row.id).catch(() => null);++ return { row, zone: record(row, { usage: rep && rep.usage, deletion: rep && rep.deletion }) };++ }++++ async function list(project, query = {}) {++ const v = contracts.validate('zone.object-zone-list-query@1', query);++ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));++ const limit = Number(query.limit || PAGE);++ const includeDeleted = query.include_deleted === true || query.include_deleted === 'true';++ let after = '';++ if (query.cursor) {++ try { after = Buffer.from(String(query.cursor), 'base64url').toString('utf8'); } catch { after = ''; }++ if (!/^(sandbox|production)\|[a-z0-9-]{3,63}$/.test(after)) fail(400, 'zone.bad_request', 'cursor is the next_cursor of a previous page');++ }++ const rows = await db.many(`SELECT * FROM zone_zones WHERE project_id = $1 AND ($2::text IS NULL OR env = $2)++ AND ($3 OR state <> 'deleted') AND (env || '|' || name) > $4 ORDER BY env || '|' || name LIMIT $5`,++ [project, query.env || null, includeDeleted, after, limit + 1]);++ const page = rows.slice(0, limit);++ const last = page[page.length - 1];++ return { zones: page.map((r) => record(r)), next_cursor: rows.length > limit && last ? Buffer.from(`${last.env}|${last.name}`).toString('base64url') : null };++ }++++ async function update(id, body = {}) {++ const v = contracts.validate('zone.object-zone-update-request@1', body);++ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));++ const row = await byId(id);++ if (!row || row.state === 'deleted') fail(404, 'zone.unknown', 'no such zone');++ if (row.state === 'deleting') fail(409, 'zone.deleting', 'the zone is being deleted');++ if (row.is_default && body.quota_bytes !== undefined) fail(409, 'zone.default', 'the default zone is the environment: its quota is the environment\'s');++ const description = body.description !== undefined ? body.description : row.description;++ const quota = body.quota_bytes !== undefined ? body.quota_bytes : row.quota_bytes;++ const next = await db.maybe(`UPDATE zone_zones SET description = $2, quota_bytes = $3, revision = revision + 1, updated_at = $4++ WHERE id = $1 RETURNING *`, [id, description, quota, s.now()]);++ await push(next);++ return record(await byId(id));++ }++++ async function remove(id, { recursive = false } = {}) {++ const row = await byId(id);++ if (!row || row.state === 'deleted') fail(404, 'zone.unknown', 'no such zone');++ if (row.is_default) fail(409, 'zone.default', 'the default zone goes only with its environment');++ if (row.state === 'deleting') return record(row);++ const rep = await clients.report(row.id);++ if (rep && rep.deletion == null && rep.usage) {++ if (rep.usage.objects > 0 && !recursive) fail(409, 'zone.not_empty', `the zone holds ${rep.usage.objects} object(s): delete them first, or delete recursively`);++ }++ if (rep) {++ // A held object stays until its hold is released: refused before anything starts (ADR-031 section 10).++ const held = Number(rep.held_objects || 0);++ if (held > 0) fail(409, 'zone.held', `${held} object(s) of the zone are under a retention hold`);++ }++ const now = s.now();++ const next = await db.maybe(`UPDATE zone_zones SET state = 'deleting', revision = revision + 1, updated_at = $2, deletion_requested_at = $2++ WHERE id = $1 AND state IN ('provisioning', 'active') RETURNING *`, [id, now]);++ if (!next) return record(await byId(id));++ await push(next);++ return record(await byId(id), { deletion: { objects_remaining: rep && rep.usage ? rep.usage.objects : 0, held_objects: 0 } });++ }++++ async function usage(id) {++ const { row } = await get(id);++ const rep = row.state === 'deleted' ? null : await clients.report(row.id);++ const now = new Date(s.now());++ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));++ const current = rep && rep.usage ? rep.usage : { stored_bytes: 0, objects: 0, as_of: now.toISOString() };++ const out = {++ zone_id: row.id, ovrn: contracts.zones.identity({ ...row, default: row.is_default }).ovrn,++ current: { stored_bytes: current.stored_bytes, objects: current.objects, as_of: current.as_of },++ window_start: start.toISOString(), window_end: now.toISOString(), series: [],++ };++ return out;++ }++++ let ticking = false;++ async function tick() {++ if (ticking) return { pushed: 0, deleted: 0 };++ ticking = true;++ let pushed = 0, deleted = 0;++ try {++ for (const row of await db.many("SELECT * FROM zone_zones WHERE pushed_revision < revision OR state = 'deleting' ORDER BY id LIMIT 200")) {++ if (row.state === 'provisioning') { if ((await activate(row)).state === 'active') pushed++; continue; }++ if (row.pushed_revision < row.revision) { if (await push(row)) pushed++; continue; }++ if (row.state === 'deleting') {++ const rep = await clients.report(row.id).catch(() => null);++ if (rep && rep.deletion && rep.deletion.completed_at) {++ const done = await db.maybe(`UPDATE zone_zones SET state = 'deleted', revision = revision + 1, updated_at = $2, deletion_completed_at = $2++ WHERE id = $1 AND state = 'deleting' RETURNING *`, [row.id, s.now()]);++ if (done) { await push(done); deleted++; }++ }++ }++ }++ } finally { ticking = false; }++ return { pushed, deleted };++ }++++ let timer = null;++ function start(ms = 15_000) {++ if (timer) return;++ timer = setInterval(() => { tick().catch((err) => log.warn(`[Zone] tick: ${err.message}`)); }, ms);++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ return { record, byId, ensureDefault, create, get, list, update, remove, usage, tick, start, stop };++}++++module.exports = { createZones, ZoneError, RESERVED };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * The one cache rule the service serves assets with (openvibe-shared/cache-policy, plan T11): a URL++ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never++ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the++ * short public window with a day of stale-while-revalidate. The values are pinned here against++ * the module, not re-stated: the service never picks its own numbers.++ */++const assert = require('assert');++const cache = require('openvibe-shared/cache-policy');++const { assetVersion } = require('../server/render/layout');++const { boot, check, done } = require('./helpers/boot');++++const ASSET = 'css/app.css';++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';++++(async () => {++ const t = await boot();++ try {++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ });++++ await check('a wrong-but-hex ?v= is never pinned', async () => {++ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), SHORT);++ });++++ await check('no ?v= gets the short public window', async () => {++ const r = await t.get(`/${ASSET}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), SHORT);++ });++++ await check('those values are the module\'s, not the service\'s own', () => {++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The++ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a++ * rotation honoured at once, unknown-kid floods throttled. The service is a thin shim around it:++ * the boot reads the key and verifies a session/app token against the URL,++ * a JWKS outage with cached keys still verifies,++ * readiness (network_jwks) shows the cache state.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++function jwksServer({ doc = null, fail = null } = {}) {++ const seen = [];++ const srv = http.createServer((req, res) => {++ seen.push(req.url);++ if (fail) { res.writeHead(503); return res.end('down'); }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify(doc));++ });++ return new Promise((resolve) => {++ srv.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${srv.address().port}`,++ close: () => new Promise((r) => srv.close(r)),++ seen,++ setDoc: (d) => { doc = d; },++ setFail: (f) => { fail = f; },++ }));++ });++}++++function signJwt(claims, privateKey, { kid = null } = {}) {++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };++ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }++function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }++++(async () => {++ await check('a token signed by a key from a stub JWKS verifies', async () => {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });++ const { createKeyStore, jwksUrl } = require('../server/auth/keys');++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: console });++ const url = jwksUrl(config);++ await keys.ensure();++ const now = Math.floor(Date.now() / 1000);++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });++ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);++ assert.strictEqual(v.claims.username, 'demo');++ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');++ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);++ await jwks.close();++ });++++ // A rotation publishes two keys: verification must use the one the token names, not the first.++ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {++ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });++ const { createKeyStore } = require('../server/auth/keys');++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });++ const now = Math.floor(Date.now() / 1000);++ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['openvibe.zone'], 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: `zone_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: `zone_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: `zone_at=${expired}` });++ assert.ok(page.status < 400, `a page still renders: ${page.status}`);++ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');++ await t.close();++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id zone, cookies zone_at/zone_rt/zone_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'), 'zone');++ 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'), /zone_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, /zone_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /zone_rt=[^;]+;.*HttpOnly/i);++ const at = set.match(/zone_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `zone_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(/zone_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, 'zone');++ 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: 'zone' });++ const gone = await t.get('/api/v1/nope');++ assert.strictEqual(gone.status, 404);++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(gone.json().code, 'route.not_found');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Per-caller rate limits (server/http/caller-limits.js): past its limit one caller gets 429 problem+json++ * `rate_limited` with Retry-After, before the route does any work, while another caller still passes; the++ * window reopens on the clock. A person counts as themselves, anyone else by address. Reads take++ * ZONE_LIMITS_MINUTE/ZONE_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++ * zone_rate_limited_total.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { caller, BUDGETS } = require('../server/http/caller-limits');++++const READ = 'zone.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: { ZONE_LIMITS_MINUTE: '3', ZONE_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(), ['zone.lookup', 'zone.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('zone.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 zone_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('zone_rate_limited_total')).join('\n');++ assert.ok(/zone_rate_limited_total\{limit="zone\.read",window="minute"\} 2/.test(m), counted);++ const logs = t.logs();++ assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');++ assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');++ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');++ });++++ await check('who is counted', () => {++ assert.strictEqual(caller({ principal: { requester: 'app:app_x' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_x', 'a principal wins');++ assert.strictEqual(caller({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');++ assert.strictEqual(caller({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++// Crawl artifacts (server/http/discovery.js): robots.txt keeps crawlers off sign-in, the API and people's projects; the++// sitemap lists the public pages only; llms.txt says what a zone is and where the S3 endpoint is; llms-full.txt lists++// every public page.++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('robots.txt keeps crawlers off sign-in, the API and projects, and names the sitemap', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ for (const p of ['/auth/', '/api/', '/projects', '/p/']) assert.ok(r.text.includes(`Disallow: ${p}`), p);++ assert.match(r.text, /Sitemap: https:\/\/openvibe\.zone\/sitemap\.xml/);++ });++++ await check('the sitemap lists the public pages and nothing of anyone\'s project', async () => {++ const r = await t.get('/sitemap.xml');++ assert.match(r.headers.get('content-type'), /xml/);++ for (const loc of ['/', '/docs', '/updates']) assert.ok(r.text.includes(`<loc>https://openvibe.zone${loc}</loc>`), loc);++ assert.doesNotMatch(r.text, /\/p\/|\/projects/);++ });++++ await check('llms.txt says what a zone is and names the S3 endpoint', async () => {++ const r = await t.get('/llms.txt');++ assert.match(r.text, /^# OpenVibe\.Zone/m);++ assert.match(r.text, /https:\/\/s3\.openvibe\.media/);++ assert.match(r.text, /Using OpenVibe\.Zone/);++ });++++ await check('llms-full.txt lists every public page', async () => {++ const r = await t.get('/llms-full.txt');++ for (const s of ['OpenVibe.Zone home', 'Using OpenVibe.Zone', 'What shipped on OpenVibe.Zone']) assert.ok(r.text.includes(s), s);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way. The per-caller limits++// (server/http/caller-limits.js) count nobody unless a test asks: boot({ callerLimits: true, limitsNow }).++process.env.ZONE_RATE_LIMIT_PER_MIN = process.env.ZONE_RATE_LIMIT_PER_MIN || '100000';++process.env.ZONE_API_RATE_LIMIT_PER_MIN = process.env.ZONE_API_RATE_LIMIT_PER_MIN || '100000';++/**++ * Boots OpenVibe.Zone 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 (zone_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://openvibe.zone', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'zone', OV_OAUTH_CLIENT_SECRET: 'zone-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, clients: opts.clients });++ 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) => `zone_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await testdb.close();++ await network.close();++ },++ };++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * ZONE_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.ZONE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'zone', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,334 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served++ * the developer console that moved to OpenVibe.Services; what a service's tests use is sign-in and the JWKS.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `zone`, refresh_token,++ * client_credentials for `zone` 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 === 'zone') {++ if (body.client_secret !== 'zone-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:zone', 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. services with zone.resource.read). */++ serviceToken(slug, cap, { audience = 'openvibe.zone' } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub: `svc:${slug}`, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);++ },++ /** An authorization code the mock will exchange for this user if the verifier matches. */++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },++ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },++ setUsage(projectId, body) { st.usage.set(projectId, body); },++ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },++ setCatalog(list) { st.catalog = list; },++ setDown(v) { st.down = v; },++ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,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="(zone@[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], `zone@${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.Zone: Private S3-compatible storage</title>'), 'home title');++ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.zone/">'), '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="zone"', '/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.Zone'), 'the service name is on the page');++ assert.ok(r.text.includes('Private storage zones'), '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); });nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++// deploy/nginx/openvibe.zone.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/openvibe.zone.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=zone_api burst=30 nodelay;/);++assert.match(auth, /limit_req zone=zone_auth burst=10 nodelay;/);++for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);++const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];++assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');++console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/**++ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").++ * Code, client scripts, deploy files, the environment template and the manifest must not++ * mention it; the running service never sends it to Network (the Network mock refuses any request++ * that carries one), and a request that presents one gets no special treatment.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const ROOT = path.join(__dirname, '..');++const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;++++function files(dir) {++ const out = [];++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;++ const p = path.join(dir, e.name);++ if (e.isDirectory()) out.push(...files(p)); else out.push(p);++ }++ return out;++}++++(async () => {++ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {++ const targets = [++ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),++ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),++ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),++ ].filter((f) => fs.existsSync(f));++ assert.ok(targets.length > 15);++ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));++ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);++ });++++ const t = await boot();++ await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {++ const u = t.network.addUser('nia');++ for (const p of ['/', '/updates', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });++ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);++ assert.strictEqual(sent.length, 0);++ });++++ await check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {++ const r = await t.get('/api/v1/ping', { headers: { 'x-internal-key': 'anything' } });++ assert.strictEqual(r.status, 200);++ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });++ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and++// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;++// until 2026-09-26 it got through here.++const assert = require('assert');++const { sanitizeNext } = require('../server/auth/sso');++++const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };++const home = (v) => {++ const out = String(sanitizeNext(v, config));++ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;++ return host === 'site.openvibe.test' || host === 'openvibe.network';++};++for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',++ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {++ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);++ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));++}++assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');++console.log('open redirect: all checks passed');nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++// Size budgets for openvibe.zone'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: '', ZONE_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 (ZONE_PGLITE_DIR)');++ const m = await measure({ base });++ const over = check(m, BUDGETS);++ assert.deepStrictEqual(over, [], format(m, over));++ console.log(format(m));++ console.log('perf budget: all checks passed');++ } finally {++ child.kill();++ fs.rmSync(dir, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,23 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite++ * databases and an in-process mock of OpenVibe.Network; none needs the network or a running site.++ *++ * npm test # everything++ * npm test -- login # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ *++ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with++ * ○ and not counted as passed (openvibe-shared/test-runner).++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 60000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted++ * app (walking Express's router stack, so a route added later is crawled without anyone listing++ * it), fills route parameters with seeded values, and GETs each path as several people, reporting++ * any response whose body or headers carry a value that person must never see.++ * Not a test itself (no .test.js); used with test/helpers/boot.js.++ */++++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */++function mountPath(layer) {++ if (!layer.regexp || layer.regexp.fast_slash) return '';++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');++ let i = 0;++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;++}++++/** Every route of the booted app (captured from createApp): [{ path, methods }]. */++function listRoutes(app) {++ if (!app || !app._router) throw new Error('no Express app');++ const out = [];++ const walk = (stack, prefix) => {++ for (const layer of stack) {++ if (layer.route) {++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {++ const mp = mountPath(layer);++ if (mp !== null) walk(layer.handle.stack, prefix + mp);++ } else {++ const mp = mountPath(layer);++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });++ }++ }++ };++ walk(app._router.stack, '');++ const seen = new Set();++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });++}++++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */++function expand(template, values) {++ const names = [];++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });++ if (!names.length) return [t];++ const lists = names.map((n) => values(n));++ const width = Math.max(...lists.map((l) => l.length));++ const paths = new Set();++ for (let i = 0; i < width; i++) {++ let j = 0;++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));++ }++ return [...paths];++}++++/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */++function getPaths(app, values, { query = '', extra = [] } = {}) {++ const paths = new Set();++ for (const r of listRoutes(app)) {++ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;++ for (const p of expand(r.path, values)) {++ paths.add(p);++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);++ }++ }++ for (const p of extra) paths.add(p);++ return [...paths];++}++++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */++function leaks(res, needles) {++ const found = [];++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');++ for (const [label, value] of Object.entries(needles)) {++ if (!value) continue;++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });++ if (headerText.includes(value)) found.push({ label, where: 'headers' });++ }++ return found;++}++++/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */++let seq = 0;++function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }++++/**++ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs++ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person++ * must never see. Resolves { found, answered, statuses }.++ */++async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {++ const found = [];++ const statuses = {};++ let answered = 0;++ for (const [who, as] of Object.entries(people)) {++ const needles = needlesFor(who);++ const queue = [...paths];++ await Promise.all(Array.from({ length: concurrency }, async () => {++ while (queue.length) {++ const p = queue.shift();++ let r;++ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }++ if (r.status) answered++;++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';++ statuses[cls] = (statuses[cls] || 0) + 1;++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);++ }++ }));++ }++ return { found, answered, statuses };++}++++module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The service's own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the++ * internal-secret class). Its environment holds one: its Network OAuth client secret, a sentinel here. Every route++ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network++ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to++ * Network), unknown paths and every write route with a broken body. No body or header may carry it, nor may the++ * database or the captured log lines.++ *++ * node test/security-secrets.test.js++ */++const assert = require('assert');++const appModule = require('../server/app');++let capturedApp = null;++const realCreate = appModule.createApp;++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };++const { boot, check, done } = require('./helpers/boot');++const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');++++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client' };++++(async () => {++ const t = await boot({ env: SECRETS });++ const dev = t.network.addUser('dev');++ const staff = t.network.addUser('root', { role: 'admin' });++++ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {++ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];++ const values = () => ['zone-id', ...nonsense];++ const paths = getPaths(capturedApp, values, {++ query: 'q=x&code=forged&state=forged&next=%2F',++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],++ });++ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);++ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);++ assert.deepStrictEqual(r.found, []);++ });++++ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {++ const found = [];++ const values = () => ['x'];++ for (const route of listRoutes(capturedApp)) {++ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {++ for (const p of expand(route.path, values)) {++ for (const as of [null, dev]) {++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });++ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);++ }++ }++ }++ }++ assert.deepStrictEqual(found, []);++ });++++ await check('the database and the captured log lines carry no sentinel', async () => {++ const text = await t.dbDump() + t.logs();++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The zone_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 = `zone_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: `zone_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: 'zone_at=garbage' })).status, 401, 'a present but invalid access cookie');++ assert.strictEqual((await s.get('/auth/me', { cookie: 'zone_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(!/zone_at=;|zone_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') || ''), /zone_at=;/);++ });++++ await s.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/**++ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order++ * (the timers clear, the JWKS refresher stops, then the store closes: the order the manifest's++ * shutdown.drains lists) and resolves exit 0; a second signal while stopping is a no-op.++ */++const assert = require('assert');++const http = require('http');++const { check, done } = require('./helpers/boot');++const { createLifecycle } = require('../server/index');++++const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++++(async () => {++ await check('the entry point stop clears the timers, stops the JWKS refresher, then closes the store, then exits 0', async () => {++ const steps = [];++ const ctx = {++ keys: { client: { stop: () => { steps.push('keys.stop'); } } },++ s: { close: async () => { steps.push('store.close'); } },++ };++ const server = http.createServer((req, res) => res.end('ok'));++ await listen(server);++++ const exits = [];++ const timer = setInterval(() => steps.push('tick'), 1_000_000);++ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false, timers: [timer] });++ const code = await lifecycle.stop('SIGTERM');++++ assert.strictEqual(code, 0);++ assert.deepStrictEqual(exits, [0]);++ assert.deepStrictEqual(steps, ['keys.stop', 'store.close']);++ assert.strictEqual(server.listening, false);++ });++++ await check('a second signal while stopping is a no-op', async () => {++ let stops = 0; let closes = 0; let exits = 0;++ const ctx = {++ keys: { client: { stop: () => { stops += 1; } } },++ s: { close: async () => { closes += 1; } },++ };++ const server = http.createServer((req, res) => res.end('ok'));++ await listen(server);++++ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });++ const first = lifecycle.stop('SIGTERM');++ const second = lifecycle.stop('SIGINT');++++ assert.strictEqual(first, second);++ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);++ assert.strictEqual(stops, 1);++ assert.strictEqual(closes, 1);++ assert.strictEqual(exits, 1);++ });++++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,244 @@++'use strict';++// OpenVibe.Zone end to end against a mock Network and a fake Media: default zones, creating with an Idempotency-Key,++// names taken and retired, provisioning by revision (provisioning → active), app tokens and the sandbox rule, member++// and admin rules, updates, usage, deletion (not empty, held, recursive, then deleted by the job), the resource index++// and control for Services, and the console pages (zones and keys, the secret once, same-origin forms).++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot } = require('./helpers/boot');++++const checks = [];++const check = (name, fn) => checks.push([name, fn]);++++/** Media as Zone sees it: provisions recorded, reports and keys from a small state. */++function fakeMedia(network) {++ const zones = new Map(); // id → { record, objects, held, completed }++ const pushes = [];++ const keys = [];++ let down = false;++ const fail = (status, code, message) => Object.assign(new Error(message), { status, code });++ const { ClientError } = require('../server/zone/clients');++ return {++ pushes, zones, keys, setDown: (v) => { down = v; },++ async provision(record) {++ if (down) throw new ClientError(503, 'zone.dependency_unavailable', 'media down');++ const check = contracts.zones.checkObjectZone(record);++ if (!check.valid) throw new ClientError(400, 'media.zone.invalid', JSON.stringify(check.errors));++ pushes.push({ id: record.id, state: record.state, revision: record.revision });++ const cur = zones.get(record.id) || { objects: 0, held: 0, completed: false };++ cur.record = record;++ zones.set(record.id, cur);++ return { applied: true };++ },++ async report(id) {++ const z = zones.get(id);++ if (!z) return null;++ return {++ zone_id: id, revision: z.record.revision, state: z.record.state, held_objects: z.held,++ usage: { stored_bytes: z.objects * 100, objects: z.objects, as_of: new Date().toISOString() },++ deletion: z.record.state === 'deleting' ? { requested_at: new Date().toISOString(), objects_remaining: z.completed ? 0 : z.objects, held_objects: 0, completed_at: z.completed ? new Date().toISOString() : null } : null,++ };++ },++ async issueKey(project, body) {++ const key = { id: `ovk_01JZ0000000000000000000K${String(keys.length).padStart(2, '0')}`, project_id: project, env: body.env, name: body.name, permissions: body.permissions, zones: body.zones || [], created_by: body.on_behalf_of, created_at: new Date().toISOString(), last_used_at: null, revoked_at: null };++ keys.push(key);++ return { key, secret: 'S'.repeat(40) };++ },++ async listKeys(project) { return keys.filter((k) => k.project_id === project); },++ async revokeKey(project, id) { const k = keys.find((x) => x.id === id && x.project_id === project); if (!k) throw fail(404, 'media.access_key.unknown', 'no such key'); k.revoked_at = new Date().toISOString(); return k; },++ async memberRole(project, subject) {++ const m = network.state.members.get(project);++ const p = network.state.projects.get(project);++ return m && p && !p.archived_at ? (m.get(subject) || null) : null;++ },++ async projectsOf(token) {++ const res = await fetch(`${network.url}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}` } });++ return (await res.json()).projects;++ },++ };++}++++(async () => {++ const { startNetwork } = require('./helpers/mocks');++ const net = await startNetwork();++ let media;++ const t = await boot({ clients: new Proxy({}, { get: (_o, k) => (...a) => media[k](...a) }) });++ await net.close();++ media = fakeMedia(t.network);++ const owner = t.network.addUser('owen');++ const viewer = t.network.addUser('vera');++ const stranger = t.network.addUser('sam');++ const mk = await fetch(`${t.network.url}/api/v1/projects`, { method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Shop' }) });++ const project = (await mk.json()).id;++ t.network.state.members.get(project).set(viewer.subject, 'viewer');++ t.network.setEnvironmentPolicy(project, 'sandbox+production');++ const same = { 'sec-fetch-site': 'same-origin' };++++ check('a member lists the project\'s zones; the default zone of each environment exists from then on', async () => {++ const r = await t.get(`/api/v1/projects/${project}/zones`, { as: viewer });++ assert.strictEqual(r.status, 200, r.text);++ const list = r.json();++ assert.ok(contracts.zones.checkObjectZoneList(list).valid, JSON.stringify(contracts.zones.checkObjectZoneList(list).errors));++ assert.deepStrictEqual(list.zones.map((z) => [z.env, z.name, z.state, z.access]).sort(), [['production', 'default', 'active', 'per-object'], ['sandbox', 'default', 'active', 'per-object']]);++ assert.ok(media.pushes.filter((p) => p.state === 'provisioning').length === 2 && media.pushes.filter((p) => p.state === 'active').length === 2, 'Media hears of each zone, then that it is active');++ assert.strictEqual((await t.get(`/api/v1/projects/${project}/zones`, { as: stranger })).status, 404, 'a stranger is told there is no such project');++ });++++ let assets;++ check('an admin creates a zone; a repeat with the same Idempotency-Key answers the same zone', async () => {++ const body = { env: 'production', name: 'assets', quota_bytes: 5_000_000 };++ let r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: body, headers: { ...same, 'idempotency-key': 'create-assets-1' } });++ assert.strictEqual(r.status, 201, r.text);++ assets = r.json();++ assert.ok(contracts.zones.checkObjectZone(assets).valid);++ assert.deepStrictEqual([assets.state, assets.access, assets.revision, assets.media_namespace], ['active', 'private', 2, `app.${project}.${assets.id}`]);++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: body, headers: { ...same, 'idempotency-key': 'create-assets-1' } });++ assert.deepStrictEqual([r.status, r.json().id], [201, assets.id]);++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { ...body, quota_bytes: 1 }, headers: { ...same, 'idempotency-key': 'create-assets-1' } });++ assert.strictEqual(r.json().code, 'zone.idempotency_conflict');++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'assets' }, headers: same });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.name_taken']);++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'metrics' }, headers: same });++ assert.strictEqual(r.json().code, 'zone.name_reserved');++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'Bad_Name' }, headers: same });++ assert.strictEqual(r.json().code, 'zone.bad_request');++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: viewer, json: { env: 'production', name: 'mine' }, headers: same });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'zone.not_admin'], 'a viewer cannot create');++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'xsite' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(r.json().code, 'request.cross_site', 'a cookie write from another site is refused');++ });++++ check('while Media is down a new zone stays provisioning, and the job activates it later', async () => {++ media.setDown(true);++ let r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'later' }, headers: same });++ assert.deepStrictEqual([r.status, r.json().state], [201, 'provisioning']);++ const id = r.json().id;++ media.setDown(false);++ await t.ctx.zones.tick();++ r = await t.get(`/api/v1/zones/${id}`, { as: owner });++ assert.strictEqual(r.json().state, 'active');++ });++++ check('an app token of the project acts with its capability; a sandbox app only in the sandbox', async () => {++ const res = await fetch(`${t.network.url}/api/v1/projects/${project}/apps`, { method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'deployer', environment: 'sandbox' }) });++ const app = await res.json();++ const tok = t.network.mintApp(app.id, 'openvibe.zone', ['zone.zone.create', 'zone.zone.list', 'zone.zone.read']);++ let r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok, json: { env: 'sandbox', name: 'ci-cache' } });++ assert.strictEqual(r.status, 201, r.text);++ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok, json: { env: 'production', name: 'ci-prod' } });++ assert.deepStrictEqual([r.status, r.json().code], [403, 'zone.sandbox_only']);++ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok });++ assert.ok(r.json().zones.every((z) => z.env === 'sandbox'), 'a sandbox app lists the sandbox');++ r = await t.get(`/api/v1/zones/${assets.id}`, { bearer: tok });++ assert.strictEqual(r.status, 404, 'nor reads a production zone');++ const noCap = t.network.mintApp(app.id, 'openvibe.zone', ['zone.zone.list']);++ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: noCap, json: { env: 'sandbox', name: 'nope' } });++ assert.strictEqual(r.status, 403, 'without zone.zone.create, no create');++ });++++ check('update: description and quota; the default zone keeps the environment\'s quota', async () => {++ let r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner, method: 'PATCH', json: { description: 'site files', quota_bytes: 9_000_000 }, headers: same });++ assert.deepStrictEqual([r.status, r.json().description, r.json().quota_bytes, r.json().revision], [200, 'site files', 9_000_000, 3]);++ assert.deepStrictEqual(media.pushes.filter((p) => p.id === assets.id).map((p) => p.revision), [1, 2, 3], 'every revision reached Media');++ const def = (await t.get(`/api/v1/projects/${project}/zones?env=production`, { as: owner })).json().zones.find((z) => z.default);++ r = await t.get(`/api/v1/zones/${def.id}`, { as: owner, method: 'PATCH', json: { quota_bytes: 10 }, headers: same });++ assert.strictEqual(r.json().code, 'zone.default');++ r = await t.get(`/api/v1/zones/${def.id}`, { as: owner, method: 'DELETE', headers: same });++ assert.strictEqual(r.json().code, 'zone.default', 'and cannot be deleted on its own');++ });++++ check('usage is Media\'s snapshot, in zone.object-zone-usage@1', async () => {++ media.zones.get(assets.id).objects = 3;++ const r = await t.get(`/api/v1/zones/${assets.id}/usage`, { as: viewer });++ assert.strictEqual(r.status, 200);++ assert.ok(contracts.zones.checkObjectZoneUsage(r.json()).valid, JSON.stringify(contracts.zones.checkObjectZoneUsage(r.json()).errors));++ assert.deepStrictEqual([r.json().current.objects, r.json().current.stored_bytes], [3, 300]);++ const z = (await t.get(`/api/v1/zones/${assets.id}`, { as: viewer })).json();++ assert.deepStrictEqual(z.usage.objects, 3);++ });++++ check('deletion: not empty, held, then recursive; the job marks it deleted and its name is retired', async () => {++ let r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner, method: 'DELETE', headers: same });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.not_empty']);++ media.zones.get(assets.id).held = 1;++ r = await t.get(`/api/v1/zones/${assets.id}?recursive=true`, { as: owner, method: 'DELETE', headers: same });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.held']);++ media.zones.get(assets.id).held = 0;++ r = await t.get(`/api/v1/zones/${assets.id}?recursive=true`, { as: owner, method: 'DELETE', headers: same });++ assert.deepStrictEqual([r.status, r.json().state], [202, 'deleting']);++ assert.ok(contracts.zones.checkObjectZone(r.json()).valid);++ assert.strictEqual(media.zones.get(assets.id).record.state, 'deleting', 'Media hears of it at once');++ await t.ctx.zones.tick();++ assert.strictEqual((await t.get(`/api/v1/zones/${assets.id}`, { as: owner })).json().state, 'deleting', 'deleted only once Media reports nothing left');++ media.zones.get(assets.id).completed = true;++ await t.ctx.zones.tick();++ r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner });++ assert.deepStrictEqual([r.json().state, r.json().deletion.objects_remaining, !!r.json().deletion.completed_at], ['deleted', 0, true]);++ assert.ok(contracts.zones.checkObjectZone(r.json()).valid);++ assert.strictEqual(media.zones.get(assets.id).record.state, 'deleted');++ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'assets' }, headers: same });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.name_retired'], 'a deleted zone\'s name is never given again');++ r = await t.get(`/api/v1/projects/${project}/zones?env=production`, { as: owner });++ assert.ok(!r.json().zones.some((z) => z.id === assets.id), 'deleted zones leave the list');++ r = await t.get(`/api/v1/projects/${project}/zones?env=production&include_deleted=true`, { as: owner });++ assert.ok(r.json().zones.some((z) => z.id === assets.id), 'unless asked for');++ });++++ check('Services: the resource index and a delete through the control API, for an admin only, once', async () => {++ const svc = t.network.serviceToken('services', ['zone.resource.read', 'zone.resource.control']);++ let r = await t.get(`/api/v1/resources?project=${project}`, { bearer: svc });++ assert.strictEqual(r.status, 200, r.text);++ const later = r.json().resources.find((x) => x.name === 'later (production)');++ assert.ok(later && later.ovrn === `ovrn:zone:${project}:object-zone/${later.id}` && later.kind === 'zone.object-zone');++ r = await t.get(`/api/v1/resources/${encodeURIComponent(later.ovrn)}`, { bearer: svc });++ assert.strictEqual(r.json().id, later.id);++ const req = (who, key) => ({ project_id: project, resource: later.ovrn, action: 'delete', on_behalf_of: { type: 'user', id: who.subject }, idempotency_key: key });++ r = await t.get('/api/v1/resources/control', { bearer: svc, json: req(viewer, 'ctl-viewer-0001') });++ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'a viewer cannot delete');++ r = await t.get('/api/v1/resources/control', { bearer: svc, json: req(owner, 'ctl-owner-00001') });++ assert.deepStrictEqual([r.json().state, r.json().result.status], ['done', 'deleting'], r.text);++ const again = await t.get('/api/v1/resources/control', { bearer: svc, json: req(owner, 'ctl-owner-00001') });++ assert.deepStrictEqual(again.json(), r.json(), 'a repeat returns the stored answer');++ r = await t.get(`/api/v1/resources?project=${project}`, { bearer: t.network.serviceToken('services', ['zone.zone.read']) });++ assert.strictEqual(r.status, 403, 'the index needs zone.resource.read');++ });++++ check('the console: projects, zones, a key whose secret is shown once, and same-origin forms', async () => {++ let r = await t.get('/projects', { as: owner });++ assert.ok(r.status === 200 && r.text.includes('Shop') && r.text.includes(`/p/${project}`), r.text.slice(0, 400));++ r = await t.get(`/p/${project}`, { as: owner });++ assert.ok(r.status === 200 && r.text.includes('Production') && r.text.includes('Sandbox') && r.text.includes('Issue key'));++ r = await t.get(`/p/${project}/zones`, { as: owner, form: { env: 'production', name: 'photos', quota_mb: '5' }, headers: { origin: 'https://openvibe.zone' } });++ assert.ok(r.status === 200 && r.text.includes('Zone photos is ready in production'), r.text.slice(0, 500));++ r = await t.get(`/p/${project}/keys`, { as: owner, form: { env: 'production', name: 'backups', permissions: 'write' }, headers: same });++ assert.ok(r.text.includes('S'.repeat(40)) && r.text.includes('shown only once'), 'the secret is on the page that issued it');++ r = await t.get(`/p/${project}`, { as: owner });++ assert.ok(!r.text.includes('S'.repeat(40)) && r.text.includes('backups'), 'and on no other page');++ r = await t.get(`/p/${project}/keys/${media.keys[0].id}/revoke`, { as: owner, method: 'POST', form: {}, headers: same });++ assert.ok(r.text.includes('is revoked'));++ r = await t.get(`/p/${project}/zones`, { as: owner, form: { env: 'production', name: 'evil' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(r.status, 403, 'a form from another site is refused');++ r = await t.get(`/p/${project}`, { as: viewer });++ assert.ok(r.text.includes('Only an admin or owner') && !r.text.includes('Issue key'), 'a viewer sees the zones, not the forms');++ r = await t.get(`/p/${project}`, { as: stranger });++ assert.strictEqual(r.status, 404);++ r = await t.get(`/p/${project}`);++ assert.strictEqual(r.status, 401);++ r = await t.get('/');++ assert.ok(r.status === 200 && r.text.includes('s3.openvibe.media') && r.text.includes('Private storage zones'));++ r = await t.get('/docs');++ assert.ok(r.status === 200 && r.text.includes('zone.zone.create'));++ });++++ let failed = 0;++ try {++ for (const [name, fn] of checks) {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n${err.stack}`); }++ }++ } finally {++ await t.close();++ }++ if (failed) { console.log(`${failed} failed`); process.exit(1); }++ console.log('\nall passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# OpenVibe.Zone — environment (production: /etc/openvibe/zone.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5050
+HOST=127.0.0.1
+BASE_URL=https://openvibe.zone
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh zone: 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:zone:
+# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.
+#ZONE_LIMITS_MINUTE=120
+#ZONE_LIMITS_HOUR=3000
+
+# OpenVibe.Network: SSO (OAuth client `zone`, PKCE S256) and its JWKS; app tokens for audience openvibe.zone (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=zone
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.zone/auth/callback
+# ZONE_AUDIENCE=openvibe.zone
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
+
+# OpenVibe.Media: Zone provisions each zone there and asks it for usage, deletion progress and access keys (loopback,
+# with Zone's own client-credentials token: grants media.zone.provision and media.access_key.manage).
+MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+# The S3 endpoint the console and the docs name (s3.openvibe.media once its DNS record exists).
+ZONE_S3_ENDPOINT=https://s3.openvibe.media
+# The zones one project environment may hold besides its default.
+#ZONE_MAX_PER_ENV=20
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server scripts test
+ contracts-service: zone
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.log
+*.pem
+*.key
+!server/data/
+node_modules
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+# Changelog
+
+All notable changes to OpenVibe.Zone.
+
+## 0.1.0 — 2026-10-14
+
+- First release (ADR-031 amendment 2026-10-02, contracts 0.173.0).
+- **Zones:** named object zones per project environment.
+ - Each environment has a default zone.
+ - Names are never reused.
+ - Every change reaches OpenVibe.Media by revision; a zone is active only once Media has it.
+ - Deletion is recursive, refuses held objects and completes once Media reports nothing left.
+- **API:** for developer apps and project members, plus the resource index and control for OpenVibe.Services.
+- **Console:** at openvibe.zone, with each project's zones, their usage, and S3 access keys issued through Media. A secret is shown once.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+# OpenVibe.Zone
+
+Private S3-compatible storage zones for OpenVibe projects, at https://openvibe.zone. The design is ADR-031 in
+OpenVibe.Contracts, with its amendment of 2026-10-02.
+
+A **zone** is a named object zone of one project environment, sandbox or production. Any S3 tool reaches it as a
+bucket at `https://s3.openvibe.media`, path style.
+
+**Zone owns** the zone record:
+- its name, which is never reused;
+- its environment, state, description and per-zone quota;
+- the control API and the console.
+
+**OpenVibe.Media owns** everything inside a zone:
+- the objects, their bytes and replicas;
+- the S3 and native data APIs;
+- the access keys;
+- quota enforcement, usage counting and the one deletion path.
+
+Zone never lists keys and never holds bytes.
+
+## What it does
+
+- **Default zones.** Every project environment has a `default` zone. Its namespace is the environment root, so it holds the environment's existing objects. Zone creates it the first time the environment is used, and it cannot be deleted on its own.
+- **Named zones.** Created by name (3 to 63 of `a-z`, `0-9` and `-`), with an optional quota.
+ - A name belongs to one zone of its environment for ever: `zone.name_taken` while the zone lives, `zone.name_retired` after it is deleted. So a URL signed for a bucket can never reach a different zone.
+ - Named zones are private.
+- **Provisioning by revision.** Every change raises the zone's revision and is pushed to Media (`PUT /internal/zones/:id`, capability `media.zone.provision`). Media applies only a higher revision.
+ - A zone becomes `active` only after Media has it.
+ - The job (`zones.tick`, every 15 s) pushes whatever Media has not acknowledged yet, for example while Media restarts.
+- **Deletion.** A zone that still holds objects answers `zone.not_empty`, unless `recursive=true` is sent. A zone with an object under a retention hold answers `zone.held`.
+ - Once deletion starts, Media answers `NoSuchBucket` at once, then tombstones and purges every object.
+ - When Media reports nothing left, the job marks the zone `deleted`.
+ - Media revokes keys scoped only to that zone.
+- **Access keys.** The console issues, lists and revokes S3 keys through Media's API (`media.access_key.manage`), for a person who administers the project. A secret is shown once, on the page that issued it.
+- **Services.** OpenVibe.Services reaches Zone through two first-party routes:
+ - the resource index, `GET /api/v1/resources` (`zone.resource.read`);
+ - control, `POST /api/v1/resources/control` (`zone.resource.control`, action `delete`).
+
+## API
+
+Base `https://openvibe.zone/api/v1`. A caller is either:
+- a developer app of the project, with the route's capability for audience `openvibe.zone` (a sandbox app reaches only the sandbox), or
+- a member of the project: any role reads, and admins and owners change.
+
+| Route | Capability |
+|---|---|
+| `POST /projects/:project_id/zones` (Idempotency-Key) | `zone.zone.create` |
+| `GET /projects/:project_id/zones` | `zone.zone.list` |
+| `GET /zones/:zone_id` | `zone.zone.read` |
+| `PATCH /zones/:zone_id` | `zone.zone.manage` |
+| `DELETE /zones/:zone_id?recursive=true` | `zone.zone.delete` |
+| `GET /zones/:zone_id/usage` | `zone.usage.read` |
+| `GET /resources`, `GET /resources/:ovrn` | `zone.resource.read` (first-party) |
+| `POST /resources/control` | `zone.resource.control` (first-party) |
+
+## Run
+
+```sh
+npm ci
+npm test # PGlite; npm run test:pg on the PostgreSQL containers
+npm run dev
+```
+
+**Production:**
+- unit `openvibe-zone.service` on 127.0.0.1:5050, database `ov_zone`, env `/etc/openvibe/zone.env`;
+- nginx vhost `deploy/nginx/openvibe.zone.conf` on openvibe.zone's own certificate;
+- deployed with `sudo ovhost deploy zone`.
+
+**Grants it needs:**
+- `media.zone.provision` and `media.access_key.manage` (audience `openvibe.media`);
+- `network.project.member.read`.
+
+## Layout
+
+```
+server/zone/zones.js zone records, names, revisions, provisioning, deletion, the job
+server/zone/clients.js Media (provision, report, keys) and Network (roles, a person's projects)
+server/http/api.js /api/v1
+server/http/pages.js the console and docs; discovery.js robots.txt, sitemap.xml, llms.txt
+migrations/ the schema
+```
+
+## License
+
+AGPL-3.0, see LICENSE.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,
+openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable
+contact details are at `/.well-known/security.txt` on each site.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+{
+ "repository": "OpenVibers/OpenVibe.Zone",
+ "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": 5050,
+ "domain": "openvibe.zone",
+ "contracts": "openvibe-contracts v0.173.0",
+ "sdk": "openvibe-sdk v0.44.0",
+ "shared": "openvibe-shared v4.1.4",
+ "packages": [
+ "openvibe-contracts v0.173.0",
+ "openvibe-sdk v0.44.0",
+ "openvibe-shared v4.1.4"
+ ],
+ "summary": "OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects (ADR-031 amendment). Zone owns the zone record and its control API; OpenVibe.Media owns the objects, bytes, keys, quotas and the S3 surface at s3.openvibe.media.",
+ "works": [
+ "Default zone per project environment, created on first use",
+ "Named zones: create (Idempotency-Key), list, read with live usage, update description and quota, delete (recursive, refuses held objects)",
+ "Names never reused (name_taken, name_retired)",
+ "Provisioning in Media by revision; provisioning → active once Media acknowledges; a 15 s job retries and finishes deletions",
+ "Console: projects, zones, usage, S3 access keys issued through Media (secret shown once)",
+ "Resource index and control (delete) for OpenVibe.Services"
+ ],
+ "notYet": [
+ "Per-zone usage series (Media's hourly rollups with the zone's resource name)",
+ "api.openvibe.zone and s3.openvibe.media DNS records (owner)",
+ "Multipart uploads and CopyObject on Media's S3 surface"
+ ],
+ "capabilitiesRegistered": true,
+ "serviceManifestRegistered": true,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "Default zone per project environment, created on first use",
+ "Named zones: create (Idempotency-Key), list, read with live usage, update description and quota, delete (recursive, refuses held objects)",
+ "Names never reused (name_taken, name_retired)",
+ "Provisioning in Media by revision; provisioning → active once Media acknowledges; a 15 s job retries and finishes deletions",
+ "Console: projects, zones, usage, S3 access keys issued through Media (secret shown once)",
+ "Resource index and control (delete) for OpenVibe.Services"
+ ],
+ "deployment": "openvibe-ovh: unit openvibe-zone.service on 127.0.0.1:5050 (git checkout /opt/openvibe.zone, env /etc/openvibe/zone.env, database ov_zone)",
+ "updated": "2026-10-14"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.zone — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.zone.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5050, systemd unit openvibe-zone) renders every page and serves
+## the API; nginx terminates TLS with openvibe.zone's own certificate (openvibe.zone and *.openvibe.zone: a repo vhost
+## uses the certificate that really covers its name, or Cloudflare answers 526), rate-limits sign-in and the API, keeps
+## /internal/ off the internet, and compresses text. api.openvibe.zone is the same site once its DNS record exists;
+## www.openvibe.zone redirects to the apex. The bytes of a zone never pass here: they go to s3.openvibe.media.
+##
+## 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=zone_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=zone_api:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.zone api.openvibe.zone www.openvibe.zone;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.zone$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.zone;
+ ssl_certificate /etc/letsencrypt/live/openvibe.zone/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.zone/privkey.pem;
+ return 301 https://openvibe.zone$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.zone api.openvibe.zone;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.zone/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.zone/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.zone.access.log;
+ error_log /var/log/nginx/openvibe.zone.error.log;
+
+ # The API's JSON body cap is 32 KB and a console form 8 KB; 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=zone_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5050;
+ }
+
+ location /auth/ {
+ limit_req zone=zone_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5050;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=zone_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5050;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5050/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5050/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:5050;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+[Unit]
+Description=OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects.
+# Network (sign-in, JWKS) should be up first, but OpenVibe.Zone 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/openvibe.zone
+EnvironmentFile=/etc/openvibe/zone.env
+Environment=NODE_ENV=production
+Environment=PORT=5050
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.zone/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.Zone's own database is PostgreSQL on the host's data role; the code tree stays read-only.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+-- phase: expand
+-- OpenVibe.Zone (ADR-031 amendment 2026-10-02): named object zones per project environment. Zone owns the record;
+-- OpenVibe.Media owns everything inside a zone (objects, bytes, keys, quotas, the deletion path) and keeps a
+-- projection of each record, pushed with a higher revision on every change (pushed_revision is what Media has).
+
+CREATE TABLE zone_zones (
+ id text COLLATE "C" PRIMARY KEY, -- zon_<ULID>, never reused
+ project_id text COLLATE "C" NOT NULL,
+ env text COLLATE "C" NOT NULL CHECK (env IN ('sandbox', 'production')),
+ name text COLLATE "C" NOT NULL,
+ is_default boolean NOT NULL DEFAULT false,
+ description text,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('provisioning', 'active', 'deleting', 'deleted')),
+ quota_bytes bigint,
+ revision integer NOT NULL,
+ pushed_revision integer NOT NULL DEFAULT 0,
+ created_at bigint NOT NULL,
+ updated_at bigint,
+ deletion_requested_at bigint,
+ deletion_completed_at bigint,
+ -- A name belongs to one zone of its environment for ever, deleted or not.
+ UNIQUE (project_id, env, name)
+);
+CREATE INDEX zone_zones_project ON zone_zones (project_id, env, id);
+CREATE INDEX zone_zones_pending ON zone_zones (id) WHERE pushed_revision < revision OR state = 'deleting';
+
+-- The first answer to each create (caller, Idempotency-Key), returned again for a repeat.
+CREATE TABLE zone_idempotency (
+ caller text COLLATE "C" NOT NULL,
+ idempotency_key text COLLATE "C" NOT NULL,
+ digest text COLLATE "C" NOT NULL,
+ zone_id text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ PRIMARY KEY (caller, idempotency_key)
+);
+
+-- ADR-048 control requests from OpenVibe.Services: the first answer per (caller, project, idempotency_key).
+CREATE TABLE zone_control_log (
+ caller text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ idempotency_key text COLLATE "C" NOT NULL,
+ digest text COLLATE "C" NOT NULL,
+ response jsonb NOT NULL,
+ created_at bigint NOT NULL,
+ PRIMARY KEY (caller, project_id, idempotency_key)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1401 @@
@@ -0,0 +1,1401 @@
+{
+ "name": "openvibe-zone",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-zone",
+ "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.173.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.4",
+ "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.173.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",
+ "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",
+ "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.4",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.4",
+ "integrity": "sha512-tw1/uDkSj/js+mQjrabMGcZ6mfKRIBQLMd8SvCac2E2RAKiralwDKR+qNZL6oB8qB5ILEx6s8fJylQwVZr6poA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-zone",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Zone: private S3-compatible storage zones for OpenVibe projects (ADR-031)",
+ "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": "ZONE_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.173.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.4",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+/* OpenVibe.Zone — 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; }
+
+/* Zone */
+.zn-section { margin: 28px 0; }
+.zn-lede { font-size: 1.08rem; color: var(--text-secondary, #96a7c2); max-width: 820px; }
+.zn-muted { color: var(--text-muted, #7386a3); }
+.zn-list { padding-left: 1.2rem; max-width: 860px; }
+.zn-list li { margin: 6px 0; }
+.zn-code { max-width: 860px; }
+.zn-button { display: inline-block; padding: 8px 14px; border-radius: 10px; font-weight: 600; text-decoration: none; border: 1px solid var(--border-light, #2c3d5c);
+ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }
+.zn-button:hover { border-color: var(--accent, #3b82f6); }
+.zn-primary { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); border-color: transparent; }
+.zn-danger { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
+.zn-cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 14px; }
+.zn-card { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 16px; background: var(--bg-card, #131c2e); }
+.zn-card h2 { font-size: 1.1rem; margin: .2rem 0; }
+.zn-table { margin: 8px 0 12px; }
+.zn-section .zn-table { display: block; overflow-x: auto; }
+.zn-n { text-align: right; white-space: nowrap; }
+.zn-badge { font-size: .72rem; padding: 0 7px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
+.zn-revoked { opacity: .55; }
+.zn-form { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; margin: 10px 0; }
+.zn-form label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
+.zn-form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 6px 10px; margin: 0; flex-basis: 100%; }
+.zn-form .zn-check { flex-direction: row; align-items: center; display: inline-flex; margin-right: 12px; }
+.zn-form input, .zn-form select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); min-width: 0; }
+details summary { cursor: pointer; color: var(--error, #ef4444); }
+details form { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; max-width: 340px; }
+details form label { display: flex; flex-direction: column; gap: 4px; }
+details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
+.zn-notice { border-left: 3px solid var(--success, #22c55e); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
+.zn-error { border-left: 3px solid var(--error, #ef4444); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
+.zn-secret { border: 1px solid var(--warning, #f59e0b); border-radius: 12px; padding: 12px 16px; margin: 14px 0; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
+.zn-secret dt { color: var(--text-secondary, #96a7c2); font-size: .85rem; margin-top: 6px; }
+.zn-secret dd { margin: 0; }
+.zn-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
+@media (max-width: 600px) { .zn-form { flex-direction: column; align-items: stretch; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone — Express app factory; server/index.js listens, tests build their own instance with a temp database
+ * and a mock Network.
+ *
+ * /, /projects, /p/:project, /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 zone/zones.js (the zone records, provisioned in OpenVibe.Media) 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 { createClients } = require('./zone/clients');
+const { createZones } = require('./zone/zones');
+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 and Media), log, limitsNow, callerLimits (false: count nobody,
+ * tests only), valkey, clients (stand-ins for Media and Network, 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 clients = opts.clients || createClients({ config, fetchImpl });
+ const zones = createZones({ s, clients, config, log });
+ const ctx = { config, s, keys, sso, principal, log, clients, zones };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'zone', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'zone', 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());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-zone', 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: 'zone', service: 'zone', host: 'openvibe.zone', name: 'OpenVibe.Zone', 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 ─────────────────────────────────────────────────
+ // No CORS: a project's zones are managed from this site or by a server with its app token. Per address for the
+ // public; a first-party service calls over loopback, unproxied, and is counted per caller instead.
+ 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.ZONE_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.ZONE_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.Zone]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured
+ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline
+ * against it: the person's session token (sign-in).
+ *
+ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
+ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module
+ * is a thin shim so the rest of OpenVibe.Zone (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.Zone] 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.Zone] 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.Zone] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ /**
+ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for
+ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no
+ * key is loaded; a token that names no kid gets the first key.
+ */
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `zone`.
+ *
+ * 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 (zone_at) no script can read: with it, a script
+ * could act as them. The refresh token (zone_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 = 'zone_at';
+const REFRESH_COOKIE = 'zone_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'zone_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.Zone 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.Zone] 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.Zone?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared
+ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,
+ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone configuration. Every value comes from the environment (production: /etc/openvibe/zone.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, 5050);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.zone' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'zone',
+ 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.ZONE_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.ZONE_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 (ZONE_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ZONE_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:zone:' },
+
+ // 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.ZONE_AUDIENCE || 'openvibe.zone',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'zone',
+ 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.Media: Zone provisions each zone there and asks it for usage and deletion progress (loopback, with
+ // Zone's own service token); the S3 endpoint is what the docs and the console tell people to use.
+ media: {
+ internalUrl: trim(env.MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
+ s3Endpoint: trim(env.ZONE_S3_ENDPOINT || 'https://s3.openvibe.media'),
+ },
+ // The zones one project environment may hold besides its default.
+ maxZonesPerEnv: Math.max(1, int(env.ZONE_MAX_PER_ENV, 20)),
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone'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 zone)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Zone] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'zone', 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: 'zone-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'zone', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx(() => fn()),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Zone's API (capabilities: openvibe-contracts manifests/capabilities/zone.*; ADR-031 amendment).
+ *
+ * GET /ping public { ok }
+ * POST /projects/:project/zones zone.zone.create zone.object-zone-create-request@1, Idempotency-Key → 201
+ * GET /projects/:project/zones zone.zone.list ?env&include_deleted&cursor&limit → zone.object-zone-list@1
+ * GET /zones/:zone zone.zone.read zone.object-zone@1 with Media's live usage
+ * PATCH /zones/:zone zone.zone.manage description, quota_bytes
+ * DELETE /zones/:zone?recursive=true zone.zone.delete 202 zone.object-zone@1 (deleting)
+ * GET /zones/:zone/usage zone.usage.read zone.object-zone-usage@1
+ * GET /resources[?project&kind&cursor&limit] zone.resource.read first-party: common.resource-list-result@1
+ * GET /resources/:ovrn zone.resource.read first-party: common.resource-summary@1
+ * POST /resources/control zone.resource.control first-party: delete, for a project admin
+ *
+ * Who may act on a project: a developer app of that project holding the route's capability (a sandbox app only in the
+ * sandbox), or a person who is a member of it (any role reads; admins and owners change). Anyone else is told the
+ * project or zone does not exist.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { ZoneError } = require('../zone/zones');
+const { ClientError } = require('../zone/clients');
+
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ADMIN = new Set(['admin', 'owner']);
+const KIND = 'zone.object-zone';
+
+function createApi(ctx) {
+ const { principal, zones, clients, limits, config } = ctx;
+ const r = asyncRouter();
+ const json = express.json({ limit: '32kb' });
+ const send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const wrap = (fn) => async (req, res) => {
+ try { await fn(req, res); } catch (err) {
+ if (err instanceof ZoneError || err instanceof ClientError) return send(req, res, err.status, err.code, err.detail || err.message);
+ throw err;
+ }
+ };
+ r.use(principal.middleware);
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ // Every route counts against the caller's read numbers first; the writes and the reads that ask Media add their own.
+ r.use(limits.reads('zone.read'));
+
+ /** May this caller act on `project` (read, or `write`)? Throws 404/403 as a ZoneError. */
+ async function access(req, project, { write = false, env = null } = {}) {
+ const p = req.principal;
+ const unknown = () => { throw new ZoneError(404, 'zone.unknown_project', 'no such project'); };
+ if (!PROJECT_RE.test(String(project || ''))) unknown();
+ if (p.kind === 'app') {
+ if (p.project !== project) unknown();
+ if (env && p.claims.env === 'sandbox' && env !== 'sandbox') throw new ZoneError(403, 'zone.sandbox_only', 'a sandbox app reaches only the sandbox environment');
+ return p.requester;
+ }
+ if (p.kind === 'user') {
+ const role = await clients.memberRole(project, p.requester.slice('user:'.length));
+ if (!role) unknown();
+ if (write && !ADMIN.has(role)) throw new ZoneError(403, 'zone.not_admin', 'only an admin or owner of the project changes its zones');
+ return p.requester;
+ }
+ throw new ZoneError(401, 'token.required', 'Sign in at openvibe.zone, or send a Network token');
+ }
+
+ async function zoneFor(req, { write = false } = {}) {
+ if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');
+ const row = await zones.byId(req.params.zone);
+ if (!row) throw new ZoneError(404, 'zone.unknown', 'no such zone');
+ try { await access(req, row.project_id, { write, env: row.env }); } catch (err) {
+ // Another project's zone, and a production zone to a sandbox app, do not exist for this caller.
+ if (err.code === 'zone.unknown_project' || err.code === 'zone.sandbox_only') throw new ZoneError(404, 'zone.unknown', 'no such zone');
+ throw err;
+ }
+ return row;
+ }
+
+ r.get('/ping', (req, res) => res.json({ ok: true, service: 'zone' }));
+
+ r.post('/projects/:project/zones', principal.requireCapability('zone.zone.create'), limits.budget('zone.write'), json, wrap(async (req, res) => {
+ const body = req.body || {};
+ const caller = await access(req, req.params.project, { write: true, env: body.env });
+ const key = req.get('idempotency-key');
+ if (key !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(key)) return send(req, res, 400, 'zone.bad_request', 'Idempotency-Key is 8 to 128 of A-Z a-z 0-9 _ . : -');
+ const out = await zones.create(req.params.project, body, { caller, idempotencyKey: key || null });
+ res.status(201).json(out.zone);
+ }));
+
+ r.get('/projects/:project/zones', principal.requireCapability('zone.zone.list'), wrap(async (req, res) => {
+ await access(req, req.params.project, { env: req.query.env || null });
+ const q = { ...req.query };
+ if (req.principal.kind === 'app' && req.principal.claims.env === 'sandbox') q.env = 'sandbox';
+ for (const env of q.env ? [q.env] : ['sandbox', 'production']) if (['sandbox', 'production'].includes(env)) await zones.ensureDefault(req.params.project, env);
+ res.json(await zones.list(req.params.project, q));
+ }));
+
+ r.get('/zones/:zone', principal.requireCapability('zone.zone.read'), limits.budget('zone.lookup'), wrap(async (req, res) => {
+ await zoneFor(req);
+ res.json((await zones.get(req.params.zone, { withUsage: true })).zone);
+ }));
+
+ r.patch('/zones/:zone', principal.requireCapability('zone.zone.manage'), limits.budget('zone.write'), json, wrap(async (req, res) => {
+ await zoneFor(req, { write: true });
+ res.json(await zones.update(req.params.zone, req.body || {}));
+ }));
+
+ r.delete('/zones/:zone', principal.requireCapability('zone.zone.delete'), limits.budget('zone.write'), wrap(async (req, res) => {
+ await zoneFor(req, { write: true });
+ const v = contracts.validate('zone.object-zone-delete-query@1', { ...req.query });
+ if (!v.valid) return send(req, res, 400, 'zone.bad_request', 'recursive is true or false');
+ const recursive = req.query.recursive === 'true';
+ res.status(202).json(await zones.remove(req.params.zone, { recursive }));
+ }));
+
+ r.get('/zones/:zone/usage', principal.requireCapability('zone.usage.read'), limits.budget('zone.lookup'), wrap(async (req, res) => {
+ await zoneFor(req);
+ res.json(await zones.usage(req.params.zone));
+ }));
+
+ // ── ADR-048: the resource index and control, for OpenVibe.Services (first-party service tokens only) ──
+ const firstParty = (cap) => [principal.requireCapability(cap), (req, res, next) => {
+ const p = req.principal;
+ if (p.kind === 'app' && /^service:/.test(p.requester)) return next();
+ return send(req, res, 403, 'capability.denied', `${cap} is first-party`);
+ }];
+ const summary = (row) => {
+ const z = zones.record(row);
+ return {
+ id: row.id, kind: KIND, service: 'zone', project_id: row.project_id, ovrn: z.ovrn,
+ name: `${row.name} (${row.env})`, state: row.state, created_at: z.created_at,
+ ...(z.updated_at ? { updated_at: z.updated_at } : {}),
+ links: { console: `${config.baseUrl}/p/${row.project_id}#${row.id}`, docs: `${config.baseUrl}/docs` },
+ };
+ };
+ r.get('/resources', ...firstParty('zone.resource.read'), wrap(async (req, res) => {
+ const q = req.query;
+ const project = typeof q.project === 'string' && q.project ? q.project : null;
+ if (project && !PROJECT_RE.test(project)) return send(req, res, 400, 'resources.bad_query', 'project must be a prj_ id');
+ if (q.kind && q.kind !== KIND) return res.json({ resources: [], next_cursor: null });
+ if (q.owner) return res.json({ resources: [], next_cursor: null }); // zones belong to projects, not people
+ const limit = q.limit ? Number(q.limit) : 100;
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) return send(req, res, 400, 'resources.bad_query', 'limit must be an integer 1-1000');
+ let after = '';
+ if (q.cursor) {
+ try { after = JSON.parse(Buffer.from(String(q.cursor), 'base64url').toString('utf8'))[1]; } catch { after = null; }
+ if (!ZONE_RE.test(String(after || ''))) return send(req, res, 400, 'resources.bad_query', 'cursor is not one this index issued');
+ }
+ const rows = await ctx.s.db.many(`SELECT * FROM zone_zones WHERE state <> 'deleted' AND ($1::text IS NULL OR project_id = $1) AND id > $2
+ ORDER BY id LIMIT $3`, [project, after, limit + 1]);
+ const page = rows.slice(0, limit);
+ res.json({ resources: page.map(summary), next_cursor: rows.length > limit ? Buffer.from(JSON.stringify([KIND, page[page.length - 1].id])).toString('base64url') : null });
+ }));
+ r.get('/resources/:ovrn', ...firstParty('zone.resource.read'), wrap(async (req, res) => {
+ const parsed = contracts.resources.parse(String(req.params.ovrn || ''));
+ const row = parsed && parsed.service === 'zone' && parsed.type === 'object-zone' ? await zones.byId(parsed.id) : null;
+ if (!row || row.state === 'deleted' || row.project_id !== parsed.project_id) return send(req, res, 404, 'resources.unknown_resource', `no resource named ${req.params.ovrn}`);
+ res.json(summary(row));
+ }));
+
+ const canonical = (v) => (Array.isArray(v) ? `[${v.map(canonical).join(',')}]`
+ : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`
+ : JSON.stringify(v));
+ const digestOf = (request) => crypto.createHash('sha256').update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');
+ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
+ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: new Date(ctx.s.now()).toISOString(), ...extra });
+
+ async function decide(request) {
+ const parsed = contracts.resources.parse(request.resource || '');
+ if (!parsed || parsed.service !== 'zone' || parsed.type !== 'object-zone' || !ZONE_RE.test(parsed.id)) {
+ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no zone named ${request.resource || request.resource_kind}`) });
+ }
+ if (request.action !== 'delete') return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a zone takes delete, not ${request.action}`) });
+ const person = request.on_behalf_of;
+ if (!person || person.type !== 'user' || !USER_RE.test(String(person.id || ''))) return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person acting') });
+ const row = await zones.byId(parsed.id);
+ const inProject = !!row && row.state !== 'deleted' && row.project_id === parsed.project_id && parsed.project_id === request.project_id;
+ let role = null;
+ if (inProject) {
+ try { role = await clients.memberRole(row.project_id, person.id); } catch {
+ return result(request, 'failed', { problem: problem(503, 'identity.unavailable', 'Network could not say whether this person administers the project; nothing was changed') });
+ }
+ }
+ if (!inProject || !ADMIN.has(role)) return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no zone named ${request.resource} that this person may delete`) });
+ if (row.is_default) return result(request, 'refused', { problem: problem(409, 'zone.default', 'the default zone goes only with its environment') });
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the zone and every object in it, with no restore; its name is never used again' } });
+ try {
+ const z = await zones.remove(row.id, { recursive: true });
+ return result(request, 'done', { result: { status: z.state, changed: true } });
+ } catch (err) {
+ if (err instanceof ZoneError) return result(request, 'refused', { problem: problem(err.status, err.code, err.detail || err.message) });
+ if (err instanceof ClientError) return result(request, 'failed', { problem: problem(503, err.code, err.message) });
+ throw err;
+ }
+ }
+ r.post('/resources/control', ...firstParty('zone.resource.control'), json, wrap(async (req, res) => {
+ const request = req.body;
+ const check = contracts.resources.checkControlRequest(request || {});
+ if (!check.valid) return send(req, res, 400, 'control.bad_request', JSON.stringify(check.errors).slice(0, 500));
+ const caller = req.principal.requester;
+ const digest = digestOf(request);
+ const seen = await ctx.s.db.maybe('SELECT digest, response FROM zone_control_log WHERE caller = $1 AND project_id = $2 AND idempotency_key = $3', [caller, request.project_id, request.idempotency_key]);
+ if (seen) {
+ if (seen.digest !== digest) return send(req, res, 409, 'control.idempotency_conflict', 'this idempotency_key was used for another request');
+ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
+ }
+ const answer = await decide(request);
+ await ctx.s.db.exec(`INSERT INTO zone_control_log (caller, project_id, idempotency_key, digest, response, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), ctx.s.now()]);
+ res.json(answer);
+ }));
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * Per-caller rate limits at OpenVibe.Zone'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 zone_rate_limited_total{limit,window}. Reads take ZONE_LIMITS_MINUTE /
+ * ZONE_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.
+ *
+ * 'zone.thing.create': { minute: 6, hour: 60 },
+ */
+const BUDGETS = {
+ // Reads that ask Media for a zone's live usage.
+ 'zone.lookup': { minute: 120, hour: 3000 },
+ // Creating, changing and deleting zones and access keys: each one is a call to Media.
+ 'zone.write': { minute: 30, hour: 300 },
+};
+
+function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
+ const refused = registry
+ ? registry.counter({ name: 'zone_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const limiter = createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor: enabled ? caller : () => null,
+ now,
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
+ onLimited(e) {
+ // The caller is a subject, a principal or an address, never a token.
+ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name) => {
+ const limit = limiter(name);
+ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
+ };
+ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
+ limiter.budget = (name) => {
+ const m = budgets.get(name);
+ if (!m) throw new Error(`limits: no budget named ${name}`);
+ return m;
+ };
+ return limiter;
+}
+
+module.exports = { createCallerLimits, caller, BUDGETS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.zone, 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 (home, docs, updates) are for search engines and AI
+ * crawlers; sign-in, a person's projects and the API are not.
+ */
+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.Zone';
+const DESCRIPTION = 'OpenVibe.Zone: private S3-compatible storage zones for your OpenVibe projects.';
+const DISALLOW = ['/auth/', '/api/', '/projects', '/p/'];
+
+const PAGE_TEXT = {
+ '/': ['OpenVibe.Zone home', 'Private storage zones for each project and environment, reached with any S3 tool and scoped access keys.'],
+ '/docs': ['Using OpenVibe.Zone', 'The S3 endpoint and its operations, access keys, and the zone API.'],
+ '/updates': ['What shipped on OpenVibe.Zone', '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: 'weekly', priority: 1.0 },
+ { path: '/docs', changefreq: 'monthly', priority: 0.6 },
+ { path: '/updates', changefreq: 'daily', priority: 0.4 },
+];
+function createDiscoveryRoutes(ctx) {
+ const { config } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# openvibe.zone: the public pages are for search and AI crawlers; sign-in, projects and the API are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: DESCRIPTION,
+ details: `A zone is an S3 bucket of one OpenVibe project environment (sandbox or production). Zones are made here or `
+ + `with the API; access keys read, write or both and may be limited to some zones; any S3 tool reaches them at `
+ + `${config.media.s3Endpoint} (path style, SigV4). A zone's name is never reused. Every page is server-rendered.`,
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Zone', url: abs('/'), note: 'what a zone is, and the tools that reach one' },
+ { title: 'Using OpenVibe.Zone', url: abs('/docs'), note: 'the S3 endpoint, access keys and the API' },
+ { title: 'What shipped on OpenVibe.Zone', url: abs('/updates') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'Full text for language models', url: abs('/llms-full.txt') },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ { title: 'Elsewhere', links: [
+ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'projects, apps and every resource' },
+ { title: 'OpenVibe.Media', url: 'https://openvibe.media', note: 'the objects inside every zone' },
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ const pages = publicPages().map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME, summary: 'Every public page of OpenVibe.Zone, one line each.', base: site, maxBytes: 64 * 1024,
+ sections: [{ title: 'Pages', pages }],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }))));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,307 @@
@@ -0,0 +1,307 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone's pages, server-rendered and usable without JavaScript:
+ *
+ * / what a zone is, how to use one with any S3 tool
+ * /projects a signed-in person's projects (from Network, with their own token)
+ * /p/:project one project: its zones per environment with their usage, and its S3 access keys;
+ * admins and owners create and delete zones, issue and revoke keys (forms below)
+ * POST /p/:project/zones create a zone
+ * POST /p/:project/zones/:zone/delete delete a zone and every object in it (the name must be typed)
+ * POST /p/:project/keys issue an access key (its secret is shown on the page it returns, once)
+ * POST /p/:project/keys/:key/revoke revoke a key
+ * /docs the API and S3 usage
+ * /updates the update log
+ *
+ * Every form is a same-origin POST from a signed-in person; Zone and Media both check that the person administers
+ * the project. A key's secret is in the response to its issue only: never stored here, never logged.
+ */
+const express = require('express');
+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 { sameOrigin } = require('./principal');
+const { html, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { ZoneError, RESERVED } = require('../zone/zones');
+const { ClientError } = require('../zone/clients');
+
+const SITE_NAME = 'OpenVibe.Zone';
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
+const KEY_RE = /^ovk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ADMIN = new Set(['admin', 'owner']);
+
+function bytes(n) {
+ const v = Number(n) || 0;
+ if (v < 1024) return `${v} B`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let x = v / 1024, i = 0;
+ while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; }
+ return `${x >= 10 ? Math.round(x) : x.toFixed(1)} ${units[i]}`;
+}
+const plural = (n, one, many) => `${Number(n).toLocaleString('en-US')} ${n === 1 ? one : many}`;
+const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');
+
+function createPageRoutes(ctx) {
+ const { config, clients, zones } = ctx;
+ const r = asyncRouter();
+ const form = express.urlencoded({ extended: false, limit: '8kb' });
+ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const person = (req) => (req.viewer && req.viewer.kind === 'user' && /^usr_/.test(String(req.viewer.subject || '')) ? req.viewer : null);
+ const s3 = config.media.s3Endpoint;
+
+ const signInPage = (req, res, next) => page(req, res, {
+ title: 'Sign in', body: html`<h1>Sign in to manage your zones</h1>
+<p>Your zones belong to your OpenVibe projects. Sign in with your OpenVibe account to see them.</p>
+<p><a class="zn-button zn-primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p>`,
+ }, 401);
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', (req, res) => {
+ const me = person(req);
+ const hero = showcase.hero({
+ eyebrow: `${SITE_NAME} · part of OpenVibe`,
+ title: 'Private storage zones',
+ accent: 'S3 buckets for your projects.',
+ lede: 'Named zones for each project and environment. The AWS SDKs, rclone and backup tools reach them over S3, with access keys you scope to a zone and quotas you set. Every object is an OpenVibe.Media object, readable through its API too.',
+ actions: me
+ ? [{ label: 'Your projects', href: '/projects', primary: true }, { label: 'How to use it', href: '/docs' }]
+ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fprojects', primary: true }, { label: 'How to use it', href: '/docs' }],
+ note: 'Each project includes 1 GB in production and 100 MB in its sandbox at no charge.',
+ });
+ page(req, res, {
+ index: true, cache: me ? null : PUBLIC_CACHE, jsonLd: homeJsonLd(config), styles: [showcase.STYLESHEET],
+ body: html`${raw(hero)}
+${raw(showcase.steps({
+ title: 'From nothing to a bucket',
+ items: [
+ { title: 'Pick a project', text: 'Zones belong to an OpenVibe project. Make one on OpenVibe.Services if you have none.', href: 'https://openvibe.services/' },
+ { title: 'Create a zone', text: 'A zone is a bucket: a name in one environment, sandbox or production, with an optional quota.' },
+ { title: 'Issue an access key', text: 'Read, write or both, for every zone of the environment or only some. The secret is shown once.' },
+ { title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },
+ ],
+ }))}
+<section class="zn-section" aria-labelledby="zn-try"><h2 id="zn-try">With the tools you have</h2>
+<pre class="zn-code"><code>aws configure set aws_access_key_id ovk_…
+aws configure set aws_secret_access_key …
+aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive
+aws --endpoint-url ${s3} s3 ls s3://assets/</code></pre>
+<pre class="zn-code"><code>rclone config create oz s3 provider Other endpoint ${s3} \\
+ access_key_id ovk_… secret_access_key … no_check_bucket true
+rclone sync ./backups oz:backups</code></pre>
+<p class="zn-muted">Objects go up in one request (multipart uploads are not supported yet), up to 100 MB each.</p>
+</section>
+<section class="zn-section" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>
+<ul class="zn-list">
+<li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li>
+<li><strong>Names that stay yours.</strong> A zone's name is never given to another zone, even after it is deleted, so a URL signed for one bucket can never reach a different one.</li>
+<li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li>
+<li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li>
+<li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>
+</ul></section>`,
+ });
+ });
+
+ // ── A person's projects ──────────────────────────────────
+ r.get('/projects', async (req, res) => {
+ const me = person(req);
+ if (!me) return signInPage(req, res, '/projects');
+ let projects;
+ try { projects = (await clients.projectsOf(me.token)).filter((p) => !p.archived_at); } catch (err) {
+ return page(req, res, { title: 'Your projects', body: html`<h1>Your projects</h1><p class="zn-error">Your projects could not be read just now (${err.message}). Try again in a moment.</p>` }, 503);
+ }
+ page(req, res, {
+ title: 'Your projects',
+ body: html`<h1>Your projects</h1>
+${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li class="zn-card"><h2><a href="/p/${p.id}">${p.name}</a></h2>
+<p class="zn-muted">${p.role || 'member'} · ${(p.environments || ['sandbox']).join(' and ')}</p></li>`)}</ul>`
+ : html`<p>You are not in any project yet. Make one on <a href="https://openvibe.services/">OpenVibe.Services</a>, then come back to give it zones.</p>`}`,
+ });
+ });
+
+ // ── One project ──────────────────────────────────────────
+ async function projectView(req, res, project, { notice = null, secret = null, error = null } = {}) {
+ const me = person(req);
+ if (!me) return signInPage(req, res, `/p/${project}`);
+ if (!PROJECT_RE.test(project)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such project.</p>` }, 404);
+ let role, projects;
+ try {
+ projects = await clients.projectsOf(me.token);
+ role = await clients.memberRole(project, me.subject);
+ } catch (err) {
+ return page(req, res, { title: 'Project', body: html`<h1>Project</h1><p class="zn-error">Network could not be asked about this project (${err.message}). Try again in a moment.</p>` }, 503);
+ }
+ const p = projects.find((x) => x.id === project);
+ if (!p || !role) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such project of yours. <a href="/projects">Your projects</a>.</p>` }, 404);
+ const admin = ADMIN.has(role);
+ const envs = p.environments && p.environments.length ? p.environments : ['sandbox'];
+ for (const env of envs) await zones.ensureDefault(project, env);
+ const list = (await zones.list(project, {})).zones;
+ const usage = new Map();
+ await Promise.all(list.filter((z) => z.state !== 'deleted').map(async (z) => {
+ const rep = await clients.report(z.id).catch(() => null);
+ if (rep) usage.set(z.id, rep);
+ }));
+ let keys = [];
+ let keysError = null;
+ if (admin) { try { keys = await clients.listKeys(project, me.subject); } catch (err) { keysError = err.message; } }
+ const zoneRow = (z) => {
+ const rep = usage.get(z.id);
+ const u = rep && rep.usage;
+ return html`<tr id="${z.id}"><td><code>${z.name}</code>${z.default ? html` <span class="zn-badge">default</span>` : ''}</td><td>${z.state}</td>
+<td class="zn-n">${u ? `${bytes(u.stored_bytes)} · ${plural(u.objects, 'object', 'objects')}` : '—'}</td>
+<td class="zn-n">${z.quota_bytes ? bytes(z.quota_bytes) : (z.default ? 'environment' : '—')}</td><td>${day(z.created_at)}</td>
+<td>${admin && !z.default && z.state === 'active' ? html`<details><summary>Delete</summary><form method="post" action="/p/${project}/zones/${z.id}/delete">
+<p>This deletes every object in <code>${z.name}</code>, with no undo, and the name can never be used again.</p>
+<label>Type the zone's name to confirm <input name="confirm" required autocomplete="off" pattern="${z.name}"></label>
+<button class="zn-button zn-danger" type="submit">Delete ${z.name}</button></form></details>` : ''}</td></tr>`;
+ };
+ const envSection = (env) => {
+ const mine = list.filter((z) => z.env === env);
+ return html`<section class="zn-section" aria-labelledby="zn-env-${env}"><h2 id="zn-env-${env}">${env === 'production' ? 'Production' : 'Sandbox'}</h2>
+<table class="zn-table"><thead><tr><th scope="col">Zone (bucket)</th><th scope="col">State</th><th scope="col" class="zn-n">Stored</th><th scope="col" class="zn-n">Quota</th><th scope="col">Created</th><th scope="col"><span class="zn-sr">Actions</span></th></tr></thead>
+<tbody>${mine.map(zoneRow)}</tbody></table>
+${admin ? html`<form class="zn-form" method="post" action="/p/${project}/zones"><input type="hidden" name="env" value="${env}">
+<label>New zone <input name="name" required minlength="3" maxlength="63" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="assets" autocomplete="off"></label>
+<label>Quota (MB, optional) <input name="quota_mb" type="number" min="1" step="1" inputmode="numeric"></label>
+<button class="zn-button" type="submit">Create zone</button></form>` : ''}</section>`;
+ };
+ const keysSection = admin ? html`<section class="zn-section" aria-labelledby="zn-keys"><h2 id="zn-keys">S3 access keys</h2>
+${keysError ? html`<p class="zn-error">The keys could not be read just now (${keysError}).</p>` : ''}
+${keys.length ? html`<table class="zn-table"><thead><tr><th scope="col">Name</th><th scope="col">Key id</th><th scope="col">Environment</th><th scope="col">Can</th><th scope="col">Zones</th><th scope="col">Last used</th><th scope="col"><span class="zn-sr">Actions</span></th></tr></thead>
+<tbody>${keys.map((k) => html`<tr class="${k.revoked_at ? 'zn-revoked' : ''}"><td>${k.name}</td><td><code>${k.id}</code></td><td>${k.env}</td><td>${k.permissions}</td>
+<td>${k.zones.length ? k.zones.map((id) => (list.find((z) => z.id === id) || {}).name || id).join(', ') : 'every zone'}</td><td>${day(k.last_used_at)}</td>
+<td>${k.revoked_at ? `revoked ${day(k.revoked_at)}` : html`<form method="post" action="/p/${project}/keys/${k.id}/revoke"><button class="zn-button zn-danger" type="submit">Revoke</button></form>`}</td></tr>`)}</tbody></table>`
+ : html`<p class="zn-muted">No keys yet.</p>`}
+<form class="zn-form" method="post" action="/p/${project}/keys">
+<label>Name <input name="name" required maxlength="80" placeholder="ci deploys" autocomplete="off"></label>
+<label>Environment <select name="env">${envs.map((e) => html`<option value="${e}">${e}</option>`)}</select></label>
+<label>Can <select name="permissions"><option value="read-write">read and write</option><option value="read">read</option><option value="write">write</option></select></label>
+<fieldset><legend>Zones (none ticked: every zone of the environment)</legend>${list.filter((z) => z.state === 'active').map((z) => html`<label class="zn-check"><input type="checkbox" name="zones" value="${z.id}"> ${z.name} (${z.env})</label>`)}</fieldset>
+<button class="zn-button" type="submit">Issue key</button></form></section>` : html`<p class="zn-muted">Only an admin or owner of this project manages its zones and keys.</p>`;
+ page(req, res, {
+ title: `${p.name}: zones`,
+ body: html`<nav class="crumbs" aria-label="Breadcrumbs"><a href="/projects">Your projects</a> / ${p.name}</nav>
+<h1>${p.name}</h1>
+${notice ? html`<p class="zn-notice" role="status">${notice}</p>` : ''}
+${error ? html`<p class="zn-error" role="alert">${error}</p>` : ''}
+${secret ? html`<div class="zn-secret" role="alert"><h2>Your new key</h2><p>Copy the secret now: it is shown only once.</p>
+<dl><dt>Access key id</dt><dd><code>${secret.key.id}</code></dd><dt>Secret access key</dt><dd><code>${secret.secret}</code></dd><dt>Endpoint</dt><dd><code>${s3}</code> (path style, region <code>us-east-1</code>)</dd></dl></div>` : ''}
+${envs.map(envSection)}
+${keysSection}
+<p class="zn-muted">Endpoint: <code>${s3}</code>. <a href="/docs">How to use it</a>.</p>`,
+ cache: null,
+ }, error ? 400 : 200);
+ }
+ r.get('/p/:project', (req, res) => projectView(req, res, req.params.project));
+
+ /** A form post: signed in, same origin, an admin; then the action, then the project page again. */
+ function action(fn) {
+ return [form, async (req, res) => {
+ const project = req.params.project;
+ const me = person(req);
+ if (!me) return signInPage(req, res, `/p/${project}`);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Refused', body: html`<h1>Refused</h1><p>A change must come from this site's own page.</p>` }, 403);
+ if (!PROJECT_RE.test(project)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
+ try {
+ const role = await clients.memberRole(project, me.subject);
+ if (!ADMIN.has(role)) return page(req, res, { title: 'Refused', body: html`<h1>Refused</h1><p>Only an admin or owner of this project changes its zones and keys.</p>` }, 403);
+ const out = await fn(req, me, project);
+ return await projectView(req, res, project, out);
+ } catch (err) {
+ if (err instanceof ZoneError || err instanceof ClientError) return await projectView(req, res, project, { error: err.detail || err.message });
+ throw err;
+ }
+ }];
+ }
+
+ r.post('/p/:project/zones', ...action(async (req, me, project) => {
+ const b = req.body || {};
+ const name = String(b.name || '').trim().toLowerCase();
+ if (RESERVED.has(name)) throw new ZoneError(400, 'zone.name_reserved', `${name} is reserved`);
+ const quotaMb = b.quota_mb ? Number(b.quota_mb) : null;
+ if (quotaMb !== null && !(Number.isInteger(quotaMb) && quotaMb >= 1)) throw new ZoneError(400, 'zone.bad_request', 'the quota is a whole number of MB');
+ const out = await zones.create(project, { env: b.env, name, ...(quotaMb ? { quota_bytes: quotaMb * 1024 * 1024 } : {}) }, { caller: `user:${me.subject}` });
+ return { notice: `Zone ${out.zone.name} is ${out.zone.state === 'active' ? 'ready' : 'being set up'} in ${out.zone.env}.` };
+ }));
+
+ r.post('/p/:project/zones/:zone/delete', ...action(async (req, me, project) => {
+ if (!ZONE_RE.test(req.params.zone)) throw new ZoneError(404, 'zone.unknown', 'no such zone');
+ const row = await zones.byId(req.params.zone);
+ if (!row || row.project_id !== project) throw new ZoneError(404, 'zone.unknown', 'no such zone');
+ if (String((req.body || {}).confirm || '') !== row.name) throw new ZoneError(400, 'zone.confirm', `type ${row.name} to delete it`);
+ await zones.remove(row.id, { recursive: true });
+ return { notice: `Zone ${row.name} is being deleted: it answers "no such bucket" already, and its objects are being erased.` };
+ }));
+
+ r.post('/p/:project/keys', ...action(async (req, me, project) => {
+ const b = req.body || {};
+ const picked = [].concat(b.zones || []).filter((z) => ZONE_RE.test(String(z)));
+ const out = await clients.issueKey(project, { env: b.env, name: String(b.name || '').trim(), permissions: b.permissions || 'read-write', zones: picked, on_behalf_of: me.subject });
+ return { notice: `Key ${out.key.name} is issued.`, secret: out };
+ }));
+
+ r.post('/p/:project/keys/:key/revoke', ...action(async (req, me, project) => {
+ if (!KEY_RE.test(req.params.key)) throw new ZoneError(404, 'zone.unknown_key', 'no such key');
+ const k = await clients.revokeKey(project, req.params.key, me.subject);
+ return { notice: `Key ${k.name} is revoked: every request signed with it is refused from now on.` };
+ }));
+
+ // ── Docs ─────────────────────────────────────────────────
+ r.get('/docs', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'Using OpenVibe.Zone',
+ description: 'Create zones, issue S3 access keys and reach your buckets with the AWS SDKs, rclone and any S3 tool: the OpenVibe.Zone guide and API.',
+ body: html`<h1>Using OpenVibe.Zone</h1>
+<p class="zn-lede">A zone is an S3 bucket of one project environment. You make zones here or with the API, issue access keys, and point any S3 tool at <code>${s3}</code>.</p>
+<h2 id="s3">The S3 endpoint</h2>
+<ul class="zn-list">
+<li><strong>Endpoint:</strong> <code>${s3}</code>, path style (<code>/<zone>/<key></code>), region <code>us-east-1</code>, Signature Version 4.</li>
+<li><strong>Operations:</strong>
+ <ul>
+ <li>objects: PutObject, GetObject (ranges), HeadObject, DeleteObject, DeleteObjects;</li>
+ <li>listing: ListObjectsV2 and ListObjects (prefix, delimiter, pages), ListBuckets, HeadBucket;</li>
+ <li>presigned GET and PUT URLs, valid for 7 days at most.</li>
+ </ul></li>
+<li><strong>Not yet supported:</strong> multipart uploads, CopyObject, ACLs, policies and versioning. Set your tool to upload in one request (AWS CLI: <code>aws configure set default.s3.multipart_threshold 100MB</code>; rclone: <code>upload_cutoff 100M</code>).</li>
+<li><strong>Size:</strong> one object is at most 100 MB through this endpoint.</li>
+<li><strong>Privacy:</strong> zones are private. Every request is signed. A presigned URL reaches only its own key, until it expires or its key is revoked.</li>
+</ul>
+<h2 id="keys">Access keys</h2>
+<p>A key belongs to one project environment.</p>
+<ul class="zn-list">
+<li>It may read, write or both.</li>
+<li>It may be limited to some zones; with none named, it reaches every zone of the environment.</li>
+<li>Its secret is shown once, when it is issued. Revoking a key takes effect at once.</li>
+<li>A key limited to one zone is revoked when that zone is deleted.</li>
+</ul>
+<h2 id="api">The API</h2>
+<p>Base address: <code>${config.baseUrl}/api/v1</code>. Authenticate with a Network token: a developer app of the project (audience <code>openvibe.zone</code>, holding the route's capability; a sandbox app reaches only the sandbox), or a person who is a member of the project. Errors are RFC 9457 problem+json with a stable <code>code</code>.</p>
+<ul class="zn-list">
+<li><code>POST /projects/:project_id/zones</code> (<code>zone.zone.create</code>): <code>{ env, name, description?, quota_bytes? }</code> with an <code>Idempotency-Key</code> header. Answers 201 with the zone, or 409 with <code>zone.name_taken</code> or <code>zone.name_retired</code>.</li>
+<li><code>GET /projects/:project_id/zones?env=&include_deleted=&cursor=&limit=</code> (<code>zone.zone.list</code>).</li>
+<li><code>GET /zones/:zone_id</code> (<code>zone.zone.read</code>): the zone with its stored bytes and objects.</li>
+<li><code>PATCH /zones/:zone_id</code> (<code>zone.zone.manage</code>): <code>{ description?, quota_bytes? }</code>.</li>
+<li><code>DELETE /zones/:zone_id?recursive=true</code> (<code>zone.zone.delete</code>). Without <code>recursive</code>, a zone that still holds objects answers <code>zone.not_empty</code>. A zone holding an object under a retention hold answers <code>zone.held</code>.</li>
+<li><code>GET /zones/:zone_id/usage</code> (<code>zone.usage.read</code>).</li>
+</ul>
+<p>The objects of a zone are OpenVibe.Media objects. Media's own API (<code>/api/v2/:project_id/objects</code>, with <code>namespace</code> set to the zone's id) reads and writes the same objects.</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: 'zone', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
+ }));
+
+ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
+ r.use(createDiscoveryRoutes(ctx));
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'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.zone; 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/zone.*). 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 = ['zone.zone.create', 'zone.zone.list', 'zone.zone.read', 'zone.zone.manage', 'zone.zone.delete', 'zone.usage.read',
+ 'zone.resource.read', 'zone.resource.control'];
+
+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'] };
+ // A developer app's sandbox token is accepted: it acts on its project's sandbox zones only (http/api.js).
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });
+ 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.Zone'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 openvibe.zone, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.zone).', 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 openvibe.zone 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.Zone's capabilities`);
+ return function readGuard(req, res, next) {
+ const p = req.principal;
+ if (p.kind !== 'app') return next();
+ const decision = capabilities.check(p.claims, cap);
+ if (decision.allowed) return next();
+ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
+ };
+ }
+
+ return { middleware, resolve, requireCapability: require_, requireRead };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
+ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone — process entry. `node server/index.js`
+ * Listens on PORT (5050) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the zone job stops, the JWKS
+ * refresher stops and the store closes. Exported so a test can inject `exit` and `signals: false`. `extra` is what
+ * start() adds (the job).
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Zone', 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.Zone] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.keys.client.start();
+ // The zone job (zone/zones.js tick): pushes what Media has not acknowledged and finishes deletions, every 15 s.
+ ctx.zones.start();
+ createLifecycle({ server, ctx, extra: [() => ctx.zones.stop()] });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Zone] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * db required a real round trip to OpenVibe.Zone'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: 'zone',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => {
+ out += render(v) + strings[i + 1];
+ });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+/** A <table> from headers and rows of cells (cells are already html or plain values). */
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
+function problemBox(p, { title = 'The request failed' } = {}) {
+ if (!p) return '';
+ return html`<div class="problem" role="alert"><strong>${title}</strong>
+<p>${p.detail || 'No detail was given.'}</p>
+<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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.Zone';
+const TAGLINE = 'Private S3-compatible storage';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+// The product's own navigation: one entry per public page it serves.
+const NAV = [
+ { label: 'Zone', href: '/' },
+ { label: 'Your projects', href: '/projects' },
+ { label: 'Docs', 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: 'zone',
+ 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: 'zone', 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: 'zone' })}
+<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="zone@${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: 'zone', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+function render(v) { return require('./html').render(v); }
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ // A page rendered for a signed-in person names them in the account bar: never shared caches.
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+
+/**
+ * A deliberately small Markdown renderer for text a caller supplied or a model wrote. Everything is escaped first; only
+ * headings, paragraphs, lists, fenced code, tables, bold, italics, inline code and links are recognised. Links are kept
+ * only when http(s) or relative, and never vouched for: rel="nofollow ugc noopener", opened in a new tab.
+ */
+const { esc } = require('./html');
+
+function inline(s) {
+ let out = esc(s);
+ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
+ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {
+ const h = href.replace(/&/g, '&');
+ if (/^https?:\/\//.test(h)) return `<a href="${esc(h)}" rel="nofollow ugc noopener" target="_blank">${text}</a>`;
+ if (/^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;
+ return text;
+ });
+ // Bare links the model wrote out, outside an <a> already made above.
+ out = out.replace(/(^|[\s(])(https?:\/\/[^\s<)"']+[^\s<)"'.,;:!?])/g, (m, pre, url) => `${pre}<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`);
+ return out;
+}
+
+const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
+const isSeparator = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
+
+function markdown(src, { headingOffset = 1 } = {}) {
+ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
+ const out = [];
+ let para = [];
+ let list = null; // { type: 'ul' | 'ol', items: [] }
+ let fence = null;
+ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
+ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (fence) {
+ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
+ continue;
+ }
+ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
+ // A table: a header row, a separator row, then rows while lines start with |.
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && isSeparator(lines[i + 1])) {
+ flushPara(); flushList();
+ const head = cells(line);
+ const rows = [];
+ i += 2;
+ while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
+ i--;
+ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
+ continue;
+ }
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
+ if (h) {
+ flushPara(); flushList();
+ const level = Math.min(6, h[1].length + headingOffset);
+ out.push(`<h${level}>${inline(h[2])}</h${level}>`);
+ continue;
+ }
+ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
+ if (li) {
+ flushPara();
+ const type = li[1] ? 'ol' : 'ul';
+ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
+ list.items.push(li[2]);
+ continue;
+ }
+ if (!line.trim()) { flushPara(); flushList(); continue; }
+ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
+ flushList();
+ para.push(line.trim());
+ }
+ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
+ flushPara(); flushList();
+ return out.join('\n');
+}
+
+module.exports = { markdown, inline };
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+
+/**
+ * Zone's calls to the two services it depends on, each with Zone's own client-credentials token (never a person's,
+ * except where a person's own projects are listed):
+ *
+ * OpenVibe.Media (audience openvibe.media):
+ * provision(record) PUT /internal/zones/:id media.zone.provision → { applied, state, usage, deletion }
+ * report(zoneId) GET /internal/zones/:id → { state, usage, deletion } (null when Media has none)
+ * issueKey(project, body) POST /api/v1/projects/:p/access-keys media.access_key.manage → { key, secret }
+ * listKeys(project, person) GET /api/v1/projects/:p/access-keys?on_behalf_of=
+ * revokeKey(project, keyId, person) DELETE …/access-keys/:id?on_behalf_of=
+ *
+ * OpenVibe.Network:
+ * memberRole(project, subject) GET /internal/projects/:p/members/:s network.project.member.read → role | null
+ * projectsOf(personToken) GET /api/v1/projects with the person's own token → their projects
+ *
+ * A failure is a ClientError with the status and problem code the other service answered (or 503 when it did not).
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const TIMEOUT_MS = 10_000;
+
+class ClientError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+
+function createClients({ config, fetchImpl = globalThis.fetch }) {
+ const tokenUrl = `${config.networkInternalUrl}/oauth/token`;
+ const secret = config.oauth.clientSecret;
+ const mediaTokens = secret ? serviceAuth.createTokenClient({
+ tokenUrl, clientId: config.oauth.clientId, clientSecret: secret, audience: 'openvibe.media',
+ scope: 'media.zone.provision media.access_key.manage', fetchImpl,
+ }) : null;
+ const networkTokens = secret ? serviceAuth.createTokenClient({
+ tokenUrl, clientId: config.oauth.clientId, clientSecret: secret, audience: 'openvibe.network',
+ scope: 'network.project.member.read', fetchImpl,
+ }) : null;
+
+ async function call(tokens, base, method, path, body) {
+ if (!tokens) throw new ClientError(503, 'zone.unconfigured', 'OV_OAUTH_CLIENT_SECRET is not set: Zone cannot call its services');
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ } catch (err) {
+ throw new ClientError(503, 'zone.dependency_unavailable', `${base} did not answer: ${err.message}`);
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new ClientError(res.status, (data && data.code) || `http_${res.status}`, (data && (data.detail || data.error)) || `answered ${res.status}`);
+ return data;
+ }
+ const media = (method, path, body) => call(mediaTokens, config.media.internalUrl, method, path, body);
+ const q = (person) => `on_behalf_of=${encodeURIComponent(person)}`;
+
+ return {
+ provision: (record) => media('PUT', `/internal/zones/${record.id}`, record),
+ async report(zoneId) {
+ try { return await media('GET', `/internal/zones/${zoneId}`); } catch (err) {
+ if (err.status === 404) return null;
+ throw err;
+ }
+ },
+ issueKey: (project, body) => media('POST', `/api/v1/projects/${project}/access-keys`, body),
+ listKeys: async (project, person) => (await media('GET', `/api/v1/projects/${project}/access-keys?${q(person)}`)).keys,
+ revokeKey: (project, keyId, person) => media('DELETE', `/api/v1/projects/${project}/access-keys/${encodeURIComponent(keyId)}?${q(person)}`),
+
+ async memberRole(project, subject) {
+ try {
+ const m = await call(networkTokens, config.networkInternalUrl, 'GET', `/internal/projects/${project}/members/${subject}`);
+ return m && m.project_id === project && m.subject === subject && m.archived === false ? m.role : null;
+ } catch (err) {
+ if (err.status === 404) return null;
+ throw err;
+ }
+ },
+ async projectsOf(personToken) {
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/api/v1/projects`, { headers: { Accept: 'application/json', Authorization: `Bearer ${personToken}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ } catch (err) { throw new ClientError(503, 'zone.dependency_unavailable', `Network did not answer: ${err.message}`); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new ClientError(res.status, (data && data.code) || `http_${res.status}`, (data && data.detail) || `answered ${res.status}`);
+ return (data && data.projects) || [];
+ },
+ };
+}
+
+module.exports = { createClients, ClientError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+
+/**
+ * OpenVibe.Zone's zones (ADR-031 amendment 2026-10-02). A zone is a named object zone of one project environment; this
+ * module owns its record and keeps OpenVibe.Media's projection of it current (clients.provision, by revision).
+ *
+ * ensureDefault(project, env) the environment's `default` zone, created with the first use of it
+ * create(project, body, { caller, idempotencyKey }) → zone.object-zone@1 (provisioning until Media acknowledged)
+ * get(id) / list(project, query) → zone.object-zone@1 / zone.object-zone-list@1
+ * update(id, body) description and quota_bytes (the default zone's quota is the environment's)
+ * remove(id, { recursive }) → the zone in state deleting; Media tombstones and purges its objects
+ * usage(id) → zone.object-zone-usage@1 (Media's live snapshot; Zone never counts)
+ * tick() pushes every change Media has not acknowledged, and marks a deleting zone
+ * deleted once Media reports nothing left (the job runs it every 15 s)
+ *
+ * A name belongs to one zone of its environment for ever (409 zone.name_taken while it lives, zone.name_retired once
+ * it is deleting or deleted), so a request signed for a bucket can only ever reach the zone it was signed for.
+ */
+const crypto = require('crypto');
+const contracts = require('openvibe-contracts');
+
+const RESERVED = new Set(['default', 'metrics']);
+const PAGE = 50;
+
+class ZoneError 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 ZoneError(status, code, detail); };
+const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
+
+function createZones({ s, clients, config, log = console }) {
+ const { db } = s;
+
+ /** The zone.object-zone@1 of a row (usage when given; deletion progress while it is deleting or deleted). */
+ function record(row, { usage = null, deletion = null } = {}) {
+ const z = {
+ id: row.id, project_id: row.project_id, env: row.env, name: row.name, default: !!row.is_default,
+ description: row.description ?? null, access: row.is_default ? 'per-object' : 'private', state: row.state,
+ quota_bytes: row.quota_bytes == null ? null : Number(row.quota_bytes), revision: Number(row.revision),
+ created_at: iso(row.created_at), updated_at: iso(row.updated_at),
+ };
+ Object.assign(z, contracts.zones.identity(z));
+ if (usage) z.usage = { stored_bytes: usage.stored_bytes, objects: usage.objects, as_of: usage.as_of };
+ if (row.state === 'deleting' || row.state === 'deleted') {
+ z.deletion = {
+ requested_at: iso(row.deletion_requested_at),
+ objects_remaining: row.state === 'deleted' ? 0 : (deletion ? deletion.objects_remaining : 0),
+ held_objects: row.state === 'deleted' ? 0 : (deletion ? deletion.held_objects : 0),
+ completed_at: row.state === 'deleted' ? iso(row.deletion_completed_at) : null,
+ };
+ }
+ const check = contracts.zones.checkObjectZone(z);
+ if (!check.valid) throw new Error(`zone ${row.id} does not make a valid record: ${JSON.stringify(check.errors).slice(0, 300)}`);
+ return z;
+ }
+
+ const byId = async (id) => await db.maybe('SELECT * FROM zone_zones WHERE id = $1', [String(id || '')]);
+
+ /** Push a row's current record to Media; pushed_revision follows what Media acknowledged. false when Media refused or is down. */
+ async function push(row) {
+ try {
+ const out = await clients.provision(record(row));
+ await db.exec('UPDATE zone_zones SET pushed_revision = GREATEST(pushed_revision, $2) WHERE id = $1', [row.id, row.revision]);
+ return out;
+ } catch (err) {
+ log.warn(`[Zone] Media has not taken ${row.id} r${row.revision} yet (${err.code || err.message}); retried by the job`);
+ return null;
+ }
+ }
+
+ /** provisioning → active once Media holds the zone (two revisions: Media first learns of it, then serves it). */
+ async function activate(row) {
+ if (row.state !== 'provisioning') return row;
+ if (row.pushed_revision < row.revision && !await push(row)) return row;
+ const next = await db.maybe(`UPDATE zone_zones SET state = 'active', revision = revision + 1, updated_at = $2
+ WHERE id = $1 AND state = 'provisioning' RETURNING *`, [row.id, s.now()]);
+ if (!next) return await byId(row.id);
+ await push(next);
+ return await byId(row.id);
+ }
+
+ async function insert(project, env, name, { isDefault = false, description = null, quota = null }) {
+ const id = s.newId('zon');
+ await db.exec(`INSERT INTO zone_zones (id, project_id, env, name, is_default, description, state, quota_bytes, revision, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, 1, $8)`, [id, project, env, name, isDefault, description, quota, s.now()]);
+ return await activate(await byId(id));
+ }
+
+ async function ensureDefault(project, env) {
+ const cur = await db.maybe("SELECT * FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = 'default'", [project, env]);
+ if (cur) return cur.state === 'provisioning' ? await activate(cur) : cur;
+ try { return await insert(project, env, 'default', { isDefault: true }); } catch (err) {
+ // Two first uses raced: the other one made it.
+ const again = await db.maybe("SELECT * FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = 'default'", [project, env]);
+ if (again) return again;
+ throw err;
+ }
+ }
+
+ async function create(project, body = {}, { caller, idempotencyKey = null } = {}) {
+ const v = contracts.validate('zone.object-zone-create-request@1', body);
+ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));
+ if (RESERVED.has(body.name)) fail(400, 'zone.name_reserved', `${body.name} is reserved`);
+ const digest = crypto.createHash('sha256').update(JSON.stringify([project, body.env, body.name, body.description ?? null, body.quota_bytes ?? null])).digest('hex');
+ if (idempotencyKey) {
+ const seen = await db.maybe('SELECT digest, zone_id FROM zone_idempotency WHERE caller = $1 AND idempotency_key = $2', [caller, idempotencyKey]);
+ if (seen) {
+ if (seen.digest !== digest) fail(409, 'zone.idempotency_conflict', 'this Idempotency-Key was used for another request');
+ return { zone: record(await byId(seen.zone_id)), repeat: true };
+ }
+ }
+ await ensureDefault(project, body.env);
+ const taken = await db.maybe('SELECT state FROM zone_zones WHERE project_id = $1 AND env = $2 AND name = $3', [project, body.env, body.name]);
+ if (taken) fail(409, ['deleting', 'deleted'].includes(taken.state) ? 'zone.name_retired' : 'zone.name_taken',
+ ['deleting', 'deleted'].includes(taken.state) ? `${body.name} was a zone of this environment; a name is never given to a second zone` : `${body.name} is a zone of this environment already`);
+ const count = Number(await db.value("SELECT COUNT(*) FROM zone_zones WHERE project_id = $1 AND env = $2 AND state <> 'deleted' AND NOT is_default", [project, body.env]));
+ if (count >= config.maxZonesPerEnv) fail(403, 'zone.quota_exceeded', `an environment holds at most ${config.maxZonesPerEnv} zones besides its default`);
+ let row;
+ try {
+ row = await insert(project, body.env, body.name, { description: body.description ?? null, quota: body.quota_bytes ?? null });
+ } catch (err) {
+ if (/unique|duplicate/i.test(String(err.message))) fail(409, 'zone.name_taken', `${body.name} is a zone of this environment already`);
+ throw err;
+ }
+ if (idempotencyKey) {
+ await db.exec('INSERT INTO zone_idempotency (caller, idempotency_key, digest, zone_id, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING',
+ [caller, idempotencyKey, digest, row.id, s.now()]);
+ }
+ return { zone: record(row), repeat: false };
+ }
+
+ async function get(id, { withUsage = false } = {}) {
+ const row = await byId(id);
+ if (!row) fail(404, 'zone.unknown', 'no such zone');
+ if (!withUsage) return { row, zone: record(row) };
+ const rep = row.state === 'deleted' ? null : await clients.report(row.id).catch(() => null);
+ return { row, zone: record(row, { usage: rep && rep.usage, deletion: rep && rep.deletion }) };
+ }
+
+ async function list(project, query = {}) {
+ const v = contracts.validate('zone.object-zone-list-query@1', query);
+ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));
+ const limit = Number(query.limit || PAGE);
+ const includeDeleted = query.include_deleted === true || query.include_deleted === 'true';
+ let after = '';
+ if (query.cursor) {
+ try { after = Buffer.from(String(query.cursor), 'base64url').toString('utf8'); } catch { after = ''; }
+ if (!/^(sandbox|production)\|[a-z0-9-]{3,63}$/.test(after)) fail(400, 'zone.bad_request', 'cursor is the next_cursor of a previous page');
+ }
+ const rows = await db.many(`SELECT * FROM zone_zones WHERE project_id = $1 AND ($2::text IS NULL OR env = $2)
+ AND ($3 OR state <> 'deleted') AND (env || '|' || name) > $4 ORDER BY env || '|' || name LIMIT $5`,
+ [project, query.env || null, includeDeleted, after, limit + 1]);
+ const page = rows.slice(0, limit);
+ const last = page[page.length - 1];
+ return { zones: page.map((r) => record(r)), next_cursor: rows.length > limit && last ? Buffer.from(`${last.env}|${last.name}`).toString('base64url') : null };
+ }
+
+ async function update(id, body = {}) {
+ const v = contracts.validate('zone.object-zone-update-request@1', body);
+ if (!v.valid) fail(400, 'zone.bad_request', v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ').slice(0, 400));
+ const row = await byId(id);
+ if (!row || row.state === 'deleted') fail(404, 'zone.unknown', 'no such zone');
+ if (row.state === 'deleting') fail(409, 'zone.deleting', 'the zone is being deleted');
+ if (row.is_default && body.quota_bytes !== undefined) fail(409, 'zone.default', 'the default zone is the environment: its quota is the environment\'s');
+ const description = body.description !== undefined ? body.description : row.description;
+ const quota = body.quota_bytes !== undefined ? body.quota_bytes : row.quota_bytes;
+ const next = await db.maybe(`UPDATE zone_zones SET description = $2, quota_bytes = $3, revision = revision + 1, updated_at = $4
+ WHERE id = $1 RETURNING *`, [id, description, quota, s.now()]);
+ await push(next);
+ return record(await byId(id));
+ }
+
+ async function remove(id, { recursive = false } = {}) {
+ const row = await byId(id);
+ if (!row || row.state === 'deleted') fail(404, 'zone.unknown', 'no such zone');
+ if (row.is_default) fail(409, 'zone.default', 'the default zone goes only with its environment');
+ if (row.state === 'deleting') return record(row);
+ const rep = await clients.report(row.id);
+ if (rep && rep.deletion == null && rep.usage) {
+ if (rep.usage.objects > 0 && !recursive) fail(409, 'zone.not_empty', `the zone holds ${rep.usage.objects} object(s): delete them first, or delete recursively`);
+ }
+ if (rep) {
+ // A held object stays until its hold is released: refused before anything starts (ADR-031 section 10).
+ const held = Number(rep.held_objects || 0);
+ if (held > 0) fail(409, 'zone.held', `${held} object(s) of the zone are under a retention hold`);
+ }
+ const now = s.now();
+ const next = await db.maybe(`UPDATE zone_zones SET state = 'deleting', revision = revision + 1, updated_at = $2, deletion_requested_at = $2
+ WHERE id = $1 AND state IN ('provisioning', 'active') RETURNING *`, [id, now]);
+ if (!next) return record(await byId(id));
+ await push(next);
+ return record(await byId(id), { deletion: { objects_remaining: rep && rep.usage ? rep.usage.objects : 0, held_objects: 0 } });
+ }
+
+ async function usage(id) {
+ const { row } = await get(id);
+ const rep = row.state === 'deleted' ? null : await clients.report(row.id);
+ const now = new Date(s.now());
+ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+ const current = rep && rep.usage ? rep.usage : { stored_bytes: 0, objects: 0, as_of: now.toISOString() };
+ const out = {
+ zone_id: row.id, ovrn: contracts.zones.identity({ ...row, default: row.is_default }).ovrn,
+ current: { stored_bytes: current.stored_bytes, objects: current.objects, as_of: current.as_of },
+ window_start: start.toISOString(), window_end: now.toISOString(), series: [],
+ };
+ return out;
+ }
+
+ let ticking = false;
+ async function tick() {
+ if (ticking) return { pushed: 0, deleted: 0 };
+ ticking = true;
+ let pushed = 0, deleted = 0;
+ try {
+ for (const row of await db.many("SELECT * FROM zone_zones WHERE pushed_revision < revision OR state = 'deleting' ORDER BY id LIMIT 200")) {
+ if (row.state === 'provisioning') { if ((await activate(row)).state === 'active') pushed++; continue; }
+ if (row.pushed_revision < row.revision) { if (await push(row)) pushed++; continue; }
+ if (row.state === 'deleting') {
+ const rep = await clients.report(row.id).catch(() => null);
+ if (rep && rep.deletion && rep.deletion.completed_at) {
+ const done = await db.maybe(`UPDATE zone_zones SET state = 'deleted', revision = revision + 1, updated_at = $2, deletion_completed_at = $2
+ WHERE id = $1 AND state = 'deleting' RETURNING *`, [row.id, s.now()]);
+ if (done) { await push(done); deleted++; }
+ }
+ }
+ }
+ } finally { ticking = false; }
+ return { pushed, deleted };
+ }
+
+ let timer = null;
+ function start(ms = 15_000) {
+ if (timer) return;
+ timer = setInterval(() => { tick().catch((err) => log.warn(`[Zone] tick: ${err.message}`)); }, ms);
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ return { record, byId, ensureDefault, create, get, list, update, remove, usage, tick, start, stop };
+}
+
+module.exports = { createZones, ZoneError, RESERVED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * The one cache rule the service serves assets with (openvibe-shared/cache-policy, plan T11): a URL
+ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never
+ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the
+ * short public window with a day of stale-while-revalidate. The values are pinned here against
+ * the module, not re-stated: the service never picks its own numbers.
+ */
+const assert = require('assert');
+const cache = require('openvibe-shared/cache-policy');
+const { assetVersion } = require('../server/render/layout');
+const { boot, check, done } = require('./helpers/boot');
+
+const ASSET = 'css/app.css';
+const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
+ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ });
+
+ await check('a wrong-but-hex ?v= is never pinned', async () => {
+ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), SHORT);
+ });
+
+ await check('no ?v= gets the short public window', async () => {
+ const r = await t.get(`/${ASSET}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), SHORT);
+ });
+
+ await check('those values are the module\'s, not the service\'s own', () => {
+ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');
+ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);
+ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The
+ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
+ * rotation honoured at once, unknown-kid floods throttled. The service is a thin shim around it:
+ * the boot reads the key and verifies a session/app token against the URL,
+ * a JWKS outage with cached keys still verifies,
+ * readiness (network_jwks) shows the cache state.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+function jwksServer({ doc = null, fail = null } = {}) {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ seen.push(req.url);
+ if (fail) { res.writeHead(503); return res.end('down'); }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(doc));
+ });
+ return new Promise((resolve) => {
+ srv.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${srv.address().port}`,
+ close: () => new Promise((r) => srv.close(r)),
+ seen,
+ setDoc: (d) => { doc = d; },
+ setFail: (f) => { fail = f; },
+ }));
+ });
+}
+
+function signJwt(claims, privateKey, { kid = null } = {}) {
+ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
+ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }
+function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }
+
+(async () => {
+ await check('a token signed by a key from a stub JWKS verifies', async () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
+ const { createKeyStore, jwksUrl } = require('../server/auth/keys');
+ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: console });
+ const url = jwksUrl(config);
+ await keys.ensure();
+ const now = Math.floor(Date.now() / 1000);
+ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
+ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);
+ assert.strictEqual(v.claims.username, 'demo');
+ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');
+ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);
+ await jwks.close();
+ });
+
+ // A rotation publishes two keys: verification must use the one the token names, not the first.
+ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {
+ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });
+ const { createKeyStore } = require('../server/auth/keys');
+ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });
+ const now = Math.floor(Date.now() / 1000);
+ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['openvibe.zone'], 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: `zone_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: `zone_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: `zone_at=${expired}` });
+ assert.ok(page.status < 400, `a page still renders: ${page.status}`);
+ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
+ await t.close();
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id zone, cookies zone_at/zone_rt/zone_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'), 'zone');
+ 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'), /zone_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, /zone_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /zone_rt=[^;]+;.*HttpOnly/i);
+ const at = set.match(/zone_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `zone_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(/zone_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, 'zone');
+ 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: 'zone' });
+ const gone = await t.get('/api/v1/nope');
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(gone.json().code, 'route.not_found');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Per-caller rate limits (server/http/caller-limits.js): past its limit one caller gets 429 problem+json
+ * `rate_limited` with Retry-After, before the route does any work, while another caller still passes; the
+ * window reopens on the clock. A person counts as themselves, anyone else by address. Reads take
+ * ZONE_LIMITS_MINUTE/ZONE_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
+ * zone_rate_limited_total.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { caller, BUDGETS } = require('../server/http/caller-limits');
+
+const READ = 'zone.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: { ZONE_LIMITS_MINUTE: '3', ZONE_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(), ['zone.lookup', 'zone.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('zone.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 zone_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('zone_rate_limited_total')).join('\n');
+ assert.ok(/zone_rate_limited_total\{limit="zone\.read",window="minute"\} 2/.test(m), counted);
+ const logs = t.logs();
+ assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');
+ assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');
+ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');
+ });
+
+ await check('who is counted', () => {
+ assert.strictEqual(caller({ principal: { requester: 'app:app_x' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_x', 'a principal wins');
+ assert.strictEqual(caller({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
+ assert.strictEqual(caller({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+// Crawl artifacts (server/http/discovery.js): robots.txt keeps crawlers off sign-in, the API and people's projects; the
+// sitemap lists the public pages only; llms.txt says what a zone is and where the S3 endpoint is; llms-full.txt lists
+// every public page.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('robots.txt keeps crawlers off sign-in, the API and projects, and names the sitemap', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ for (const p of ['/auth/', '/api/', '/projects', '/p/']) assert.ok(r.text.includes(`Disallow: ${p}`), p);
+ assert.match(r.text, /Sitemap: https:\/\/openvibe\.zone\/sitemap\.xml/);
+ });
+
+ await check('the sitemap lists the public pages and nothing of anyone\'s project', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.match(r.headers.get('content-type'), /xml/);
+ for (const loc of ['/', '/docs', '/updates']) assert.ok(r.text.includes(`<loc>https://openvibe.zone${loc}</loc>`), loc);
+ assert.doesNotMatch(r.text, /\/p\/|\/projects/);
+ });
+
+ await check('llms.txt says what a zone is and names the S3 endpoint', async () => {
+ const r = await t.get('/llms.txt');
+ assert.match(r.text, /^# OpenVibe\.Zone/m);
+ assert.match(r.text, /https:\/\/s3\.openvibe\.media/);
+ assert.match(r.text, /Using OpenVibe\.Zone/);
+ });
+
+ await check('llms-full.txt lists every public page', async () => {
+ const r = await t.get('/llms-full.txt');
+ for (const s of ['OpenVibe.Zone home', 'Using OpenVibe.Zone', 'What shipped on OpenVibe.Zone']) assert.ok(r.text.includes(s), s);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way. The per-caller limits
+// (server/http/caller-limits.js) count nobody unless a test asks: boot({ callerLimits: true, limitsNow }).
+process.env.ZONE_RATE_LIMIT_PER_MIN = process.env.ZONE_RATE_LIMIT_PER_MIN || '100000';
+process.env.ZONE_API_RATE_LIMIT_PER_MIN = process.env.ZONE_API_RATE_LIMIT_PER_MIN || '100000';
+/**
+ * Boots OpenVibe.Zone 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 (zone_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://openvibe.zone', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'zone', OV_OAUTH_CLIENT_SECRET: 'zone-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, clients: opts.clients });
+ 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) => `zone_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await testdb.close();
+ await network.close();
+ },
+ };
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * ZONE_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.ZONE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'zone', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,334 @@
@@ -0,0 +1,334 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served
+ * the developer console that moved to OpenVibe.Services; what a service's tests use is sign-in and the JWKS.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `zone`, refresh_token,
+ * client_credentials for `zone` 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 === 'zone') {
+ if (body.client_secret !== 'zone-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:zone', 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. services with zone.resource.read). */
+ serviceToken(slug, cap, { audience = 'openvibe.zone' } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: `svc:${slug}`, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
+ },
+ /** An authorization code the mock will exchange for this user if the verifier matches. */
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
+ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
+ setUsage(projectId, body) { st.usage.set(projectId, body); },
+ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
+ setCatalog(list) { st.catalog = list; },
+ setDown(v) { st.down = v; },
+ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a
+ * same-site click swaps <main> in place without a reload. The marker names the layout's release,
+ * which the app sets from openvibe-shared/release (a swap only happens between pages of the same
+ * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the
+ * shared navbar's sign-in follows the current page through the {path} template.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const MARKER = /<meta name="ov-boost" content="(zone@[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], `zone@${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.Zone: Private S3-compatible storage</title>'), 'home title');
+ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.zone/">'), '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="zone"', '/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.Zone'), 'the service name is on the page');
+ assert.ok(r.text.includes('Private storage zones'), '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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+// deploy/nginx/openvibe.zone.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/openvibe.zone.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=zone_api burst=30 nodelay;/);
+assert.match(auth, /limit_req zone=zone_auth burst=10 nodelay;/);
+for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
+const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
+assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
+console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/**
+ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").
+ * Code, client scripts, deploy files, the environment template and the manifest must not
+ * mention it; the running service never sends it to Network (the Network mock refuses any request
+ * that carries one), and a request that presents one gets no special treatment.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const ROOT = path.join(__dirname, '..');
+const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;
+
+function files(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...files(p)); else out.push(p);
+ }
+ return out;
+}
+
+(async () => {
+ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
+ const targets = [
+ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
+ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),
+ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
+ ].filter((f) => fs.existsSync(f));
+ assert.ok(targets.length > 15);
+ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));
+ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);
+ });
+
+ const t = await boot();
+ await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {
+ const u = t.network.addUser('nia');
+ for (const p of ['/', '/updates', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });
+ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
+ assert.strictEqual(sent.length, 0);
+ });
+
+ await check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {
+ const r = await t.get('/api/v1/ping', { headers: { 'x-internal-key': 'anything' } });
+ assert.strictEqual(r.status, 200);
+ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });
+ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and
+// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
+// until 2026-09-26 it got through here.
+const assert = require('assert');
+const { sanitizeNext } = require('../server/auth/sso');
+
+const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };
+const home = (v) => {
+ const out = String(sanitizeNext(v, config));
+ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;
+ return host === 'site.openvibe.test' || host === 'openvibe.network';
+};
+for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',
+ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {
+ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);
+ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
+}
+assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
+console.log('open redirect: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+// Size budgets for openvibe.zone'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: '', ZONE_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 (ZONE_PGLITE_DIR)');
+ const m = await measure({ base });
+ const over = check(m, BUDGETS);
+ assert.deepStrictEqual(over, [], format(m, over));
+ console.log(format(m));
+ console.log('perf budget: all checks passed');
+ } finally {
+ child.kill();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite
+ * databases and an in-process mock of OpenVibe.Network; none needs the network or a running site.
+ *
+ * npm test # everything
+ * npm test -- login # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ *
+ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
+ * ○ and not counted as passed (openvibe-shared/test-runner).
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 60000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
+ * app (walking Express's router stack, so a route added later is crawled without anyone listing
+ * it), fills route parameters with seeded values, and GETs each path as several people, reporting
+ * any response whose body or headers carry a value that person must never see.
+ * Not a test itself (no .test.js); used with test/helpers/boot.js.
+ */
+
+/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
+function mountPath(layer) {
+ if (!layer.regexp || layer.regexp.fast_slash) return '';
+ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
+ let i = 0;
+ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
+ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
+ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
+}
+
+/** Every route of the booted app (captured from createApp): [{ path, methods }]. */
+function listRoutes(app) {
+ if (!app || !app._router) throw new Error('no Express app');
+ const out = [];
+ const walk = (stack, prefix) => {
+ for (const layer of stack) {
+ if (layer.route) {
+ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
+ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
+ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
+ const mp = mountPath(layer);
+ if (mp !== null) walk(layer.handle.stack, prefix + mp);
+ } else {
+ const mp = mountPath(layer);
+ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
+ }
+ }
+ };
+ walk(app._router.stack, '');
+ const seen = new Set();
+ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
+}
+
+/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
+function expand(template, values) {
+ const names = [];
+ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
+ if (!names.length) return [t];
+ const lists = names.map((n) => values(n));
+ const width = Math.max(...lists.map((l) => l.length));
+ const paths = new Set();
+ for (let i = 0; i < width; i++) {
+ let j = 0;
+ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
+ }
+ return [...paths];
+}
+
+/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */
+function getPaths(app, values, { query = '', extra = [] } = {}) {
+ const paths = new Set();
+ for (const r of listRoutes(app)) {
+ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;
+ for (const p of expand(r.path, values)) {
+ paths.add(p);
+ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
+ }
+ }
+ for (const p of extra) paths.add(p);
+ return [...paths];
+}
+
+/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
+function leaks(res, needles) {
+ const found = [];
+ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
+ for (const [label, value] of Object.entries(needles)) {
+ if (!value) continue;
+ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
+ if (headerText.includes(value)) found.push({ label, where: 'headers' });
+ }
+ return found;
+}
+
+/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */
+let seq = 0;
+function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }
+
+/**
+ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs
+ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person
+ * must never see. Resolves { found, answered, statuses }.
+ */
+async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {
+ const found = [];
+ const statuses = {};
+ let answered = 0;
+ for (const [who, as] of Object.entries(people)) {
+ const needles = needlesFor(who);
+ const queue = [...paths];
+ await Promise.all(Array.from({ length: concurrency }, async () => {
+ while (queue.length) {
+ const p = queue.shift();
+ let r;
+ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }
+ if (r.status) answered++;
+ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
+ statuses[cls] = (statuses[cls] || 0) + 1;
+ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
+ }
+ }));
+ }
+ return { found, answered, statuses };
+}
+
+module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The service's own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
+ * internal-secret class). Its environment holds one: its Network OAuth client secret, a sentinel here. Every route
+ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network
+ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to
+ * Network), unknown paths and every write route with a broken body. No body or header may carry it, nor may the
+ * database or the captured log lines.
+ *
+ * node test/security-secrets.test.js
+ */
+const assert = require('assert');
+const appModule = require('../server/app');
+let capturedApp = null;
+const realCreate = appModule.createApp;
+appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
+const { boot, check, done } = require('./helpers/boot');
+const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
+
+const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client' };
+
+(async () => {
+ const t = await boot({ env: SECRETS });
+ const dev = t.network.addUser('dev');
+ const staff = t.network.addUser('root', { role: 'admin' });
+
+ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {
+ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];
+ const values = () => ['zone-id', ...nonsense];
+ const paths = getPaths(capturedApp, values, {
+ query: 'q=x&code=forged&state=forged&next=%2F',
+ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],
+ });
+ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
+ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
+ assert.deepStrictEqual(r.found, []);
+ });
+
+ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {
+ const found = [];
+ const values = () => ['x'];
+ for (const route of listRoutes(capturedApp)) {
+ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {
+ for (const p of expand(route.path, values)) {
+ for (const as of [null, dev]) {
+ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });
+ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);
+ }
+ }
+ }
+ }
+ assert.deepStrictEqual(found, []);
+ });
+
+ await check('the database and the captured log lines carry no sentinel', async () => {
+ const text = await t.dbDump() + t.logs();
+ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The zone_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 = `zone_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: `zone_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: 'zone_at=garbage' })).status, 401, 'a present but invalid access cookie');
+ assert.strictEqual((await s.get('/auth/me', { cookie: 'zone_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(!/zone_at=;|zone_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') || ''), /zone_at=;/);
+ });
+
+ await s.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order
+ * (the timers clear, the JWKS refresher stops, then the store closes: the order the manifest's
+ * shutdown.drains lists) and resolves exit 0; a second signal while stopping is a no-op.
+ */
+const assert = require('assert');
+const http = require('http');
+const { check, done } = require('./helpers/boot');
+const { createLifecycle } = require('../server/index');
+
+const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+
+(async () => {
+ await check('the entry point stop clears the timers, stops the JWKS refresher, then closes the store, then exits 0', async () => {
+ const steps = [];
+ const ctx = {
+ keys: { client: { stop: () => { steps.push('keys.stop'); } } },
+ s: { close: async () => { steps.push('store.close'); } },
+ };
+ const server = http.createServer((req, res) => res.end('ok'));
+ await listen(server);
+
+ const exits = [];
+ const timer = setInterval(() => steps.push('tick'), 1_000_000);
+ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false, timers: [timer] });
+ const code = await lifecycle.stop('SIGTERM');
+
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(exits, [0]);
+ assert.deepStrictEqual(steps, ['keys.stop', 'store.close']);
+ assert.strictEqual(server.listening, false);
+ });
+
+ await check('a second signal while stopping is a no-op', async () => {
+ let stops = 0; let closes = 0; let exits = 0;
+ const ctx = {
+ keys: { client: { stop: () => { stops += 1; } } },
+ s: { close: async () => { closes += 1; } },
+ };
+ const server = http.createServer((req, res) => res.end('ok'));
+ await listen(server);
+
+ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });
+ const first = lifecycle.stop('SIGTERM');
+ const second = lifecycle.stop('SIGINT');
+
+ assert.strictEqual(first, second);
+ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);
+ assert.strictEqual(stops, 1);
+ assert.strictEqual(closes, 1);
+ assert.strictEqual(exits, 1);
+ });
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
+'use strict';
+// OpenVibe.Zone end to end against a mock Network and a fake Media: default zones, creating with an Idempotency-Key,
+// names taken and retired, provisioning by revision (provisioning → active), app tokens and the sandbox rule, member
+// and admin rules, updates, usage, deletion (not empty, held, recursive, then deleted by the job), the resource index
+// and control for Services, and the console pages (zones and keys, the secret once, same-origin forms).
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot } = require('./helpers/boot');
+
+const checks = [];
+const check = (name, fn) => checks.push([name, fn]);
+
+/** Media as Zone sees it: provisions recorded, reports and keys from a small state. */
+function fakeMedia(network) {
+ const zones = new Map(); // id → { record, objects, held, completed }
+ const pushes = [];
+ const keys = [];
+ let down = false;
+ const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
+ const { ClientError } = require('../server/zone/clients');
+ return {
+ pushes, zones, keys, setDown: (v) => { down = v; },
+ async provision(record) {
+ if (down) throw new ClientError(503, 'zone.dependency_unavailable', 'media down');
+ const check = contracts.zones.checkObjectZone(record);
+ if (!check.valid) throw new ClientError(400, 'media.zone.invalid', JSON.stringify(check.errors));
+ pushes.push({ id: record.id, state: record.state, revision: record.revision });
+ const cur = zones.get(record.id) || { objects: 0, held: 0, completed: false };
+ cur.record = record;
+ zones.set(record.id, cur);
+ return { applied: true };
+ },
+ async report(id) {
+ const z = zones.get(id);
+ if (!z) return null;
+ return {
+ zone_id: id, revision: z.record.revision, state: z.record.state, held_objects: z.held,
+ usage: { stored_bytes: z.objects * 100, objects: z.objects, as_of: new Date().toISOString() },
+ deletion: z.record.state === 'deleting' ? { requested_at: new Date().toISOString(), objects_remaining: z.completed ? 0 : z.objects, held_objects: 0, completed_at: z.completed ? new Date().toISOString() : null } : null,
+ };
+ },
+ async issueKey(project, body) {
+ const key = { id: `ovk_01JZ0000000000000000000K${String(keys.length).padStart(2, '0')}`, project_id: project, env: body.env, name: body.name, permissions: body.permissions, zones: body.zones || [], created_by: body.on_behalf_of, created_at: new Date().toISOString(), last_used_at: null, revoked_at: null };
+ keys.push(key);
+ return { key, secret: 'S'.repeat(40) };
+ },
+ async listKeys(project) { return keys.filter((k) => k.project_id === project); },
+ async revokeKey(project, id) { const k = keys.find((x) => x.id === id && x.project_id === project); if (!k) throw fail(404, 'media.access_key.unknown', 'no such key'); k.revoked_at = new Date().toISOString(); return k; },
+ async memberRole(project, subject) {
+ const m = network.state.members.get(project);
+ const p = network.state.projects.get(project);
+ return m && p && !p.archived_at ? (m.get(subject) || null) : null;
+ },
+ async projectsOf(token) {
+ const res = await fetch(`${network.url}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}` } });
+ return (await res.json()).projects;
+ },
+ };
+}
+
+(async () => {
+ const { startNetwork } = require('./helpers/mocks');
+ const net = await startNetwork();
+ let media;
+ const t = await boot({ clients: new Proxy({}, { get: (_o, k) => (...a) => media[k](...a) }) });
+ await net.close();
+ media = fakeMedia(t.network);
+ const owner = t.network.addUser('owen');
+ const viewer = t.network.addUser('vera');
+ const stranger = t.network.addUser('sam');
+ const mk = await fetch(`${t.network.url}/api/v1/projects`, { method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Shop' }) });
+ const project = (await mk.json()).id;
+ t.network.state.members.get(project).set(viewer.subject, 'viewer');
+ t.network.setEnvironmentPolicy(project, 'sandbox+production');
+ const same = { 'sec-fetch-site': 'same-origin' };
+
+ check('a member lists the project\'s zones; the default zone of each environment exists from then on', async () => {
+ const r = await t.get(`/api/v1/projects/${project}/zones`, { as: viewer });
+ assert.strictEqual(r.status, 200, r.text);
+ const list = r.json();
+ assert.ok(contracts.zones.checkObjectZoneList(list).valid, JSON.stringify(contracts.zones.checkObjectZoneList(list).errors));
+ assert.deepStrictEqual(list.zones.map((z) => [z.env, z.name, z.state, z.access]).sort(), [['production', 'default', 'active', 'per-object'], ['sandbox', 'default', 'active', 'per-object']]);
+ assert.ok(media.pushes.filter((p) => p.state === 'provisioning').length === 2 && media.pushes.filter((p) => p.state === 'active').length === 2, 'Media hears of each zone, then that it is active');
+ assert.strictEqual((await t.get(`/api/v1/projects/${project}/zones`, { as: stranger })).status, 404, 'a stranger is told there is no such project');
+ });
+
+ let assets;
+ check('an admin creates a zone; a repeat with the same Idempotency-Key answers the same zone', async () => {
+ const body = { env: 'production', name: 'assets', quota_bytes: 5_000_000 };
+ let r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: body, headers: { ...same, 'idempotency-key': 'create-assets-1' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assets = r.json();
+ assert.ok(contracts.zones.checkObjectZone(assets).valid);
+ assert.deepStrictEqual([assets.state, assets.access, assets.revision, assets.media_namespace], ['active', 'private', 2, `app.${project}.${assets.id}`]);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: body, headers: { ...same, 'idempotency-key': 'create-assets-1' } });
+ assert.deepStrictEqual([r.status, r.json().id], [201, assets.id]);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { ...body, quota_bytes: 1 }, headers: { ...same, 'idempotency-key': 'create-assets-1' } });
+ assert.strictEqual(r.json().code, 'zone.idempotency_conflict');
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'assets' }, headers: same });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.name_taken']);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'metrics' }, headers: same });
+ assert.strictEqual(r.json().code, 'zone.name_reserved');
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'Bad_Name' }, headers: same });
+ assert.strictEqual(r.json().code, 'zone.bad_request');
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: viewer, json: { env: 'production', name: 'mine' }, headers: same });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'zone.not_admin'], 'a viewer cannot create');
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'xsite' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(r.json().code, 'request.cross_site', 'a cookie write from another site is refused');
+ });
+
+ check('while Media is down a new zone stays provisioning, and the job activates it later', async () => {
+ media.setDown(true);
+ let r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'later' }, headers: same });
+ assert.deepStrictEqual([r.status, r.json().state], [201, 'provisioning']);
+ const id = r.json().id;
+ media.setDown(false);
+ await t.ctx.zones.tick();
+ r = await t.get(`/api/v1/zones/${id}`, { as: owner });
+ assert.strictEqual(r.json().state, 'active');
+ });
+
+ check('an app token of the project acts with its capability; a sandbox app only in the sandbox', async () => {
+ const res = await fetch(`${t.network.url}/api/v1/projects/${project}/apps`, { method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'deployer', environment: 'sandbox' }) });
+ const app = await res.json();
+ const tok = t.network.mintApp(app.id, 'openvibe.zone', ['zone.zone.create', 'zone.zone.list', 'zone.zone.read']);
+ let r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok, json: { env: 'sandbox', name: 'ci-cache' } });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok, json: { env: 'production', name: 'ci-prod' } });
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'zone.sandbox_only']);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: tok });
+ assert.ok(r.json().zones.every((z) => z.env === 'sandbox'), 'a sandbox app lists the sandbox');
+ r = await t.get(`/api/v1/zones/${assets.id}`, { bearer: tok });
+ assert.strictEqual(r.status, 404, 'nor reads a production zone');
+ const noCap = t.network.mintApp(app.id, 'openvibe.zone', ['zone.zone.list']);
+ r = await t.get(`/api/v1/projects/${project}/zones`, { bearer: noCap, json: { env: 'sandbox', name: 'nope' } });
+ assert.strictEqual(r.status, 403, 'without zone.zone.create, no create');
+ });
+
+ check('update: description and quota; the default zone keeps the environment\'s quota', async () => {
+ let r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner, method: 'PATCH', json: { description: 'site files', quota_bytes: 9_000_000 }, headers: same });
+ assert.deepStrictEqual([r.status, r.json().description, r.json().quota_bytes, r.json().revision], [200, 'site files', 9_000_000, 3]);
+ assert.deepStrictEqual(media.pushes.filter((p) => p.id === assets.id).map((p) => p.revision), [1, 2, 3], 'every revision reached Media');
+ const def = (await t.get(`/api/v1/projects/${project}/zones?env=production`, { as: owner })).json().zones.find((z) => z.default);
+ r = await t.get(`/api/v1/zones/${def.id}`, { as: owner, method: 'PATCH', json: { quota_bytes: 10 }, headers: same });
+ assert.strictEqual(r.json().code, 'zone.default');
+ r = await t.get(`/api/v1/zones/${def.id}`, { as: owner, method: 'DELETE', headers: same });
+ assert.strictEqual(r.json().code, 'zone.default', 'and cannot be deleted on its own');
+ });
+
+ check('usage is Media\'s snapshot, in zone.object-zone-usage@1', async () => {
+ media.zones.get(assets.id).objects = 3;
+ const r = await t.get(`/api/v1/zones/${assets.id}/usage`, { as: viewer });
+ assert.strictEqual(r.status, 200);
+ assert.ok(contracts.zones.checkObjectZoneUsage(r.json()).valid, JSON.stringify(contracts.zones.checkObjectZoneUsage(r.json()).errors));
+ assert.deepStrictEqual([r.json().current.objects, r.json().current.stored_bytes], [3, 300]);
+ const z = (await t.get(`/api/v1/zones/${assets.id}`, { as: viewer })).json();
+ assert.deepStrictEqual(z.usage.objects, 3);
+ });
+
+ check('deletion: not empty, held, then recursive; the job marks it deleted and its name is retired', async () => {
+ let r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner, method: 'DELETE', headers: same });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.not_empty']);
+ media.zones.get(assets.id).held = 1;
+ r = await t.get(`/api/v1/zones/${assets.id}?recursive=true`, { as: owner, method: 'DELETE', headers: same });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.held']);
+ media.zones.get(assets.id).held = 0;
+ r = await t.get(`/api/v1/zones/${assets.id}?recursive=true`, { as: owner, method: 'DELETE', headers: same });
+ assert.deepStrictEqual([r.status, r.json().state], [202, 'deleting']);
+ assert.ok(contracts.zones.checkObjectZone(r.json()).valid);
+ assert.strictEqual(media.zones.get(assets.id).record.state, 'deleting', 'Media hears of it at once');
+ await t.ctx.zones.tick();
+ assert.strictEqual((await t.get(`/api/v1/zones/${assets.id}`, { as: owner })).json().state, 'deleting', 'deleted only once Media reports nothing left');
+ media.zones.get(assets.id).completed = true;
+ await t.ctx.zones.tick();
+ r = await t.get(`/api/v1/zones/${assets.id}`, { as: owner });
+ assert.deepStrictEqual([r.json().state, r.json().deletion.objects_remaining, !!r.json().deletion.completed_at], ['deleted', 0, true]);
+ assert.ok(contracts.zones.checkObjectZone(r.json()).valid);
+ assert.strictEqual(media.zones.get(assets.id).record.state, 'deleted');
+ r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'assets' }, headers: same });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'zone.name_retired'], 'a deleted zone\'s name is never given again');
+ r = await t.get(`/api/v1/projects/${project}/zones?env=production`, { as: owner });
+ assert.ok(!r.json().zones.some((z) => z.id === assets.id), 'deleted zones leave the list');
+ r = await t.get(`/api/v1/projects/${project}/zones?env=production&include_deleted=true`, { as: owner });
+ assert.ok(r.json().zones.some((z) => z.id === assets.id), 'unless asked for');
+ });
+
+ check('Services: the resource index and a delete through the control API, for an admin only, once', async () => {
+ const svc = t.network.serviceToken('services', ['zone.resource.read', 'zone.resource.control']);
+ let r = await t.get(`/api/v1/resources?project=${project}`, { bearer: svc });
+ assert.strictEqual(r.status, 200, r.text);
+ const later = r.json().resources.find((x) => x.name === 'later (production)');
+ assert.ok(later && later.ovrn === `ovrn:zone:${project}:object-zone/${later.id}` && later.kind === 'zone.object-zone');
+ r = await t.get(`/api/v1/resources/${encodeURIComponent(later.ovrn)}`, { bearer: svc });
+ assert.strictEqual(r.json().id, later.id);
+ const req = (who, key) => ({ project_id: project, resource: later.ovrn, action: 'delete', on_behalf_of: { type: 'user', id: who.subject }, idempotency_key: key });
+ r = await t.get('/api/v1/resources/control', { bearer: svc, json: req(viewer, 'ctl-viewer-0001') });
+ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['refused', 'resources.unknown_resource'], 'a viewer cannot delete');
+ r = await t.get('/api/v1/resources/control', { bearer: svc, json: req(owner, 'ctl-owner-00001') });
+ assert.deepStrictEqual([r.json().state, r.json().result.status], ['done', 'deleting'], r.text);
+ const again = await t.get('/api/v1/resources/control', { bearer: svc, json: req(owner, 'ctl-owner-00001') });
+ assert.deepStrictEqual(again.json(), r.json(), 'a repeat returns the stored answer');
+ r = await t.get(`/api/v1/resources?project=${project}`, { bearer: t.network.serviceToken('services', ['zone.zone.read']) });
+ assert.strictEqual(r.status, 403, 'the index needs zone.resource.read');
+ });
+
+ check('the console: projects, zones, a key whose secret is shown once, and same-origin forms', async () => {
+ let r = await t.get('/projects', { as: owner });
+ assert.ok(r.status === 200 && r.text.includes('Shop') && r.text.includes(`/p/${project}`), r.text.slice(0, 400));
+ r = await t.get(`/p/${project}`, { as: owner });
+ assert.ok(r.status === 200 && r.text.includes('Production') && r.text.includes('Sandbox') && r.text.includes('Issue key'));
+ r = await t.get(`/p/${project}/zones`, { as: owner, form: { env: 'production', name: 'photos', quota_mb: '5' }, headers: { origin: 'https://openvibe.zone' } });
+ assert.ok(r.status === 200 && r.text.includes('Zone photos is ready in production'), r.text.slice(0, 500));
+ r = await t.get(`/p/${project}/keys`, { as: owner, form: { env: 'production', name: 'backups', permissions: 'write' }, headers: same });
+ assert.ok(r.text.includes('S'.repeat(40)) && r.text.includes('shown only once'), 'the secret is on the page that issued it');
+ r = await t.get(`/p/${project}`, { as: owner });
+ assert.ok(!r.text.includes('S'.repeat(40)) && r.text.includes('backups'), 'and on no other page');
+ r = await t.get(`/p/${project}/keys/${media.keys[0].id}/revoke`, { as: owner, method: 'POST', form: {}, headers: same });
+ assert.ok(r.text.includes('is revoked'));
+ r = await t.get(`/p/${project}/zones`, { as: owner, form: { env: 'production', name: 'evil' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(r.status, 403, 'a form from another site is refused');
+ r = await t.get(`/p/${project}`, { as: viewer });
+ assert.ok(r.text.includes('Only an admin or owner') && !r.text.includes('Issue key'), 'a viewer sees the zones, not the forms');
+ r = await t.get(`/p/${project}`, { as: stranger });
+ assert.strictEqual(r.status, 404);
+ r = await t.get(`/p/${project}`);
+ assert.strictEqual(r.status, 401);
+ r = await t.get('/');
+ assert.ok(r.status === 200 && r.text.includes('s3.openvibe.media') && r.text.includes('Private storage zones'));
+ r = await t.get('/docs');
+ assert.ok(r.status === 200 && r.text.includes('zone.zone.create'));
+ });
+
+ let failed = 0;
+ try {
+ for (const [name, fn] of checks) {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n${err.stack}`); }
+ }
+ } finally {
+ await t.close();
+ }
+ if (failed) { console.log(`${failed} failed`); process.exit(1); }
+ console.log('\nall passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });