openvibers/openvibe.search Commits
Search service: index documents with revision ordering and tombstones, event inbox, permission-aware FTS5 query API, removal events through an outbox
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew 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,1362 @@++{++ "name": "openvibe-search",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-search",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1",++ "better-sqlite3": "^11.10.0",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "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/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "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/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "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": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "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",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "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/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "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-signature": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",++ "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/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "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/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "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/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/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "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/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=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/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/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "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/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "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/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "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/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/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "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": "BSD-3-Clause"++ },++ "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/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "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/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/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "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/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "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/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "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/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.7.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",++ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",++ "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/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/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "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/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "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/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "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/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "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/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "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",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "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/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "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/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "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/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "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/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,28 @@++{++ "name": "openvibe-search",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Search: the canonical index document, event-fed and direct indexing with revision ordering and tombstones, and a permission-aware query API over SQLite FTS5.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "subscribe": "node scripts/subscribe.js"++ },++ "keywords": ["openvibe", "search", "fts5", "acl"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Search",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1",++ "better-sqlite3": "^11.10.0",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,53 @@++#!/usr/bin/env node++'use strict';++/**++ * Create Search's OpenVibe.Events subscription for index documents.++ *++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4710/internal/events] [--pattern '*.index_document.*']++ *++ * Reads the environment (.env or /etc/openvibe/search.env):++ * EVENTS_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the search++ * principal needs events.subscription.manage for audience openvibe.events)++ * SEARCH_EVENTS_SECRET the delivery signing secret; the first value is handed to Events, so++ * generate it before running this (e.g. `openssl rand -hex 32`). Nothing++ * secret is printed.++ * An existing identical subscription is reported, not duplicated.++ */++require('dotenv').config();++const { serviceAuth } = require('openvibe-contracts');++const { load } = require('../server/config');++++const args = process.argv.slice(2);++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };++++(async () => {++ const config = load();++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);++ const pattern = opt('pattern', '*.index_document.*');++ const secret = config.events.webhookSecrets[0];++ if (!config.events.url) throw new Error('EVENTS_URL is not set');++ if (!secret || secret.length < 32) throw new Error('SEARCH_EVENTS_SECRET must be set (32+ characters) before subscribing');++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events',++ scope: 'events.subscription.manage',++ });++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),++ });++ const body = await res.json().catch(() => ({}));++ if (res.status === 409 && body.subscription_id) {++ console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`);++ return;++ }++ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);++})().catch((err) => {++ console.error(`subscribe failed: ${err.message}`);++ process.exit(1);++});nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * Owner-side API (service token with search.document.write; a service writes and reads only the++ * documents it owns: svc:wiki → owner "wiki").++ *++ * PUT /api/v1/documents/:owner/:type/:id upsert (search.index-document@1 body)++ * DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone++ * GET /api/v1/owners/:owner/documents?type=&after=&limit=++ * reconciliation: (type, id, revision, deleted, hash)++ * GET /api/v1/owners/:owner/documents/:type/:id the stored document + effective indexability++ * GET /api/v1/owners/:owner/rejections?after= index-document events Search refused++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { validate, effectiveIndexability } = require('../document');++const { CAPS } = require('../auth');++++const OWNER_RE = /^[a-z][a-z0-9-]{1,39}$/;++const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;++const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;++++function validIdentity(p) {++ return OWNER_RE.test(p.owner) && TYPE_RE.test(p.type) && (p.id === undefined || ID_RE.test(p.id));++}++++function outcomeResponse(res, ctx, r) {++ if (r.outcome === 'stale') {++ return http.sendProblem(res, 409, 'search.stale_revision', {++ detail: `revision ${r.revision} is older than the stored revision ${r.stored_revision}${r.deleted ? ' (tombstone)' : ''}`,++ ctx, extra: { stored_revision: r.stored_revision, deleted: r.deleted },++ });++ }++ if (r.outcome === 'conflict') {++ return http.sendProblem(res, 409, 'search.revision_conflict', {++ detail: `revision ${r.revision} is already indexed with different content; send a new revision`,++ ctx, extra: { stored_revision: r.stored_revision },++ });++ }++ return res.json(r);++}++++function documentsRouter({ store, auth, db, relay }) {++ const router = express.Router();++ const guard = auth.requireCap(CAPS.write);++++ function ownerCheck(req, res) {++ const ctx = req.ov;++ if (!validIdentity(req.params)) {++ http.sendProblem(res, 400, 'search.bad_identity', { detail: 'owner, type or id is malformed', ctx });++ return false;++ }++ if (req.params.owner !== req.principal.service) {++ http.sendProblem(res, 403, 'search.not_owner', { detail: `svc:${req.principal.service} cannot write or read documents owned by ${req.params.owner}`, ctx });++ return false;++ }++ return true;++ }++++ router.put('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {++ const ctx = req.ov;++ if (!ownerCheck(req, res)) return;++ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;++ if (!body) return http.sendProblem(res, 400, 'search.bad_document', { detail: 'body must be an index document object', ctx });++ for (const k of ['owner', 'type', 'id']) {++ if (body[k] !== undefined && body[k] !== req.params[k]) {++ return http.sendProblem(res, 422, 'search.bad_document', { detail: `body ${k} does not match the path`, ctx });++ }++ }++ const doc = { ...body, owner: req.params.owner, type: req.params.type, id: req.params.id };++ const v = validate(doc);++ if (!v.valid) return http.sendProblem(res, 422, 'search.bad_document', { detail: 'document does not match search.index-document@1', ctx, errors: v.errors });++ const r = store.apply(doc, { via: 'api', traceId: ctx.traceId });++ if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});++ return outcomeResponse(res, ctx, r);++ });++++ router.delete('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {++ const ctx = req.ov;++ if (!ownerCheck(req, res)) return;++ let revision = null;++ if (req.query.revision !== undefined) {++ if (!/^\d{1,15}$/.test(String(req.query.revision))) return http.sendProblem(res, 400, 'search.bad_revision', { detail: 'revision must be a non-negative integer', ctx });++ revision = Number(req.query.revision);++ }++ const r = store.remove(req.params.owner, req.params.type, req.params.id, { revision, via: 'api', traceId: ctx.traceId });++ if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});++ return outcomeResponse(res, ctx, r);++ });++++ router.get('/api/v1/owners/:owner/documents', guard, (req, res) => {++ const ctx = req.ov;++ if (!ownerCheck(req, res)) return;++ const type = req.query.type ? String(req.query.type) : null;++ if (type && !TYPE_RE.test(type)) return http.sendProblem(res, 400, 'search.bad_identity', { detail: 'type is malformed', ctx });++ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 500, 1), 1000);++ res.json({ owner: req.params.owner, ...store.ownerPage(req.params.owner, { type, after, limit }) });++ });++++ router.get('/api/v1/owners/:owner/documents/:type/:id', guard, (req, res) => {++ const ctx = req.ov;++ if (!ownerCheck(req, res)) return;++ const found = store.get(req.params.owner, req.params.type, req.params.id);++ if (!found) return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such document', ctx });++ res.json({++ document: found.doc,++ effective_indexability: effectiveIndexability(found.doc),++ exposure: store.EXPOSURE_NAME[found.row.exposure],++ via: found.row.via,++ event_id: found.row.event_id,++ indexed_at: new Date(found.row.indexed_at).toISOString(),++ });++ });++++ router.get('/api/v1/owners/:owner/rejections', guard, (req, res) => {++ if (!ownerCheck(req, res)) return;++ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;++ const rows = db.prepare('SELECT * FROM ingest_rejections WHERE owner = ? AND seq > ? ORDER BY seq LIMIT 200').all(req.params.owner, after);++ res.json({++ rejections: rows.map(r => ({++ seq: r.seq, event_id: r.event_id, event_type: r.event_type, type: r.type, id: r.id, revision: r.revision,++ code: r.code, detail: r.detail, at: new Date(r.at).toISOString(),++ })),++ next_after: rows.length ? rows[rows.length - 1].seq : null,++ });++ });++++ return router;++}++++module.exports = { documentsRouter, OWNER_RE, TYPE_RE, ID_RE };nnew file mode 100644@@@ -0,0 +1,208 @@++'use strict';++/**++ * Query API. Visibility is decided by the engine SQL for every hit, count and suggestion:++ *++ * anonymous public, published, indexable documents only++ * user (Network JWT) + restricted documents whose ACL names its subject (or role:<role>)++ * service + X-OV-Subject + the ACL matches of that subject and the groups/entitlements the++ * (search.query.delegate) service vouches for++ *++ * GET /api/v1/search?q=&owner=&type=&lang=&facet.<key>=<value>&facets=<k1,k2>&limit=&cursor=++ * GET /api/v1/suggest?q=&owner=&type=&limit=++ * GET /api/v1/documents/:owner/:type/:id one document by exact id (unlisted: any signed-in++ * subject; public noindex: anyone)++ *++ * Nothing here ever returns an ACL, a draft, an unpublished or deleted document, a total count, or++ * a facet count over documents the viewer cannot see. A document the viewer may not see and a++ * document that does not exist give the same 404.++ */++const crypto = require('crypto');++const express = require('express');++const { http } = require('openvibe-contracts');++const { toMatch, snippetHtml } = require('../engine/fts5');++const { EXPOSURE } = require('../document');++const { AuthError } = require('../auth');++const { OWNER_RE, TYPE_RE, ID_RE } = require('./documents');++++const FACET_KEY_RE = /^[a-z][a-z0-9_]{0,39}$/;++const LANG_RE = /^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$/;++++class QueryError extends Error {++ constructor(code, detail) { super(detail); this.code = code; }++}++++function one(v) {++ return Array.isArray(v) ? v[0] : v;++}++++/** Filters from the query string (identity never comes from here). */++function parseFilters(query, config) {++ const filters = { facets: [] };++ const owner = one(query.owner);++ const type = one(query.type);++ const lang = one(query.lang);++ if (owner !== undefined) { if (!OWNER_RE.test(String(owner))) throw new QueryError('search.bad_query', 'owner is malformed'); filters.owner = String(owner); }++ if (type !== undefined) { if (!TYPE_RE.test(String(type))) throw new QueryError('search.bad_query', 'type is malformed'); filters.type = String(type); }++ if (lang !== undefined) { if (!LANG_RE.test(String(lang))) throw new QueryError('search.bad_query', 'lang is malformed'); filters.language = String(lang); }++ for (const [k, v] of Object.entries(query)) {++ if (!k.startsWith('facet.')) continue;++ const key = k.slice(6);++ if (!FACET_KEY_RE.test(key)) throw new QueryError('search.bad_query', `facet key ${key} is malformed`);++ const values = (Array.isArray(v) ? v : [v]).map(String).filter(x => x.length && x.length <= 200).slice(0, 20);++ if (!values.length) continue;++ filters.facets.push([key, values]);++ }++ if (filters.facets.length > config.query.maxFacetFilters) throw new QueryError('search.bad_query', `at most ${config.query.maxFacetFilters} facet filters`);++ return filters;++}++++function queryHash(parts) {++ return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('base64url').slice(0, 16);++}++++function encodeCursor(obj) {++ return Buffer.from(JSON.stringify(obj)).toString('base64url');++}++++function decodeCursor(s, hash) {++ if (s === undefined || s === '') return null;++ let c;++ try { c = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8')); } catch { c = null; }++ if (!c || c.h !== hash || !Number.isInteger(c.i)) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');++ if (c.k === 'r' && typeof c.r === 'number') return { rank: c.r, rid: c.i };++ if (c.k === 't' && typeof c.t === 'string') return { sort_at: c.t, rid: c.i };++ throw new QueryError('search.bad_cursor', 'cursor is malformed');++}++++/** What a viewer gets for one document: never the ACL, the hash or the owner's internals. */++function projection(doc, row, snip) {++ const out = {++ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,++ visibility: doc.visibility,++ title: doc.title,++ summary: doc.summary || null,++ canonical_url: doc.canonical_url,++ facets: doc.facets,++ language: doc.language,++ authorship: doc.authorship,++ provenance: doc.provenance,++ published_at: doc.published_at,++ updated_at: doc.updated_at,++ indexable: row.index_decision === 'index',++ };++ if (snip !== undefined) out.snippet_html = snippetHtml(snip);++ return out;++}++++/** May this viewer see this stored document by exact id? */++function canView(viewer, row, doc) {++ if (row.deleted || row.exposure === EXPOSURE.none) return false;++ if (row.exposure >= EXPOSURE.public_unlisted) return true;++ if (!viewer.subject) return false;++ const acl = doc.acl || {};++ const subj = (acl.subjects || []).includes(viewer.subject);++ if (doc.visibility === 'private') return subj;++ if (doc.visibility === 'unlisted') return true; // a signed-in subject holding the exact id++ if (doc.visibility === 'members') {++ return subj++ || (acl.groups || []).some(g => (viewer.groups || []).includes(g))++ || (acl.entitlements || []).some(e => (viewer.entitlements || []).includes(e));++ }++ return false;++}++++function queryRouter({ config, store, engine, auth }) {++ const router = express.Router();++++ function withViewer(handler) {++ return (req, res) => {++ const ctx = req.ov;++ let viewer;++ try {++ viewer = auth.viewer(req);++ } catch (err) {++ if (err instanceof AuthError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });++ throw err;++ }++ // Never cached: a visibility change or deletion must leave results immediately, and a++ // personalised answer must never reach a shared cache.++ res.setHeader('Cache-Control', 'no-store');++ res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');++ try {++ return handler(req, res, viewer);++ } catch (err) {++ if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });++ throw err;++ }++ };++ }++++ router.get('/api/v1/search', withViewer((req, res, viewer) => {++ const q = one(req.query.q);++ const text = q === undefined ? '' : String(q);++ if (text.length > 500) throw new QueryError('search.bad_query', 'q is longer than 500 characters');++ const filters = parseFilters(req.query, config);++ const limit = Math.min(Math.max(parseInt(one(req.query.limit), 10) || config.query.defaultLimit, 1), config.query.maxLimit);++ const facetKeys = String(one(req.query.facets) || '').split(',').map(s => s.trim()).filter(Boolean);++ if (facetKeys.length > 5 || facetKeys.some(k => !FACET_KEY_RE.test(k))) throw new QueryError('search.bad_query', 'facets: at most 5 well-formed keys');++++ const match = text.trim() ? toMatch(text, { maxTerms: config.query.maxTerms }) : null;++ const hash = queryHash([match, filters]);++ const after = decodeCursor(one(req.query.cursor), hash);++ if (text.trim() && !match) {++ return res.json({ results: [], next_cursor: null, ...(facetKeys.length ? { facets: Object.fromEntries(facetKeys.map(k => [k, []])) } : {}) });++ }++ let page;++ if (match) {++ if (after && after.rank === undefined) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');++ page = engine.search({ match, filters, viewer, limit, after });++ } else {++ if (after && after.sort_at === undefined) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');++ page = engine.browse({ filters, viewer, limit, after });++ }++ const results = [];++ for (const h of page.hits) {++ const found = store.byRid(h.rid);++ // Defence in depth: the engine already filtered; re-check against the stored row.++ if (!found || !canView(viewer, found.row, found.doc)) continue;++ results.push(projection(found.doc, found.row, match ? h.snip : undefined));++ }++ const next = page.next++ ? encodeCursor(match ? { k: 'r', r: page.next.rank, i: page.next.rid, h: hash } : { k: 't', t: page.next.sort_at, i: page.next.rid, h: hash })++ : null;++ const body = { results, next_cursor: next };++ if (facetKeys.length) body.facets = engine.facetCounts({ match, filters, viewer, keys: facetKeys });++ res.json(body);++ }));++++ router.get('/api/v1/suggest', withViewer((req, res, viewer) => {++ const text = String(one(req.query.q) || '');++ if (text.length > 100) throw new QueryError('search.bad_query', 'q is longer than 100 characters');++ const filters = parseFilters(req.query, config);++ const limit = Math.min(Math.max(parseInt(one(req.query.limit), 10) || 8, 1), 20);++ const match = toMatch(text, { maxTerms: 6, column: 'title', prefixLast: true });++ if (!match) return res.json({ suggestions: [] });++ const rows = engine.suggest({ match, filters, viewer, limit });++ const suggestions = [];++ for (const r of rows) {++ const found = store.byRid(r.rid);++ if (!found || !canView(viewer, found.row, found.doc)) continue;++ suggestions.push({ owner: found.doc.owner, type: found.doc.type, id: found.doc.id, title: found.doc.title, canonical_url: found.doc.canonical_url });++ }++ res.json({ suggestions });++ }));++++ router.get('/api/v1/documents/:owner/:type/:id', withViewer((req, res, viewer) => {++ const { owner, type, id } = req.params;++ if (!OWNER_RE.test(owner) || !TYPE_RE.test(type) || !ID_RE.test(id)) throw new QueryError('search.bad_identity', 'owner, type or id is malformed');++ const found = store.get(owner, type, id);++ if (!found || !canView(viewer, found.row, found.doc)) {++ return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such document', ctx: req.ov });++ }++ res.json({ document: projection(found.doc, found.row) });++ }));++++ return router;++}++++module.exports = { queryRouter, canView, projection, toMatch };nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * Events consumer: POST /internal/events, the endpoint of Search's OpenVibe.Events subscription++ * (topic pattern `*.index_document.*`, see scripts/subscribe.js).++ *++ * Owners publish, through their own outbox:++ * <owner>.index_document.upserted payload = search.index-document@1 (owner may be omitted)++ * <owner>.index_document.deleted payload = { type, id, revision } (a tombstone)++ * with envelope subject { type, id, revision } and visibility "internal".++ *++ * Exactly once: the inbox receipt (consumer, event_id) and the document change commit in one++ * SQLite transaction. A redelivery of a processed event is answered 204 and changes nothing.++ * Revision order is the store's rule, so out-of-order delivery is harmless.++ *++ * Refusals (unknown owner, a source writing another owner's document, a bad document, a subject++ * that does not match the payload) are acknowledged, recorded once in ingest_rejections and++ * visible to the owner through GET /api/v1/owners/:owner/rejections: retrying cannot fix them.++ */++const crypto = require('crypto');++const express = require('express');++const { http } = require('openvibe-contracts');++const { validate } = require('../document');++++const CONSUMER = 'search-index';++const TYPE_RE = /^([a-z][a-z0-9-]{1,39})\.index_document\.(upserted|deleted)$/;++++function sign(raw, secret) {++ return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');++}++++function verifySignature(raw, header, secrets) {++ if (!raw || typeof header !== 'string') return false;++ const given = Buffer.from(header.trim());++ return secrets.some((s) => {++ const expected = Buffer.from(sign(raw, s));++ return given.length === expected.length && crypto.timingSafeEqual(given, expected);++ });++}++++function createInbox(db, { now = () => Date.now() } = {}) {++ const claim = db.prepare('INSERT OR IGNORE INTO idempotency_receipts (consumer, event_id, outcome, processed_at) VALUES (?, ?, ?, ?)');++ const setOutcome = db.prepare('UPDATE idempotency_receipts SET outcome = ? WHERE consumer = ? AND event_id = ?');++ const reject = db.prepare(`INSERT INTO ingest_rejections (event_id, event_type, owner, type, id, revision, code, detail, at)++ VALUES (@event_id, @event_type, @owner, @type, @id, @revision, @code, @detail, @at)`);++++ /** fn() runs inside the transaction and returns an outcome string. */++ function once(eventId, fn) {++ return db.transaction(() => {++ if (claim.run(CONSUMER, eventId, 'processing', now()).changes === 0) return { duplicate: true };++ const outcome = fn();++ setOutcome.run(outcome, CONSUMER, eventId);++ return { duplicate: false, outcome };++ })();++ }++++ function recordRejection(r) {++ reject.run({ owner: null, type: null, id: null, revision: null, detail: null, event_type: null, ...r, at: now() });++ }++++ return { once, recordRejection };++}++++/** Turn one delivered envelope into { doc } or { reject: { code, detail } } or { ignore }. */++function documentFromEvent(event, owners) {++ const m = TYPE_RE.exec(String(event.event_type || ''));++ if (!m) return { ignore: 'not an index_document event' };++ const [, prefixOwner, action] = m;++ const source = event.source;++ if (prefixOwner !== source) return { reject: { code: 'owner_mismatch', detail: `event_type ${event.event_type} from source ${source}` } };++ if (!owners.includes(source)) return { reject: { code: 'owner_not_accepted', detail: `${source} is not in SEARCH_EVENT_OWNERS` } };++ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};++ if (payload.owner !== undefined && payload.owner !== source) {++ return { reject: { code: 'owner_mismatch', detail: `payload owner ${payload.owner} from source ${source}` } };++ }++ const doc = action === 'deleted'++ ? { owner: source, type: payload.type, id: payload.id, revision: payload.revision, deleted: true }++ : { ...payload, owner: source };++ const subject = event.subject || {};++ if (subject.type !== doc.type || subject.id !== doc.id || (subject.revision !== undefined && subject.revision !== doc.revision)) {++ return { reject: { code: 'subject_mismatch', detail: 'envelope subject {type, id, revision} must match the document' } };++ }++ const v = validate(doc);++ if (!v.valid) return { reject: { code: 'invalid_document', detail: v.errors.map(e => `${e.path} ${e.message}`).join('; ').slice(0, 1000) } };++ return { doc };++}++++function webhookRouter({ config, db, store, relay, log = console, now }) {++ const router = express.Router();++ const inbox = createInbox(db, { now });++++ router.post('/internal/events',++ express.raw({ type: () => true, limit: config.maxBodyBytes }),++ (req, res) => {++ const ctx = req.ov;++ const secrets = config.events.webhookSecrets;++ if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ if (!verifySignature(raw, req.get('x-openvibe-signature'), secrets)) {++ return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });++ }++ let body;++ try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }++ const event = body && body.event;++ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {++ return http.sendProblem(res, 400, 'search.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx });++ }++++ const r = inbox.once(event.event_id, () => {++ const parsed = documentFromEvent(event, config.events.owners);++ if (parsed.ignore) return 'ignored';++ if (parsed.reject) {++ const p = event.payload || {};++ inbox.recordRejection({++ event_id: event.event_id, event_type: event.event_type, owner: event.source,++ type: typeof p.type === 'string' ? p.type.slice(0, 40) : null,++ id: typeof p.id === 'string' ? p.id.slice(0, 128) : null,++ revision: Number.isInteger(p.revision) ? p.revision : null,++ code: parsed.reject.code, detail: parsed.reject.detail,++ });++ log.warn(`[inbox] refused ${event.event_type} ${event.event_id}: ${parsed.reject.code}`);++ return `rejected:${parsed.reject.code}`;++ }++ const traceId = typeof event.trace_id === 'string' ? event.trace_id : null;++ const out = store.apply(parsed.doc, { via: 'event', eventId: event.event_id, traceId });++ return out.outcome;++ });++ if (!r.duplicate && r.outcome === 'applied' && relay) relay.flush().catch(() => {});++ res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.outcome || null });++ });++++ return router;++}++++module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/** Express app: request context, the v1 API, the Events webhook, health/readiness. */++const express = require('express');++const { http } = require('openvibe-contracts');++const { documentsRouter } = require('./api/documents');++const { queryRouter } = require('./api/query');++const { webhookRouter } = require('./api/webhook');++const pkg = require('../package.json');++++function createApp({ config, db, store, engine, auth, keys, outbox, relay, log = console, now }) {++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', 'loopback');++ app.set('query parser', 'extended');++ app.use(http.middleware());++ app.use((req, res, next) => {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ next();++ });++++ // The webhook reads its raw body itself (the signature covers the exact bytes).++ app.use(webhookRouter({ config, db, store, relay, log, now }));++++ app.use('/api', express.json({ limit: config.maxBodyBytes, type: ['application/json', 'application/*+json'] }));++++ app.get('/api/health', (_req, res) => {++ res.json({ status: 'ok', service: 'openvibe-search', version: pkg.version });++ });++++ app.get('/api/ready', (_req, res) => {++ let dbOk = false;++ try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }++ const checks = { db: dbOk, key: keys.loaded() };++ const ready = Object.values(checks).every(Boolean);++ res.status(ready ? 200 : 503).json({++ status: ready ? 'ready' : 'not_ready',++ checks,++ engine: engine.name,++ documents: dbOk ? store.counts() : null,++ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,++ webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',++ });++ });++++ app.use(documentsRouter({ store, auth, db, relay }));++ app.use(queryRouter({ config, store, engine, auth }));++++ app.get('/', (_req, res) => {++ res.type('text/plain').send([++ 'OpenVibe.Search: permission-aware search over documents the network\'s services index.',++ '',++ 'GET /api/v1/search?q=&owner=&type=&facet.<key>=&cursor= query (anonymous: public only)',++ 'GET /api/v1/suggest?q= title suggestions',++ 'GET /api/v1/documents/:owner/:type/:id one document the caller may see',++ 'PUT /api/v1/documents/:owner/:type/:id index (owner, search.document.write)',++ 'DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone (owner)',++ 'GET /api/v1/owners/:owner/documents reconciliation (owner)',++ 'POST /internal/events OpenVibe.Events delivery (signed)',++ 'GET /api/health, /api/ready',++ '',++ 'Source: https://github.com/OpenVibers/OpenVibe.Search',++ '',++ ].join('\n'));++ });++++ app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ if (err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'search.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });++ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'search.too_large', { detail: 'request body too large', ctx: req.ov });++ log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);++ if (res.headersSent) return res.end();++ return http.sendProblem(res, 500, 'search.internal', { detail: 'internal error', ctx: req.ov });++ });++++ return app;++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++/**++ * Authentication, capabilities and the query viewer.++ *++ * - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience++ * openvibe.search), verified offline with openvibe-contracts serviceAuth.verifyServiceToken.++ * - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.++ *++ * search.document.write and search.query.delegate are proposed in docs/capabilities-proposal/ and++ * are not in openvibe-contracts yet. checkCapability() decides them with the library's own grant++ * rule (exact id or a `family.*` grant) until a contracts release knows them; from then on the++ * library decides and nothing changes here.++ */++const crypto = require('crypto');++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const CAPS = Object.freeze({++ write: 'search.document.write',++ delegate: 'search.query.delegate',++});++const PROPOSED = new Set(Object.values(CAPS));++++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const GROUP_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;++const MAX_VIEWER_KEYS = 100;++++// ── Network public key ─────────────────────────────────────++++/**++ * Loads the Network signing key from GET /api/.well-known/jwks ({ keys: [jwk] } or the older++ * { public_key: PEM }), retrying every 30 s until it loads and refreshing every 6 h after that.++ * A PEM given in config (OV_NETWORK_PUBLIC_KEY) is used as is and never fetched.++ */++function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {++ let key = pem ? toPem(pem) : null;++ let retryTimer = null;++ let refreshTimer = null;++++ function toPem(value) {++ return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' });++ }++++ async function fetchOnce() {++ for (const base of urls) {++ if (!base) continue;++ const url = `${base}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`HTTP ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find(k => k.kty === 'RSA');++ if (jwk) key = toPem({ key: jwk, format: 'jwk' });++ else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);++ else throw new Error('no RSA key in response');++ log.log(`[auth] Network public key loaded from ${base}`);++ return key;++ } catch (err) {++ log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);++ }++ }++ return null;++ }++++ function start() {++ if (pem) return Promise.resolve(key);++ const attempt = async () => {++ const k = await fetchOnce();++ if (!k && !key) {++ retryTimer = setTimeout(attempt, 30 * 1000);++ retryTimer.unref?.();++ }++ return k;++ };++ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);++ refreshTimer.unref?.();++ return attempt();++ }++++ function stop() {++ clearTimeout(retryTimer);++ clearInterval(refreshTimer);++ }++++ return { get: () => key, loaded: () => Boolean(key), start, stop, fetchOnce };++}++++// ── Capabilities ───────────────────────────────────────────++++/** → { allowed, code, reason } like capabilities.check(). */++function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }++ return capabilities.check(claims, capabilityId);++}++++// ── Tokens ─────────────────────────────────────────────────++++const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));++++function decodePayload(token) {++ const parts = typeof token === 'string' ? token.split('.') : [];++ if (parts.length !== 3) return null;++ try { return b64json(parts[1]); } catch { return null; }++}++++/** Network user JWT (RS256). Returns claims or null. Service tokens are never accepted as users. */++function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {++ if (!publicKey || typeof token !== 'string') return null;++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ try {++ const header = b64json(parts[0]);++ if (header.alg !== 'RS256') return null;++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'));++ if (!ok) return null;++ const claims = b64json(parts[1]);++ const t = Math.floor(now / 1000);++ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;++ if (issuer && claims.iss !== issuer) return null;++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];++ if (!aud.some(a => audiences.includes(a))) return null;++ if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;++ return claims;++ } catch {++ return null;++ }++}++++function bearer(req) {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;++}++++function cookie(req, name) {++ const raw = String(req.headers.cookie || '');++ for (const part of raw.split(';')) {++ const i = part.indexOf('=');++ if (i > 0 && part.slice(0, i).trim() === name) {++ try { return decodeURIComponent(part.slice(i + 1).trim()); } catch { return null; }++ }++ }++ return null;++}++++/** Service slug for a principal sub: svc:wiki -> 'wiki'; app:/mod: principals have none. */++function serviceSlug(sub) {++ const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String(sub || ''));++ return m ? m[1] : null;++}++++class AuthError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++/** Comma-separated group/entitlement keys from a header; a malformed key refuses the request. */++function keyList(value, header) {++ if (value == null || value === '') return [];++ const out = [...new Set(String(value).split(',').map(s => s.trim()).filter(Boolean))];++ if (out.length > MAX_VIEWER_KEYS) throw new AuthError(400, 'search.bad_viewer', `${header}: at most ${MAX_VIEWER_KEYS} keys`);++ for (const k of out) if (!GROUP_RE.test(k)) throw new AuthError(400, 'search.bad_viewer', `${header}: malformed key`);++ return out;++}++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, groups: [], entitlements: [] });++++function createAuth({ config, keys }) {++ function verifyService(token) {++ const publicKey = keys.get();++ if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };++ return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });++ }++++ function verifyUser(token) {++ return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });++ }++++ /**++ * Express guard for service routes: one capability. Sets req.principal = { sub, service, cap }.++ * Only service principals (svc:<slug>) pass: documents are always owned by a service.++ */++ function requireCap(id) {++ return function capGuard(req, res, next) {++ const ctx = req.ov;++ const token = bearer(req);++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });++ const r = verifyService(token);++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });++ const c = checkCapability(r.claims, id);++ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });++ const service = serviceSlug(r.claims.sub);++ if (!service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });++ req.principal = { sub: r.claims.sub, service, cap: r.claims.cap, jti: r.claims.jti };++ return next();++ };++ }++++ function userViewer(claims) {++ const subject = typeof claims.subject_id === 'string' && ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ // Without a canonical subject the user is treated like anyone else (public only).++ if (!subject) return ANONYMOUS;++ const groups = [];++ if (typeof claims.role === 'string' && /^[a-z][a-z0-9_]{0,31}$/.test(claims.role)) groups.push(`role:${claims.role}`);++ return { kind: 'user', subject, groups, entitlements: [] };++ }++++ /**++ * Who is asking a query. Identity never comes from a body or query string.++ * anonymous public only++ * user (Network JWT, subject_id) + ACL matches on its subject and role:<role>++ * service with search.query.delegate + X-OV-Subject (usr_/gst_), X-OV-Groups, X-OV-Entitlements++ * vouched for by the calling first-party service++ * A presented Bearer must verify (never downgraded to anonymous); an unverifiable cookie is++ * treated as signed out.++ */++ function viewer(req) {++ const token = bearer(req);++ if (token) {++ const payload = decodePayload(token);++ if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {++ const r = verifyService(token);++ if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);++ const c = checkCapability(r.claims, CAPS.delegate);++ if (!c.allowed) throw new AuthError(403, c.code, c.reason);++ const subjectHeader = req.get('x-ov-subject');++ if (!subjectHeader) {++ if (req.get('x-ov-groups') || req.get('x-ov-entitlements')) {++ throw new AuthError(400, 'search.bad_viewer', 'X-OV-Groups and X-OV-Entitlements need an X-OV-Subject');++ }++ return { ...ANONYMOUS, kind: 'service', service: r.claims.sub };++ }++ if (!ids.isSubjectId('user', subjectHeader) && !ids.isSubjectId('guest', subjectHeader)) {++ throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');++ }++ return {++ kind: 'service',++ service: r.claims.sub,++ subject: subjectHeader,++ groups: keyList(req.get('x-ov-groups'), 'X-OV-Groups'),++ entitlements: keyList(req.get('x-ov-entitlements'), 'X-OV-Entitlements'),++ };++ }++ if (!keys.get()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');++ const user = verifyUser(token);++ if (!user) throw new AuthError(401, 'token.invalid', 'token does not verify');++ return userViewer(user);++ }++ const fromCookie = cookie(req, 'ov_token');++ const user = fromCookie ? verifyUser(fromCookie) : null;++ return user ? userViewer(user) : ANONYMOUS;++ }++++ return { verifyService, verifyUser, requireCap, viewer };++}++++module.exports = { CAPS, PROPOSED, ANONYMOUS, AuthError, createKeyStore, createAuth, checkCapability, verifyUserJwt, serviceSlug, bearer, cookie };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * OpenVibe.Search configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/search.env in production); see .env.example for the documented list.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++++const int = (v, d) => {++ const n = parseInt(v, 10);++ return Number.isFinite(n) ? n : d;++};++const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));++const strip = (v) => String(v || '').replace(/\/$/, '');++++/**++ * Services whose index-document events are accepted from OpenVibe.Events. Events already++ * guarantees an event's `source` is the service that published it; this list decides which++ * sources may feed the index at all. The direct API is decided by the search.document.write grant.++ */++const DEFAULT_EVENT_OWNERS = ['wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade',++ 'community', 'live', 'media', 'codes', 'games', 'tools', 'sources'];++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4710);++ return {++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ serviceId: 'search',++ baseUrl: strip(env.BASE_URL || (isProduction ? 'https://search.openvibe.network' : `http://localhost:${port}`)),++++ // Identity: OpenVibe.Network signs service tokens (audience openvibe.search) and user JWTs.++ networkUrl: strip(env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkInternalUrl: strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,++ audience: 'openvibe.search',++ // A browser's user JWT is accepted when its aud contains one of these.++ userAudiences: list(env.SEARCH_USER_AUDIENCES, ['openvibe.search', 'openvibe.network']),++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'search',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ },++++ dbPath: env.SEARCH_DB_PATH || './data/search.db',++++ // Events: the outbox relays only when EVENTS_URL is set (rows wait otherwise).++ events: {++ url: strip(env.EVENTS_URL || ''),++ relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // Subscription secrets for POST /internal/events (comma-separated: rotation keeps both).++ webhookSecrets: list(env.SEARCH_EVENTS_SECRET, []),++ owners: list(env.SEARCH_EVENT_OWNERS, DEFAULT_EVENT_OWNERS),++ },++++ query: {++ maxLimit: 50,++ defaultLimit: 20,++ maxTerms: 16,++ maxFacetFilters: 8,++ },++ maxBodyBytes: int(env.SEARCH_MAX_BODY_BYTES, 256 * 1024),++ };++}++++module.exports = { load, DEFAULT_EVENT_OWNERS };nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++/**++ * The Search database (SQLite, WAL). One file owned by this service only; created on boot,++ * idempotently. The FTS5 tables belong to the engine (server/engine/fts5.js).++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const outbox = require('./events/outbox');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS documents (++ rid INTEGER PRIMARY KEY,++ owner TEXT NOT NULL,++ type TEXT NOT NULL,++ id TEXT NOT NULL,++ revision INTEGER NOT NULL,++ deleted INTEGER NOT NULL DEFAULT 0,++ visibility TEXT,++ publication_state TEXT,++ exposure INTEGER NOT NULL DEFAULT 0, -- document.js EXPOSURE (0 none .. 3 public listed)++ index_decision TEXT NOT NULL,++ noindex_reasons TEXT NOT NULL DEFAULT '[]',++ canonical_url TEXT,++ title TEXT,++ language TEXT,++ published_at TEXT,++ updated_at TEXT,++ sort_at TEXT NOT NULL DEFAULT '', -- updated_at, else published_at: browse order++ doc TEXT NOT NULL, -- normalized document JSON (tombstone: identity only)++ hash TEXT NOT NULL,++ via TEXT NOT NULL, -- event | api++ event_id TEXT,++ indexed_at INTEGER NOT NULL,++ UNIQUE (owner, type, id)++);++CREATE INDEX IF NOT EXISTS idx_documents_exposure ON documents (exposure, sort_at, rid);++CREATE INDEX IF NOT EXISTS idx_documents_owner ON documents (owner, type, id);++++-- ACL keys of servable non-public documents: kind s = subject, g = group, e = entitlement.++CREATE TABLE IF NOT EXISTS doc_acl (++ rid INTEGER NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('s', 'g', 'e')),++ value TEXT NOT NULL,++ PRIMARY KEY (rid, kind, value)++) WITHOUT ROWID;++CREATE INDEX IF NOT EXISTS idx_doc_acl_value ON doc_acl (kind, value, rid);++++CREATE TABLE IF NOT EXISTS doc_facets (++ rid INTEGER NOT NULL,++ key TEXT NOT NULL,++ value TEXT NOT NULL,++ PRIMARY KEY (rid, key, value)++) WITHOUT ROWID;++CREATE INDEX IF NOT EXISTS idx_doc_facets_value ON doc_facets (key, value, rid);++++-- Inbox: one receipt per consumed event (exactly-once effects), with what happened.++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer TEXT NOT NULL,++ event_id TEXT NOT NULL,++ outcome TEXT NOT NULL,++ processed_at INTEGER NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- Index-document events Search refused (bad document, wrong owner, subject mismatch). Owners++-- read their own through GET /api/v1/owners/:owner/rejections.++CREATE TABLE IF NOT EXISTS ingest_rejections (++ seq INTEGER PRIMARY KEY AUTOINCREMENT,++ event_id TEXT NOT NULL,++ event_type TEXT,++ owner TEXT,++ type TEXT,++ id TEXT,++ revision INTEGER,++ code TEXT NOT NULL,++ detail TEXT,++ at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_ingest_rejections_owner ON ingest_rejections (owner, seq);++`;++++function openDb(dbPath) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.pragma('synchronous = NORMAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ db.exec(SCHEMA);++ outbox.ensureSchema(db);++ return db;++}++++module.exports = { openDb };nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++/**++ * The index document (docs/contracts-proposal/contracts/search/index-document.v1.json):++ * validation, normalization, the effective indexability decision and the audience a document++ * reaches. Pure functions; the store and the engine build on them.++ *++ * The schema file is the proposal for OpenVibe.Contracts (`search.index-document@1`). Once a++ * contracts release ships it, validate() switches to contracts.validate and the file here goes.++ */++const crypto = require('crypto');++const path = require('path');++const Ajv2020 = require('ajv/dist/2020');++const addFormats = require('ajv-formats');++++const SCHEMA_PATH = path.join(__dirname, '..', 'docs', 'contracts-proposal', 'contracts', 'search', 'index-document.v1.json');++const schema = require(SCHEMA_PATH);++const ajv = new Ajv2020({ allErrors: true, strict: false });++addFormats(ajv);++const validateFn = ajv.compile(schema);++++const VISIBILITIES = ['public', 'unlisted', 'members', 'private', 'draft'];++++/**++ * How far a document reaches, lowest first. A drop in exposure is a removal (it leaves results++ * or public delivery) and is announced with search.document.removed.++ */++const EXPOSURE = Object.freeze({ none: 0, restricted: 1, public_unlisted: 2, public_listed: 3 });++++/** → { valid: true } | { valid: false, errors: [{ path, message }] } */++function validate(doc) {++ const ok = validateFn(doc);++ if (ok) return { valid: true, errors: [] };++ return {++ valid: false,++ errors: (validateFn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })),++ };++}++++// C0/C1 control characters except tab/newline; they have no place in indexed text.++// eslint-disable-next-line no-control-regex++const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;++const clean = (s) => (typeof s === 'string' ? s.replace(CONTROL_RE, '').trim() : '');++++function canonicalJson(value) {++ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;++ if (value && typeof value === 'object') {++ return `{${Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;++ }++ return JSON.stringify(value);++}++++function sha256(s) {++ return crypto.createHash('sha256').update(s).digest('hex');++}++++/** Facet values as strings (numbers/booleans stringified, arrays flattened), for exact filtering. */++function facetPairs(facets) {++ const out = [];++ for (const [key, v] of Object.entries(facets || {})) {++ const values = Array.isArray(v) ? v : [v];++ for (const x of new Set(values.map(String))) out.push([key, x]);++ }++ return out;++}++++/**++ * The effective indexability: the owner's decision plus the reasons Search enforces. It can only++ * get stricter here; nothing the owner sends can make a draft, private or stub document indexable.++ */++function effectiveIndexability(doc) {++ const reasons = new Set((doc.indexability && doc.indexability.reasons) || []);++ if (doc.deleted) reasons.add('deleted');++ else {++ if (doc.visibility === 'draft') reasons.add('draft');++ if (doc.visibility === 'private') reasons.add('private');++ if (doc.visibility === 'members') reasons.add('members_only');++ if (doc.visibility === 'unlisted') reasons.add('unlisted');++ if (doc.publication_state !== 'published') reasons.add('not_published');++ if ((doc.provenance || []).some(p => p.stub === true)) reasons.add('stub_provider');++ if (doc.visibility === 'public' && !doc.canonical_url) reasons.add('missing_canonical_url');++ }++ const ownerSaysIndex = Boolean(doc.indexability && doc.indexability.decision === 'index');++ if (!ownerSaysIndex && !reasons.size) reasons.add('owner_decision');++ const decision = ownerSaysIndex && reasons.size === 0 ? 'index' : 'noindex';++ return { decision, reasons: [...reasons].sort() };++}++++/** Is the document served to anyone at all? Drafts, unpublished and deleted documents never are. */++function servable(doc) {++ return !doc.deleted && doc.visibility !== 'draft' && doc.publication_state === 'published';++}++++function exposureOf(doc) {++ if (!doc || !servable(doc)) return EXPOSURE.none;++ if (doc.visibility !== 'public') return EXPOSURE.restricted;++ return effectiveIndexability(doc).decision === 'index' ? EXPOSURE.public_listed : EXPOSURE.public_unlisted;++}++++/**++ * Normalize a valid document into what the store keeps. A tombstone keeps only its identity.++ * Returns { doc, hash } where hash covers every stored field (same revision + same hash = replay).++ */++function normalize(input) {++ const base = { owner: input.owner, type: input.type, id: input.id, revision: input.revision };++ if (input.deleted === true) {++ const doc = { ...base, deleted: true };++ return { doc, hash: sha256(canonicalJson(doc)) };++ }++ const acl = input.acl || {};++ const doc = {++ ...base,++ deleted: false,++ visibility: input.visibility,++ acl: {++ subjects: [...new Set(acl.subjects || [])].sort(),++ groups: [...new Set(acl.groups || [])].sort(),++ entitlements: [...new Set(acl.entitlements || [])].sort(),++ },++ canonical_url: input.canonical_url || null,++ title: clean(input.title),++ summary: clean(input.summary),++ body: clean(input.body),++ facets: input.facets || {},++ language: input.language || null,++ authorship: input.authorship || null,++ provenance: input.provenance || [],++ publication_state: input.publication_state,++ published_at: input.published_at || null,++ updated_at: input.updated_at || null,++ indexability: {++ decision: input.indexability.decision,++ reasons: [...new Set(input.indexability.reasons || [])].sort(),++ },++ };++ return { doc, hash: sha256(canonicalJson(doc)) };++}++++module.exports = {++ schema, SCHEMA_PATH, VISIBILITIES, EXPOSURE,++ validate, normalize, effectiveIndexability, servable, exposureOf, facetPairs, canonicalJson, sha256,++};nnew file mode 100644@@@ -0,0 +1,205 @@++'use strict';++/**++ * Search engine: SQLite FTS5 in the service's own database (decision: docs/adr-engine.md).++ *++ * Two full-text tables, split by audience so an anonymous query never touches, ranks against or++ * counts anything but public listed documents:++ *++ * fts_public documents with exposure public_listed (public, published, indexable)++ * fts_restricted documents with exposure restricted (unlisted/members/private, published)++ *++ * Drafts, unpublished, deleted and public-noindex documents are in neither, so no MATCH can++ * reach them. Every query is also filtered by `documents.exposure` and, for restricted rows, by++ * the viewer's ACL keys (doc_acl) in the same SQL: visibility is decided before anything is++ * ranked, counted, snippeted or suggested.++ *++ * The interface (put/remove/search/browse/facetCounts/suggest) is what a PostgreSQL FTS engine++ * would implement; nothing outside this file writes SQL against the FTS tables.++ */++const { EXPOSURE } = require('../document');++++const TOKENIZE = "tokenize = 'unicode61 remove_diacritics 2'";++const WEIGHTS = '10.0, 4.0, 1.0'; // title, summary, body++const MARK_OPEN = '\u0001';++const MARK_CLOSE = '\u0002';++++function createEngine(db) {++ db.exec(`++ CREATE VIRTUAL TABLE IF NOT EXISTS fts_public USING fts5(title, summary, body, ${TOKENIZE});++ CREATE VIRTUAL TABLE IF NOT EXISTS fts_restricted USING fts5(title, summary, body, ${TOKENIZE});++ `);++ const st = {++ delPublic: db.prepare('DELETE FROM fts_public WHERE rowid = ?'),++ delRestricted: db.prepare('DELETE FROM fts_restricted WHERE rowid = ?'),++ insPublic: db.prepare('INSERT INTO fts_public (rowid, title, summary, body) VALUES (?, ?, ?, ?)'),++ insRestricted: db.prepare('INSERT INTO fts_restricted (rowid, title, summary, body) VALUES (?, ?, ?, ?)'),++ };++++ /** (Re)index one document according to its exposure. Call inside the store's transaction. */++ function put(rid, doc, exposure) {++ remove(rid);++ if (exposure === EXPOSURE.public_listed) st.insPublic.run(rid, doc.title || '', doc.summary || '', doc.body || '');++ else if (exposure === EXPOSURE.restricted) st.insRestricted.run(rid, doc.title || '', doc.summary || '', doc.body || '');++ }++++ function remove(rid) {++ st.delPublic.run(rid);++ st.delRestricted.run(rid);++ }++++ // ── SQL building ─────────────────────────────────────────++++ /**++ * The ACL predicate for restricted rows (alias d). private: the viewer's subject is in++ * acl.subjects. members/unlisted: subject, group or entitlement match. Returns null when the++ * viewer has no subject (then the restricted branch is not queried at all).++ */++ function aclClause(viewer, params) {++ if (!viewer || !viewer.subject) return null;++ params.v_subject = viewer.subject;++ params.v_groups = JSON.stringify(viewer.groups || []);++ params.v_ents = JSON.stringify(viewer.entitlements || []);++ return `(++ (d.visibility = 'private' AND EXISTS (SELECT 1 FROM doc_acl a WHERE a.rid = d.rid AND a.kind = 's' AND a.value = @v_subject))++ OR (d.visibility IN ('members', 'unlisted') AND EXISTS (SELECT 1 FROM doc_acl a WHERE a.rid = d.rid AND (++ (a.kind = 's' AND a.value = @v_subject)++ OR (a.kind = 'g' AND a.value IN (SELECT value FROM json_each(@v_groups)))++ OR (a.kind = 'e' AND a.value IN (SELECT value FROM json_each(@v_ents))))))++ )`;++ }++++ function filterClause(filters, params) {++ const parts = [];++ if (filters.owner) { parts.push('d.owner = @f_owner'); params.f_owner = filters.owner; }++ if (filters.type) { parts.push('d.type = @f_type'); params.f_type = filters.type; }++ if (filters.language) { parts.push('d.language = @f_lang'); params.f_lang = filters.language; }++ (filters.facets || []).forEach(([key, values], i) => {++ params[`f_fk${i}`] = key;++ params[`f_fv${i}`] = JSON.stringify(values);++ parts.push(`EXISTS (SELECT 1 FROM doc_facets x WHERE x.rid = d.rid AND x.key = @f_fk${i} AND x.value IN (SELECT value FROM json_each(@f_fv${i})))`);++ });++ return parts.length ? ` AND ${parts.join(' AND ')}` : '';++ }++++ /** The visible, matching set as (rid, rank, snip) rows. match = FTS5 expression. */++ function matchedSet({ match, filters, viewer, snippets = true }, params) {++ params.match = match;++ const where = filterClause(filters, params);++ const snip = (t) => (snippets ? `snippet(${t}, -1, char(1), char(2), '…', 16)` : 'NULL');++ const branches = [`SELECT d.rid AS rid, bm25(fts_public, ${WEIGHTS}) AS rank, ${snip('fts_public')} AS snip++ FROM fts_public JOIN documents d ON d.rid = fts_public.rowid++ WHERE fts_public MATCH @match AND d.exposure = ${EXPOSURE.public_listed} AND d.deleted = 0${where}`];++ const acl = aclClause(viewer, params);++ if (acl) {++ branches.push(`SELECT d.rid AS rid, bm25(fts_restricted, ${WEIGHTS}) AS rank, ${snip('fts_restricted')} AS snip++ FROM fts_restricted JOIN documents d ON d.rid = fts_restricted.rowid++ WHERE fts_restricted MATCH @match AND d.exposure = ${EXPOSURE.restricted} AND d.deleted = 0${where} AND ${acl}`);++ }++ return branches.join('\nUNION ALL\n');++ }++++ /** The visible set without a text query (browse). */++ function visibleSet({ filters, viewer }, params) {++ const where = filterClause(filters, params);++ const acl = aclClause(viewer, params);++ const vis = acl++ ? `(d.exposure = ${EXPOSURE.public_listed} OR (d.exposure = ${EXPOSURE.restricted} AND ${acl}))`++ : `d.exposure = ${EXPOSURE.public_listed}`;++ return `SELECT d.rid AS rid, d.sort_at AS sort_at FROM documents d WHERE d.deleted = 0 AND ${vis}${where}`;++ }++++ // ── Queries ──────────────────────────────────────────────++++ /** Ranked full-text search. after = { rank, rid } keyset cursor. */++ function search({ match, filters = {}, viewer, limit, after }) {++ const params = { limit: limit + 1 };++ let sql = `SELECT rid, rank, snip FROM (${matchedSet({ match, filters, viewer }, params)})`;++ if (after) {++ sql += ' WHERE (rank > @c_rank OR (rank = @c_rank AND rid > @c_rid))';++ params.c_rank = after.rank;++ params.c_rid = after.rid;++ }++ sql += ' ORDER BY rank, rid LIMIT @limit';++ const rows = db.prepare(sql).all(params);++ const more = rows.length > limit;++ const hits = rows.slice(0, limit);++ const last = hits[hits.length - 1];++ return { hits, next: more && last ? { rank: last.rank, rid: last.rid } : null };++ }++++ /** Newest first, no text query. after = { sort_at, rid }. */++ function browse({ filters = {}, viewer, limit, after }) {++ const params = { limit: limit + 1 };++ let sql = `SELECT rid, sort_at FROM (${visibleSet({ filters, viewer }, params)})`;++ if (after) {++ sql += ' WHERE (sort_at < @c_sort OR (sort_at = @c_sort AND rid < @c_rid))';++ params.c_sort = after.sort_at;++ params.c_rid = after.rid;++ }++ sql += ' ORDER BY sort_at DESC, rid DESC LIMIT @limit';++ const rows = db.prepare(sql).all(params);++ const more = rows.length > limit;++ const hits = rows.slice(0, limit).map(r => ({ rid: r.rid, rank: null, snip: null, sort_at: r.sort_at }));++ const last = hits[hits.length - 1];++ return { hits, next: more && last ? { sort_at: last.sort_at, rid: last.rid } : null };++ }++++ /**++ * Value counts for facet keys over exactly the visible matching set (same SQL as the hits),++ * so a count can never include a document the viewer cannot see.++ */++ function facetCounts({ match, filters = {}, viewer, keys, perKey = 20 }) {++ const out = {};++ for (const key of keys) {++ const params = { fkey: key, per: perKey };++ const set = match ? matchedSet({ match, filters, viewer, snippets: false }, params) : visibleSet({ filters, viewer }, params);++ const rows = db.prepare(`SELECT f.value AS value, COUNT(*) AS count FROM doc_facets f++ WHERE f.key = @fkey AND f.rid IN (SELECT rid FROM (${set}))++ GROUP BY f.value ORDER BY count DESC, value LIMIT @per`).all(params);++ out[key] = rows;++ }++ return out;++ }++++ /** Title prefix suggestions over the visible set. */++ function suggest({ match, filters = {}, viewer, limit }) {++ const params = { limit };++ const sql = `SELECT rid, rank FROM (${matchedSet({ match, filters, viewer, snippets: false }, params)}) ORDER BY rank, rid LIMIT @limit`;++ return db.prepare(sql).all(params);++ }++++ function counts() {++ return {++ public_listed: db.prepare('SELECT COUNT(*) AS n FROM fts_public').get().n,++ restricted: db.prepare('SELECT COUNT(*) AS n FROM fts_restricted').get().n,++ };++ }++++ return { name: 'sqlite-fts5', put, remove, search, browse, facetCounts, suggest, counts };++}++++// ── Query text → FTS5 expression ─────────────────────────────++++/**++ * Turns user text into a safe FTS5 expression: word tokens only, each quoted (no operators,++ * column filters or syntax errors reach FTS5), all required. A trailing `*` on the last word++ * makes it a prefix. Returns null when nothing searchable is left.++ */++function toMatch(q, { maxTerms = 16, column = null, prefixLast = false } = {}) {++ const text = String(q || '').normalize('NFKC');++ const words = (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) || []).slice(0, maxTerms);++ if (!words.length) return null;++ const wantsPrefix = prefixLast || /[\p{L}\p{N}_]\*\s*$/u.test(text);++ const parts = words.map((w, i) => `"${w}"${wantsPrefix && i === words.length - 1 ? '*' : ''}`);++ const expr = parts.join(' ');++ return column ? `{${column}} : (${expr})` : expr;++}++++/** Plain text with the engine's snippet marks → HTML with <mark>, everything else escaped. */++function snippetHtml(snip) {++ if (!snip) return null;++ const esc = String(snip).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ return esc.split(MARK_OPEN).join('<mark>').split(MARK_CLOSE).join('</mark>');++}++++module.exports = { createEngine, toMatch, snippetHtml };nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++/**++ * Transactional outbox (events.event-envelope@1), OpenVibe.Events client semantics:++ *++ * enqueue(envelope) INSIDE the transaction that makes the change, so an event exists exactly++ * when its effect does (throws outside a transaction)++ * relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service++ * token for audience openvibe.events with events.event.publish), marks them++ * sent, backs off on failure; a permanently refused row (4xx other than++ * 401/408/429) is marked rejected so it cannot block the rest++ *++ * The relay runs only when EVENTS_URL is set; without it rows wait and are relayed later. Events++ * deduplicates on event_id, so a retry after a lost response never publishes twice.++ */++const { ids, validate, serviceAuth } = require('openvibe-contracts');++++const BACKOFF_MS = [1000, 5000, 30000, 120000, 600000];++++function ensureSchema(db) {++ db.exec(`CREATE TABLE IF NOT EXISTS event_outbox (++ seq INTEGER PRIMARY KEY AUTOINCREMENT,++ event_id TEXT NOT NULL UNIQUE,++ event_type TEXT NOT NULL,++ envelope TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ attempts INTEGER NOT NULL DEFAULT 0,++ next_attempt_at INTEGER NOT NULL DEFAULT 0,++ sent_at INTEGER,++ rejected_at INTEGER,++ last_error TEXT++ );++ CREATE INDEX IF NOT EXISTS idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);`);++}++++function createOutbox(db, { source, now = () => Date.now() }) {++ const insert = db.prepare('INSERT INTO event_outbox (event_id, event_type, envelope, created_at) VALUES (?, ?, ?, ?)');++++ /** { event_type, subject, payload, visibility?, priority?, actor?, trace_id? } → envelope */++ function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actor, trace_id }) {++ if (!db.inTransaction) throw new Error('outbox.enqueue() must run inside the transaction that makes the change');++ const ms = now();++ const env = {++ event_id: ids.newId('event', ms),++ event_type,++ version: 1,++ source,++ actor: actor || { type: 'service', id: source },++ timestamp: new Date(ms).toISOString(),++ priority,++ visibility,++ subject,++ payload: payload || {},++ };++ if (trace_id && /^[0-9a-f]{32}$/.test(trace_id)) env.trace_id = trace_id;++ const v = validate('events.event-envelope@1', env);++ if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ insert.run(env.event_id, event_type, JSON.stringify(env), ms);++ return env;++ }++++ const q = {++ due: db.prepare('SELECT seq, event_id, envelope, attempts FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY seq LIMIT ?'),++ sent: db.prepare('UPDATE event_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?'),++ failed: db.prepare('UPDATE event_outbox SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE seq = ?'),++ rejected: db.prepare('UPDATE event_outbox SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE seq = ?'),++ pending: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL'),++ rejectedCount: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE rejected_at IS NOT NULL'),++ list: db.prepare('SELECT envelope FROM event_outbox ORDER BY seq'),++ prune: db.prepare('DELETE FROM event_outbox WHERE sent_at IS NOT NULL AND sent_at < ?'),++ };++++ return {++ enqueue,++ pending: () => q.pending.get().n,++ rejected: () => q.rejectedCount.get().n,++ /** Every envelope in order (tests and operators). */++ all: () => q.list.all().map(r => JSON.parse(r.envelope)),++ prune: (olderThanMs = 7 * 24 * 3600 * 1000) => q.prune.run(now() - olderThanMs).changes,++ _q: q,++ };++}++++/**++ * createRelay({ db, outbox, eventsUrl, intervalMs?, tokenClient? | tokenOpts?, fetchImpl?, log?, now? })++ * → { start, stop, flush, running }++ * tokenOpts = { tokenUrl, clientId, clientSecret } builds a client-credentials token client for++ * audience openvibe.events. Without eventsUrl or a token source the relay does nothing.++ */++function createRelay({ outbox, eventsUrl, intervalMs = 2000, tokenClient, tokenOpts, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), db }) {++ const q = outbox._q;++ const tokens = tokenClient || (tokenOpts && serviceAuth.createTokenClient({ ...tokenOpts, audience: 'openvibe.events', scope: 'events.event.publish', fetchImpl }));++ let timer = null;++ let flushing = null;++++ function permanent(status) {++ return status >= 400 && status < 500 && ![401, 408, 429].includes(status);++ }++++ async function post(rows) {++ const body = rows.length === 1 ? JSON.parse(rows[0].envelope) : { events: rows.map(r => JSON.parse(r.envelope)) };++ const res = await fetchImpl(`${eventsUrl}/api/v1/events`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 401 && typeof tokens.invalidate === 'function') tokens.invalidate();++ const text = await res.text().catch(() => '');++ return { ok: res.ok, status: res.status, text };++ }++++ function mark(rows, fn) { db.transaction(() => { for (const r of rows) fn(r); })(); }++++ async function publishRows(rows) {++ let r;++ try {++ r = await post(rows);++ } catch (err) {++ mark(rows, row => q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err.message).slice(0, 500), row.seq));++ return { sent: 0, failed: rows.length, rejected: 0 };++ }++ if (r.ok) {++ mark(rows, row => q.sent.run(now(), row.seq));++ return { sent: rows.length, failed: 0, rejected: 0 };++ }++ if (permanent(r.status) && rows.length > 1) {++ // One bad envelope refuses the batch: isolate it.++ const total = { sent: 0, failed: 0, rejected: 0 };++ for (const row of rows) {++ const s = await publishRows([row]);++ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;++ }++ return total;++ }++ const msg = `${r.status} ${r.text.slice(0, 300)}`;++ if (permanent(r.status)) {++ mark(rows, row => q.rejected.run(now(), msg, row.seq));++ log.warn(`[outbox] event refused by Events: ${msg}`);++ return { sent: 0, failed: 0, rejected: rows.length };++ }++ mark(rows, row => q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], msg, row.seq));++ return { sent: 0, failed: rows.length, rejected: 0 };++ }++++ async function doFlush() {++ const total = { sent: 0, failed: 0, rejected: 0 };++ if (!eventsUrl || !tokens) return total;++ for (;;) {++ const rows = q.due.all(now(), 50);++ if (!rows.length) break;++ const s = await publishRows(rows);++ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;++ if (s.failed || rows.length < 50) break;++ }++ if (total.failed) log.warn(`[outbox] ${total.failed} event(s) not published yet; will retry`);++ return total;++ }++++ function flush() {++ if (!flushing) flushing = doFlush().finally(() => { flushing = null; });++ return flushing;++ }++++ function start() {++ if (timer || !eventsUrl) return;++ timer = setInterval(() => { flush().catch(err => log.warn(`[outbox] relay: ${err.message}`)); }, intervalMs);++ timer.unref?.();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ return flushing || Promise.resolve();++ }++++ return { start, stop, flush, running: () => Boolean(timer) };++}++++module.exports = { ensureSchema, createOutbox, createRelay };nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * OpenVibe.Search entry point.++ *++ * node server/index.js (systemd: openvibe-search.service)++ *++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable++ * clock/fetch/log, and returns handles to every part.++ */++const { load } = require('./config');++const { openDb } = require('./db');++const { createEngine } = require('./engine/fts5');++const { createStore } = require('./store');++const { createOutbox, createRelay } = require('./events/outbox');++const { createKeyStore, createAuth } = require('./auth');++const { createApp } = require('./app');++++async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, log = console, listen = true } = {}) {++ config = config || load();++ const db = openDb(config.dbPath);++ const engine = createEngine(db);++ const outbox = createOutbox(db, { source: config.serviceId, now });++ const store = createStore({ db, engine, outbox, now });++ const relay = createRelay({++ db, outbox, eventsUrl: config.events.url, intervalMs: config.events.relayIntervalMs, fetchImpl, log, now,++ tokenClient,++ tokenOpts: config.oauth.clientSecret ? { tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret } : null,++ });++ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });++ const auth = createAuth({ config, keys });++ const app = createApp({ config, db, store, engine, auth, keys, outbox, relay, log, now });++++ const keyLoaded = keys.start().catch(() => null);++ relay.start();++ const pruneTimer = setInterval(() => { try { outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);++ pruneTimer.unref?.();++++ let server = null;++ if (listen) {++ server = await new Promise((resolve, reject) => {++ const s = app.listen(config.port, config.host, () => resolve(s));++ s.on('error', reject);++ });++ log.log(`[search] listening on http://${config.host}:${server.address().port} (engine ${engine.name})`);++ }++++ async function close() {++ clearInterval(pruneTimer);++ keys.stop();++ await relay.stop();++ if (server) {++ server.closeAllConnections?.();++ await new Promise(resolve => server.close(() => resolve()));++ }++ db.close();++ }++++ return { config, db, engine, store, outbox, relay, keys, keyLoaded, auth, app, server, close };++}++++if (require.main === module) {++ require('dotenv').config();++ start().then((handles) => {++ const shutdown = (sig) => {++ console.log(`[search] ${sig}: shutting down`);++ handles.close().then(() => process.exit(0), () => process.exit(1));++ setTimeout(() => process.exit(1), 10000).unref();++ };++ process.on('SIGTERM', () => shutdown('SIGTERM'));++ process.on('SIGINT', () => shutdown('SIGINT'));++ }).catch((err) => {++ console.error(`[search] failed to start: ${err.stack || err}`);++ process.exit(1);++ });++}++++module.exports = { start };nnew file mode 100644@@@ -0,0 +1,205 @@++'use strict';++/**++ * The document store: revision ordering, tombstones, ACL and facet rows, and the removal /++ * indexed events, all in one transaction with the engine update.++ *++ * Ordering rule (both ingestion paths):++ * - an incoming revision lower than the stored one is stale and changes nothing;++ * - at the same revision a deletion wins: a tombstone replaces a document, an upsert never++ * replaces a tombstone; the same content again is `unchanged`; different content with the++ * same revision is a `conflict` (first write wins);++ * - a higher revision replaces whatever is stored, including a tombstone (restore).++ * A tombstone keeps only the identity and revision, so a late, older upsert is still refused.++ */++const { normalize, effectiveIndexability, exposureOf, facetPairs, EXPOSURE } = require('./document');++++const EXPOSURE_NAME = Object.fromEntries(Object.entries(EXPOSURE).map(([k, v]) => [v, k]));++++function createStore({ db, engine, outbox, now = () => Date.now() }) {++ const st = {++ get: db.prepare('SELECT * FROM documents WHERE owner = ? AND type = ? AND id = ?'),++ byRid: db.prepare('SELECT * FROM documents WHERE rid = ?'),++ insert: db.prepare(`INSERT INTO documents (owner, type, id, revision, deleted, visibility, publication_state, exposure,++ index_decision, noindex_reasons, canonical_url, title, language, published_at, updated_at, sort_at, doc, hash, via, event_id, indexed_at)++ VALUES (@owner, @type, @id, @revision, @deleted, @visibility, @publication_state, @exposure,++ @index_decision, @noindex_reasons, @canonical_url, @title, @language, @published_at, @updated_at, @sort_at, @doc, @hash, @via, @event_id, @indexed_at)`),++ update: db.prepare(`UPDATE documents SET revision = @revision, deleted = @deleted, visibility = @visibility,++ publication_state = @publication_state, exposure = @exposure, index_decision = @index_decision,++ noindex_reasons = @noindex_reasons, canonical_url = @canonical_url, title = @title, language = @language,++ published_at = @published_at, updated_at = @updated_at, sort_at = @sort_at, doc = @doc, hash = @hash,++ via = @via, event_id = @event_id, indexed_at = @indexed_at WHERE rid = @rid`),++ delAcl: db.prepare('DELETE FROM doc_acl WHERE rid = ?'),++ insAcl: db.prepare('INSERT OR IGNORE INTO doc_acl (rid, kind, value) VALUES (?, ?, ?)'),++ delFacets: db.prepare('DELETE FROM doc_facets WHERE rid = ?'),++ insFacet: db.prepare('INSERT OR IGNORE INTO doc_facets (rid, key, value) VALUES (?, ?, ?)'),++ ownerPage: db.prepare(`SELECT rid, type, id, revision, deleted, hash, exposure, indexed_at FROM documents++ WHERE owner = @owner AND (@type IS NULL OR type = @type) AND rid > @after ORDER BY rid LIMIT @limit`),++ counts: db.prepare(`SELECT COUNT(*) AS total, SUM(deleted) AS tombstones,++ SUM(CASE WHEN exposure = 3 THEN 1 ELSE 0 END) AS public_listed,++ SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,++ SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),++ };++++ function rowValues(doc, hash, { via, eventId }) {++ const idx = effectiveIndexability(doc);++ return {++ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,++ deleted: doc.deleted ? 1 : 0,++ visibility: doc.deleted ? null : doc.visibility,++ publication_state: doc.deleted ? null : doc.publication_state,++ exposure: exposureOf(doc),++ index_decision: idx.decision,++ noindex_reasons: JSON.stringify(idx.reasons),++ canonical_url: doc.deleted ? null : doc.canonical_url,++ title: doc.deleted ? null : doc.title,++ language: doc.deleted ? null : doc.language,++ published_at: doc.deleted ? null : doc.published_at,++ updated_at: doc.deleted ? null : doc.updated_at,++ sort_at: doc.deleted ? '' : (doc.updated_at || doc.published_at || ''),++ doc: JSON.stringify(doc),++ hash,++ via,++ event_id: eventId || null,++ indexed_at: now(),++ };++ }++++ function writeSideRows(rid, doc, exposure) {++ st.delAcl.run(rid);++ st.delFacets.run(rid);++ if (exposure === EXPOSURE.none) return;++ if (exposure === EXPOSURE.restricted) {++ for (const s of doc.acl.subjects) st.insAcl.run(rid, 's', s);++ for (const g of doc.acl.groups) st.insAcl.run(rid, 'g', g);++ for (const e of doc.acl.entitlements) st.insAcl.run(rid, 'e', e);++ }++ for (const [k, v] of facetPairs(doc.facets)) st.insFacet.run(rid, k, v);++ }++++ function removalReason(prev, doc) {++ if (doc.deleted) return 'deleted';++ if (doc.visibility === 'draft') return 'draft';++ if (doc.publication_state !== 'published') return 'not_published';++ if (prev && prev.visibility !== doc.visibility) return 'visibility_changed';++ return 'noindex';++ }++++ function announce(prevRow, prevDoc, doc, exposure, traceId) {++ const prevExposure = prevRow ? prevRow.exposure : EXPOSURE.none;++ const subject = { type: 'document', id: `${doc.owner}/${doc.type}/${doc.id}`, revision: doc.revision };++ const wasPublic = prevExposure >= EXPOSURE.public_unlisted;++ if (exposure < prevExposure) {++ outbox.enqueue({++ event_type: 'search.document.removed',++ subject,++ trace_id: traceId,++ payload: {++ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,++ reason: removalReason(prevDoc, doc),++ previous_exposure: EXPOSURE_NAME[prevExposure],++ exposure: EXPOSURE_NAME[exposure],++ // Only a URL that was public already: caches and sitemaps purge it.++ canonical_url: wasPublic ? prevRow.canonical_url : null,++ },++ });++ }++ if (!doc.deleted && exposure > EXPOSURE.none) {++ outbox.enqueue({++ event_type: 'search.document.indexed',++ subject,++ trace_id: traceId,++ payload: {++ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,++ exposure: EXPOSURE_NAME[exposure],++ reindexed: Boolean(prevRow),++ canonical_url: exposure >= EXPOSURE.public_unlisted ? doc.canonical_url : null,++ },++ });++ }++ }++++ /**++ * Apply a valid index document (or tombstone). Returns++ * { outcome: 'applied'|'unchanged'|'stale'|'conflict', revision, stored_revision, deleted }++ * Must be called inside a transaction when combined with other writes (inbox); it opens its++ * own otherwise.++ */++ function apply(input, { via = 'api', eventId = null, traceId = null } = {}) {++ const run = () => {++ const { doc, hash } = normalize(input);++ const cur = st.get.get(doc.owner, doc.type, doc.id);++ if (cur) {++ const base = { revision: doc.revision, stored_revision: cur.revision, deleted: Boolean(cur.deleted) };++ if (doc.revision < cur.revision) return { outcome: 'stale', ...base };++ if (doc.revision === cur.revision) {++ if (cur.deleted && !doc.deleted) return { outcome: 'stale', ...base };++ if (cur.hash === hash) return { outcome: 'unchanged', ...base };++ if (!(doc.deleted && !cur.deleted)) return { outcome: 'conflict', ...base };++ }++ }++ const values = rowValues(doc, hash, { via, eventId });++ let rid;++ if (cur) {++ rid = cur.rid;++ st.update.run({ ...values, rid });++ } else {++ rid = Number(st.insert.run(values).lastInsertRowid);++ }++ writeSideRows(rid, doc, values.exposure);++ engine.put(rid, doc, values.exposure);++ const prevDoc = cur ? JSON.parse(cur.doc) : null;++ announce(cur, prevDoc, doc, values.exposure, traceId);++ return { outcome: 'applied', revision: doc.revision, stored_revision: cur ? cur.revision : null, deleted: doc.deleted };++ };++ return db.inTransaction ? run() : db.transaction(run)();++ }++++ /**++ * Tombstone a document. revision omitted = the stored revision (a deletion wins the tie), or++ * 0 for a document Search has never seen (so a late older upsert is still refused).++ */++ function remove(owner, type, id, { revision = null, via = 'api', eventId = null, traceId = null } = {}) {++ const run = () => {++ const cur = st.get.get(owner, type, id);++ const rev = revision != null ? revision : (cur ? cur.revision : 0);++ return apply({ owner, type, id, revision: rev, deleted: true }, { via, eventId, traceId });++ };++ return db.inTransaction ? run() : db.transaction(run)();++ }++++ function get(owner, type, id) {++ const row = st.get.get(owner, type, id);++ return row ? { row, doc: JSON.parse(row.doc) } : null;++ }++++ function byRid(rid) {++ const row = st.byRid.get(rid);++ return row ? { row, doc: JSON.parse(row.doc) } : null;++ }++++ /** Reconciliation page for an owner: (type, id, revision, deleted, hash) by insertion order. */++ function ownerPage(owner, { type = null, after = 0, limit = 500 } = {}) {++ const rows = st.ownerPage.all({ owner, type, after, limit: limit + 1 });++ const more = rows.length > limit;++ const page = rows.slice(0, limit);++ return {++ documents: page.map(r => ({++ type: r.type, id: r.id, revision: r.revision, deleted: Boolean(r.deleted),++ exposure: EXPOSURE_NAME[r.exposure], hash: r.hash, indexed_at: new Date(r.indexed_at).toISOString(),++ })),++ next_after: more && page.length ? page[page.length - 1].rid : null,++ };++ }++++ function counts() {++ const c = st.counts.get();++ return {++ total: c.total || 0, tombstones: c.tombstones || 0, public_listed: c.public_listed || 0,++ public_unlisted: c.public_unlisted || 0, restricted: c.restricted || 0,++ };++ }++++ return { apply, remove, get, byRid, ownerPage, counts, EXPOSURE_NAME };++}++++module.exports = { createStore, EXPOSURE_NAME };nnew file mode 100644@@@ -0,0 +1,244 @@++'use strict';++/**++ * Adversarial visibility tests. Every non-public document carries the same rare word, so any++ * leak — a hit, a snippet, a facet count, a suggestion, a direct get, a cursor — shows up.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, request, serviceToken, userToken, doc, suite } = require('./helpers');++++const t = suite('acl-leak');++let svc;++const WIKI = serviceToken('wiki', ['search.document.write']);++const DELEGATE = serviceToken('blog', ['search.query.delegate']);++++const alice = ids.newId('user'); // named in ACLs++const mallory = ids.newId('user'); // named nowhere++const guest = ids.newId('guest');++++const put = (d) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token: WIKI, body: d });++const get = (p, opts = {}) => request(svc.base, 'GET', p, opts);++const q = (query, opts) => get(`/api/v1/search?${query}`, opts);++const asUser = (subjectId, role) => ({ token: userToken({ subjectId, role }) });++const asDelegate = (headers = {}) => ({ token: DELEGATE, headers });++++// the corpus++const SECRET = 'quuxsecret';++const D = {++ public: doc({ id: 'pub', title: `Public ${SECRET} page`, facets: { category: 'open', tags: ['shared'] } }),++ publicNoindex: doc({ id: 'pubnoidx', title: `Public noindex ${SECRET}`, indexability: { decision: 'noindex', reasons: ['thin_content'] }, facets: { category: 'thin' } }),++ unlisted: doc({ id: 'unl', visibility: 'unlisted', acl: { subjects: [alice] }, title: `Unlisted ${SECRET}`, facets: { category: 'hiddencat-unlisted' } }),++ unlistedNoAcl: doc({ id: 'unl2', visibility: 'unlisted', title: `Unlisted no acl ${SECRET}`, facets: { category: 'hiddencat-unlisted2' } }),++ members: doc({ id: 'mem', visibility: 'members', acl: { groups: ['wiki.space:spc_1:member'], entitlements: ['vip.plan:gold'] }, title: `Members ${SECRET}`, facets: { category: 'hiddencat-members' } }),++ private: doc({ id: 'prv', visibility: 'private', acl: { subjects: [alice], groups: ['role:admin'] }, title: `Private ${SECRET} diary`, body: `private body ${SECRET} with lots of words`, facets: { category: 'hiddencat-private', tags: ['shared'] } }),++ draft: doc({ id: 'drf', visibility: 'draft', acl: { subjects: [alice] }, title: `Draft ${SECRET}`, facets: { category: 'hiddencat-draft' } }),++ unpublished: doc({ id: 'unp', visibility: 'private', acl: { subjects: [alice] }, publication_state: 'draft', title: `Unpublished ${SECRET}`, facets: { category: 'hiddencat-unpub' } }),++ retracted: doc({ id: 'ret', publication_state: 'retracted', title: `Retracted ${SECRET}`, facets: { category: 'hiddencat-retracted' } }),++ deleted: doc({ id: 'del', visibility: 'private', acl: { subjects: [alice] }, title: `Deleted ${SECRET}`, facets: { category: 'hiddencat-deleted' } }),++};++++function idsOf(r) {++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ return r.body.results.map(x => x.id).sort();++}++++function assertNoLeak(r, allowed) {++ const text = JSON.stringify(r.body);++ for (const [name, d] of Object.entries(D)) {++ if (allowed.includes(d.id)) continue;++ assert.ok(!text.includes(`"${d.id}"`), `${name} leaked by id`);++ assert.ok(!text.includes(d.title), `${name} leaked by title`);++ if (d.facets && d.facets.category && d.facets.category.startsWith('hiddencat')) {++ assert.ok(!text.includes(d.facets.category), `${name} leaked through a facet value`);++ }++ }++ assert.ok(!text.includes(alice), 'an ACL subject leaked');++ assert.ok(!text.includes('vip.plan:gold') && !text.includes('role:admin'), 'an ACL key leaked');++}++++t('boot and index the corpus', async () => {++ svc = await boot();++ for (const d of Object.values(D)) assert.strictEqual((await put(d)).status, 200, d.id);++ const r = await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${D.deleted.id}?revision=2`, { token: WIKI });++ assert.strictEqual(r.body.outcome, 'applied');++});++++t('anonymous sees public listed documents only — hits, snippets, facets, suggestions', async () => {++ const r = await q(`q=${SECRET}&facets=category,tags`);++ assert.deepStrictEqual(idsOf(r), ['pub']);++ assertNoLeak(r, ['pub']);++ assert.deepStrictEqual(r.body.facets.category, [{ value: 'open', count: 1 }]);++ assert.deepStrictEqual(r.body.facets.tags, [{ value: 'shared', count: 1 }], 'the private document also has tag shared: it must not be counted');++ const s = await get(`/api/v1/suggest?q=${SECRET.slice(0, 5)}`);++ assert.strictEqual(s.status, 200);++ assert.deepStrictEqual(s.body.suggestions.map(x => x.id), ['pub'], 'only the public title completes');++ assertNoLeak(s, ['pub']);++ const s2 = await get('/api/v1/suggest?q=pub');++ assert.deepStrictEqual(s2.body.suggestions.map(x => x.id), ['pub']);++ const browse = await q('facets=category');++ assert.deepStrictEqual(idsOf(browse), ['pub']);++ assertNoLeak(browse, ['pub']);++});++++t('filtering on a hidden facet value or owner finds nothing and counts nothing', async () => {++ for (const cat of ['hiddencat-private', 'hiddencat-members', 'hiddencat-draft', 'hiddencat-deleted', 'thin']) {++ const r = await q(`facet.category=${cat}&facets=category`);++ assert.deepStrictEqual(idsOf(r), [], cat);++ assert.deepStrictEqual(r.body.facets.category, [], cat);++ }++ const r = await q(`q=diary&owner=wiki&type=page`);++ assert.deepStrictEqual(idsOf(r), []);++});++++t('a signed-in stranger sees exactly what anonymous sees in results', async () => {++ const r = await q(`q=${SECRET}&facets=category`, asUser(mallory));++ assert.deepStrictEqual(idsOf(r), ['pub']);++ assertNoLeak(r, ['pub']);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++});++++t('the ACL subject sees its private and unlisted documents, never drafts, unpublished or deleted', async () => {++ const r = await q(`q=${SECRET}&facets=category`, asUser(alice));++ assert.deepStrictEqual(idsOf(r), ['prv', 'pub', 'unl']);++ const text = JSON.stringify(r.body);++ for (const hidden of ['drf', 'unp', 'ret', 'del', 'mem', 'unl2', 'pubnoidx']) assert.ok(!text.includes(`"${hidden}"`), hidden);++ assert.ok(!text.includes('"acl"'), 'the ACL itself is never returned');++ const cats = r.body.facets.category.map(c => c.value).sort();++ assert.deepStrictEqual(cats, ['hiddencat-private', 'hiddencat-unlisted', 'open']);++ const s = await get('/api/v1/suggest?q=priv', asUser(alice));++ assert.deepStrictEqual(s.body.suggestions.map(x => x.id), ['prv']);++});++++t('a role group from the Network token matches role:<role> ACL keys (private needs the subject)', async () => {++ const r = await q(`q=${SECRET}`, asUser(mallory, 'admin'));++ assert.deepStrictEqual(idsOf(r), ['pub'], 'private is subject-only; an admin role is not a subject');++});++++t('members documents need a vouched group or entitlement', async () => {++ const none = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory }));++ assert.deepStrictEqual(idsOf(none), ['pub']);++ const grp = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory, 'X-OV-Groups': 'wiki.space:spc_1:member' }));++ assert.deepStrictEqual(idsOf(grp), ['mem', 'pub']);++ const ent = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': guest, 'X-OV-Entitlements': 'vip.plan:gold' }));++ assert.deepStrictEqual(idsOf(ent), ['mem', 'pub']);++ const wrong = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory, 'X-OV-Groups': 'wiki.space:spc_2:member', 'X-OV-Entitlements': 'vip.plan:silver' }));++ assert.deepStrictEqual(idsOf(wrong), ['pub']);++ const forAlice = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': alice }));++ assert.deepStrictEqual(idsOf(forAlice), ['prv', 'pub', 'unl']);++});++++t('delegation headers are ignored for browsers and refused without the capability', async () => {++ const forged = await q(`q=${SECRET}`, { token: userToken({ subjectId: mallory }), headers: { 'X-OV-Subject': alice, 'X-OV-Groups': 'role:admin', 'X-OV-Entitlements': 'vip.plan:gold' } });++ assert.deepStrictEqual(idsOf(forged), ['pub']);++ const anonForged = await q(`q=${SECRET}`, { headers: { 'X-OV-Subject': alice } });++ assert.deepStrictEqual(idsOf(anonForged), ['pub']);++ const noCap = await q(`q=${SECRET}`, { token: serviceToken('blog', ['search.document.write']), headers: { 'X-OV-Subject': alice } });++ assert.strictEqual(noCap.status, 403);++ const groupsWithoutSubject = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Groups': 'wiki.space:spc_1:member' }));++ assert.strictEqual(groupsWithoutSubject.status, 400);++ const badSubject = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': '57' }));++ assert.strictEqual(badSubject.status, 400);++ const asItself = await q(`q=${SECRET}`, asDelegate());++ assert.deepStrictEqual(idsOf(asItself), ['pub']);++});++++t('a bad Bearer is refused, never downgraded; an unverifiable cookie is signed out', async () => {++ const forgedKey = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++ const bad = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, key: forgedKey.privateKey }) });++ assert.strictEqual(bad.status, 401);++ const badSvc = await q(`q=${SECRET}`, { token: serviceToken('blog', ['search.query.delegate'], { key: forgedKey.privateKey }), headers: { 'X-OV-Subject': alice } });++ assert.strictEqual(badSvc.status, 401);++ const expired = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, exp: Math.floor(Date.now() / 1000) - 3600 }) });++ assert.strictEqual(expired.status, 401);++ const wrongIss = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, iss: 'https://evil.example' }) });++ assert.strictEqual(wrongIss.status, 401);++ const cookie = await q(`q=${SECRET}`, { headers: { Cookie: `ov_token=${userToken({ subjectId: alice, key: forgedKey.privateKey })}` } });++ assert.deepStrictEqual(idsOf(cookie), ['pub']);++ const goodCookie = await q(`q=${SECRET}`, { headers: { Cookie: `ov_token=${userToken({ subjectId: alice })}` } });++ assert.deepStrictEqual(idsOf(goodCookie), ['prv', 'pub', 'unl']);++});++++t('direct gets: same 404 for hidden and missing; unlisted by id needs a signed-in subject', async () => {++ const missing = await get('/api/v1/documents/wiki/page/nope');++ assert.strictEqual(missing.status, 404);++ for (const id of ['prv', 'mem', 'drf', 'unp', 'ret', 'del', 'unl', 'unl2']) {++ const r = await get(`/api/v1/documents/wiki/page/${id}`);++ assert.strictEqual(r.status, 404, `anonymous ${id}`);++ assert.deepStrictEqual(Object.keys(r.body).sort(), Object.keys(missing.body).sort());++ assert.strictEqual(r.body.code, missing.body.code);++ }++ for (const id of ['prv', 'mem', 'drf', 'unp', 'ret', 'del']) {++ assert.strictEqual((await get(`/api/v1/documents/wiki/page/${id}`, asUser(mallory))).status, 404, `stranger ${id}`);++ }++ assert.strictEqual((await get('/api/v1/documents/wiki/page/unl2', asUser(mallory))).status, 200, 'unlisted by exact id');++ assert.strictEqual((await get('/api/v1/documents/wiki/page/pubnoidx')).status, 200, 'public noindex is public by id');++ const prv = await get('/api/v1/documents/wiki/page/prv', asUser(alice));++ assert.strictEqual(prv.status, 200);++ assert.strictEqual(prv.body.document.acl, undefined);++ for (const id of ['drf', 'unp', 'del']) {++ assert.strictEqual((await get(`/api/v1/documents/wiki/page/${id}`, asUser(alice))).status, 404, `ACL subject cannot see ${id}`);++ }++});++++t('FTS syntax in q cannot widen the query', async () => {++ for (const bad of [`${SECRET} OR private`, `title:${SECRET}`, '*', `"${SECRET}" NOT pub`, `{title body}: ${SECRET}`, `${SECRET}) OR (diary`, 'NEAR(quuxsecret diary)', `${SECRET}*`]) {++ const r = await q(`q=${encodeURIComponent(bad)}&facets=category`);++ assert.strictEqual(r.status, 200, bad);++ assertNoLeak(r, ['pub']);++ }++});++++t('cursors are bound to their query and never widen it', async () => {++ for (let i = 0; i < 5; i++) await put(doc({ id: `page_${i}`, title: `Paging thetaword ${i}` }));++ const p1 = await q('q=thetaword&limit=2');++ assert.strictEqual(p1.body.results.length, 2);++ const p2 = await q(`q=thetaword&limit=2&cursor=${p1.body.next_cursor}`);++ const p3 = await q(`q=thetaword&limit=2&cursor=${p2.body.next_cursor}`);++ const seen = [...p1.body.results, ...p2.body.results, ...p3.body.results].map(x => x.id);++ assert.strictEqual(new Set(seen).size, 5);++ assert.strictEqual(p3.body.next_cursor, null);++ const reused = await q(`q=${SECRET}&cursor=${p1.body.next_cursor}`);++ assert.strictEqual(reused.status, 400);++ assert.strictEqual(reused.body.code, 'search.bad_cursor');++ const forged = Buffer.from(JSON.stringify({ k: 'r', r: -1e9, i: 0, h: 'x' })).toString('base64url');++ assert.strictEqual((await q(`q=${SECRET}&cursor=${forged}`)).status, 400);++});++++t('a visibility change or deletion leaves results at once and announces search.document.removed', async () => {++ const d = doc({ id: 'flip', title: 'Flipping iotaword', canonical_url: 'https://openvibe.wiki/p/flip', revision: 1 });++ await put(d);++ assert.deepStrictEqual(idsOf(await q('q=iotaword')), ['flip']);++ await put({ ...d, revision: 2, visibility: 'private', acl: { subjects: [alice] } });++ assert.deepStrictEqual(idsOf(await q('q=iotaword')), []);++ assert.strictEqual((await get('/api/v1/documents/wiki/page/flip')).status, 404);++ assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), ['flip']);++ await request(svc.base, 'DELETE', '/api/v1/documents/wiki/page/flip?revision=3', { token: WIKI });++ assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), []);++++ const removed = svc.outbox.all().filter(e => e.event_type === 'search.document.removed' && e.payload.id === 'flip');++ assert.strictEqual(removed.length, 2);++ assert.deepStrictEqual(removed.map(e => [e.payload.reason, e.payload.previous_exposure, e.payload.exposure]), [++ ['visibility_changed', 'public_listed', 'restricted'],++ ['deleted', 'restricted', 'none'],++ ]);++ assert.strictEqual(removed[0].payload.canonical_url, 'https://openvibe.wiki/p/flip', 'the public URL to purge');++ assert.strictEqual(removed[1].payload.canonical_url, null, 'a URL that was never public is not broadcast');++ assert.ok(removed.every(e => e.visibility === 'internal' && e.source === 'search'));++});++++t('publishing → draft and public → noindex also announce removal', async () => {++ const d = doc({ id: 'unpub', title: 'Kappaword', revision: 1 });++ await put(d);++ await put({ ...d, revision: 2, publication_state: 'unpublished' });++ const d2 = doc({ id: 'noidx', title: 'Lambdaword', revision: 1 });++ await put(d2);++ await put({ ...d2, revision: 2, indexability: { decision: 'noindex', reasons: ['duplicate_without_canonical'] } });++ const reasons = svc.outbox.all().filter(e => e.event_type === 'search.document.removed' && ['unpub', 'noidx'].includes(e.payload.id)).map(e => `${e.payload.id}:${e.payload.reason}`);++ assert.deepStrictEqual(reasons, ['unpub:not_published', 'noidx:noindex']);++ assert.deepStrictEqual(idsOf(await q('q=kappaword')), []);++ assert.deepStrictEqual(idsOf(await q('q=lambdaword')), []);++});++++t('done', async () => { await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++/** Owner API: capability and ownership, validation, revision ordering, tombstones, reconciliation. */++const assert = require('assert');++const { boot, request, serviceToken, doc, suite } = require('./helpers');++++const t = suite('documents');++let svc;++const WIKI = serviceToken('wiki', ['search.document.write']);++const BLOG = serviceToken('blog', ['search.document.write']);++++const put = (d, token = WIKI) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token, body: d });++const del = (d, rev, token = WIKI) => request(svc.base, 'DELETE', `/api/v1/documents/${d.owner}/${d.type}/${d.id}${rev === undefined ? '' : `?revision=${rev}`}`, { token });++const search = (q) => request(svc.base, 'GET', `/api/v1/search?q=${encodeURIComponent(q)}`);++++t('boot', async () => { svc = await boot(); });++++t('writing needs a service token with search.document.write', async () => {++ const d = doc();++ assert.strictEqual((await put(d, null)).status, 401);++ const noCap = serviceToken('wiki', ['search.query.delegate']);++ const r = await put(d, noCap);++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.body.code, 'capability.denied');++ const wrongAud = serviceToken('wiki', ['search.document.write'], { aud: 'openvibe.events' });++ assert.strictEqual((await put(d, wrongAud)).status, 401);++ assert.strictEqual((await put(d)).status, 200);++});++++t('a service writes only its own documents', async () => {++ const d = doc();++ const r = await put(d, BLOG);++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.body.code, 'search.not_owner');++ const r2 = await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, owner: 'blog' } });++ assert.strictEqual(r2.status, 422);++});++++t('invalid documents are refused with the schema errors', async () => {++ const r = await put(doc({ visibility: 'everyone' }));++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.body.code, 'search.bad_document');++ assert.ok(r.body.errors.length);++ assert.strictEqual((await put(doc({ acl: { subjects: ['57'] } }))).status, 422, 'integer user ids are not subjects');++ assert.strictEqual((await put(doc({ canonical_url: 'javascript:alert(1)' }))).status, 422);++ const noTitle = doc();++ delete noTitle.title;++ assert.strictEqual((await put(noTitle)).status, 422);++});++++t('an older revision never overwrites a newer one', async () => {++ const d = doc({ title: 'Revision ordering alphaone', revision: 5 });++ assert.strictEqual((await put(d)).body.outcome, 'applied');++ const older = await put({ ...d, revision: 4, title: 'Revision ordering betatwo' });++ assert.strictEqual(older.status, 409);++ assert.strictEqual(older.body.code, 'search.stale_revision');++ assert.strictEqual(older.body.stored_revision, 5);++ assert.strictEqual((await search('alphaone')).body.results.length, 1);++ assert.strictEqual((await search('betatwo')).body.results.length, 0);++});++++t('the same revision again is unchanged; different content at that revision is a conflict', async () => {++ const d = doc({ revision: 3 });++ await put(d);++ const again = await put(d);++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.body.outcome, 'unchanged');++ const other = await put({ ...d, title: 'Something else' });++ assert.strictEqual(other.status, 409);++ assert.strictEqual(other.body.code, 'search.revision_conflict');++});++++t('a newer revision replaces the document and its search terms', async () => {++ const d = doc({ title: 'Gammaword original', revision: 1 });++ await put(d);++ assert.strictEqual((await put({ ...d, revision: 2, title: 'Deltaword replacement' })).body.outcome, 'applied');++ assert.strictEqual((await search('gammaword')).body.results.length, 0);++ const r = await search('deltaword');++ assert.strictEqual(r.body.results.length, 1);++ assert.strictEqual(r.body.results[0].revision, 2);++});++++t('a tombstone removes the document and wins over the same and older revisions', async () => {++ const d = doc({ title: 'Tombstoned epsilonword', revision: 7 });++ await put(d);++ assert.strictEqual((await search('epsilonword')).body.results.length, 1);++ const r = await del(d, 7);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.outcome, 'applied');++ assert.strictEqual((await search('epsilonword')).body.results.length, 0);++ // a late upsert with the same revision, or an older one, cannot resurrect it++ assert.strictEqual((await put(d)).status, 409);++ assert.strictEqual((await put({ ...d, revision: 6 })).status, 409);++ assert.strictEqual((await search('epsilonword')).body.results.length, 0);++ assert.strictEqual((await request(svc.base, 'GET', `/api/v1/documents/wiki/page/${d.id}`)).status, 404);++ // a newer revision is a deliberate restore++ assert.strictEqual((await put({ ...d, revision: 8 })).body.outcome, 'applied');++ assert.strictEqual((await search('epsilonword')).body.results.length, 1);++});++++t('a deletion at the stored revision wins the tie; an older deletion is stale', async () => {++ const d = doc({ title: 'Tie zetaword', revision: 3 });++ await put(d);++ assert.strictEqual((await del(d, 2)).status, 409);++ assert.strictEqual((await search('zetaword')).body.results.length, 1);++ assert.strictEqual((await del(d)).body.outcome, 'applied', 'no revision = the stored revision');++ assert.strictEqual((await search('zetaword')).body.results.length, 0);++ assert.strictEqual((await del(d)).body.outcome, 'unchanged');++});++++t('deleting a document Search never saw leaves a tombstone that refuses late upserts', async () => {++ const d = doc({ title: 'Never seen etaword', revision: 4 });++ assert.strictEqual((await del(d, 4)).body.outcome, 'applied');++ assert.strictEqual((await put(d)).status, 409);++ assert.strictEqual((await search('etaword')).body.results.length, 0);++});++++t('reconciliation lists the owner\'s (id, revision) pairs, tombstones included, with paging', async () => {++ const own = serviceToken('recon', ['search.document.write']);++ const made = [];++ for (let i = 0; i < 5; i++) {++ const d = doc({ owner: 'recon', type: 'item', id: `it_${i}`, revision: i + 1 });++ await put(d, own);++ made.push(d);++ }++ await del(made[2], 3, own);++ const p1 = await request(svc.base, 'GET', '/api/v1/owners/recon/documents?limit=3', { token: own });++ assert.strictEqual(p1.status, 200);++ assert.strictEqual(p1.body.documents.length, 3);++ assert.ok(p1.body.next_after);++ const p2 = await request(svc.base, 'GET', `/api/v1/owners/recon/documents?limit=3&after=${p1.body.next_after}`, { token: own });++ const all = [...p1.body.documents, ...p2.body.documents];++ assert.deepStrictEqual(all.map(x => [x.id, x.revision, x.deleted]), [++ ['it_0', 1, false], ['it_1', 2, false], ['it_2', 3, true], ['it_3', 4, false], ['it_4', 5, false],++ ]);++ assert.strictEqual(p2.body.next_after, null);++ assert.ok(all.every(x => /^[0-9a-f]{64}$/.test(x.hash)));++ // another service cannot read this owner's inventory++ assert.strictEqual((await request(svc.base, 'GET', '/api/v1/owners/recon/documents', { token: WIKI })).status, 403);++ const detail = await request(svc.base, 'GET', '/api/v1/owners/recon/documents/item/it_1', { token: own });++ assert.strictEqual(detail.body.document.revision, 2);++ assert.strictEqual(detail.body.effective_indexability.decision, 'index');++});++++t('Search only makes indexability stricter', async () => {++ const own = serviceToken('gate', ['search.document.write']);++ const cases = [++ [{ visibility: 'members' }, 'members_only'],++ [{ publication_state: 'scheduled' }, 'not_published'],++ [{ provenance: [{ service: 'sources', type: 'item', id: 'x', stub: true }] }, 'stub_provider'],++ [{ canonical_url: undefined }, 'missing_canonical_url'],++ ];++ for (const [i, [over, reason]] of cases.entries()) {++ const d = doc({ owner: 'gate', type: 'thing', id: `g${i}`, ...over });++ if ('canonical_url' in over && over.canonical_url === undefined) delete d.canonical_url;++ assert.strictEqual((await put(d, own)).status, 200);++ const r = await request(svc.base, 'GET', `/api/v1/owners/gate/documents/thing/g${i}`, { token: own });++ assert.strictEqual(r.body.effective_indexability.decision, 'noindex', reason);++ assert.ok(r.body.effective_indexability.reasons.includes(reason), reason);++ }++ const d = doc({ owner: 'gate', type: 'thing', id: 'g9', indexability: { decision: 'noindex' } });++ await put(d, own);++ const r = await request(svc.base, 'GET', '/api/v1/owners/gate/documents/thing/g9', { token: own });++ assert.deepStrictEqual(r.body.effective_indexability, { decision: 'noindex', reasons: ['owner_decision'] });++});++++t('done', async () => { await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,143 @@++'use strict';++/**++ * Shared test fixtures: a generated Network signing key, service/user token minting, a booted++ * Search service on a random port with a temp database, index-document builders and a signed++ * Events delivery helper. Nothing here needs the network or a running OpenVibe.Network.++ */++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { serviceAuth, ids } = require('openvibe-contracts');++const { load } = require('../server/config');++const { start } = require('../server/index');++const { sign } = require('../server/api/webhook');++++const ISSUER = 'https://openvibe.network';++const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++});++++const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };++++function serviceToken(slug, cap, { aud = 'openvibe.search', exp = Math.floor(Date.now() / 1000) + 300, iss = ISSUER, key = privateKey, sub } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, key);++}++++function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey, iss = ISSUER } = {}) {++ return serviceAuth.signServiceToken({++ sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role, iss, aud, iat: Math.floor(Date.now() / 1000), exp,++ }, key);++}++++const made = [];++process.on('exit', () => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });++++function tmpDir() {++ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-search-test-'));++ made.push(d);++ return d;++}++++async function boot({ env = {}, fetchImpl, tokenClient, now } = {}) {++ const dir = tmpDir();++ const config = load({++ NODE_ENV: 'test',++ PORT: '0',++ SEARCH_DB_PATH: path.join(dir, 'search.db'),++ OV_NETWORK_PUBLIC_KEY: publicKey,++ SEARCH_EVENTS_SECRET: WEBHOOK_SECRET,++ ...env,++ });++ const h = await start({ config, log: silent, fetchImpl, tokenClient, now });++ const base = `http://127.0.0.1:${h.server.address().port}`;++ return { ...h, base, dir, async stop() { await h.close(); } };++}++++async function request(base, method, p, { token, body, headers = {}, raw } = {}) {++ const h = { ...headers };++ if (token) h.Authorization = `Bearer ${token}`;++ if (body !== undefined) h['Content-Type'] = 'application/json';++ const res = await fetch(base + p, { method, headers: h, body: raw !== undefined ? raw : body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }++ return { status: res.status, body: json, text, headers: res.headers };++}++++/** A valid, public, published, indexable document. Override anything. */++function doc(overrides = {}) {++ return {++ owner: 'wiki',++ type: 'page',++ id: 'pg_' + crypto.randomBytes(6).toString('hex'),++ revision: 1,++ visibility: 'public',++ acl: {},++ canonical_url: 'https://openvibe.wiki/p/example',++ title: 'Example page',++ summary: 'A short summary.',++ body: 'Body text.',++ facets: { category: 'guides', tags: ['alpha', 'beta'] },++ language: 'en',++ authorship: 'human',++ provenance: [],++ publication_state: 'published',++ published_at: '2026-09-01T10:00:00Z',++ updated_at: '2026-09-02T10:00:00Z',++ indexability: { decision: 'index', reasons: [] },++ ...overrides,++ };++}++++/** A delivered Events envelope carrying an index document. */++function indexEvent(document, { action = 'upserted', source = document.owner, eventId = ids.newId('event'), subject, eventType } = {}) {++ const payload = action === 'deleted' ? { type: document.type, id: document.id, revision: document.revision } : { ...document };++ return {++ event_id: eventId,++ event_type: eventType || `${source}.index_document.${action}`,++ version: 1,++ source,++ actor: { type: 'service', id: source },++ timestamp: new Date().toISOString(),++ visibility: 'internal',++ subject: subject || { type: document.type, id: document.id, revision: document.revision },++ payload,++ };++}++++/** POST a signed delivery to /internal/events. */++async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false } = {}) {++ const raw = JSON.stringify({ event, seq });++ const signature = badSignature ? sign(raw, 'wrong-secret-' + 'y'.repeat(32)) : sign(raw, secret);++ return request(base, 'POST', '/internal/events', {++ raw,++ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signature, 'X-OpenVibe-Event-Id': event.event_id },++ });++}++++function suite(name) {++ const tests = [];++ const t = (n, fn) => tests.push([n, fn]);++ t.run = async () => {++ let failed = 0;++ for (const [n, fn] of tests) {++ try { await fn(); console.log(` ok ${n}`); } catch (err) { failed++; console.log(` FAIL ${n}\n${err.stack}`); }++ }++ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);++ if (failed) process.exit(1);++ };++ return t;++}++++module.exports = {++ ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, tmpDir, boot, request,++ doc, indexEvent, deliver, suite,++};nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/** Outbox relay: removal events reach OpenVibe.Events, failures back off, refusals do not block. */++const assert = require('assert');++const http = require('http');++const { validate } = require('openvibe-contracts');++const { boot, request, serviceToken, doc, suite } = require('./helpers');++++const t = suite('outbox');++++/** Stub OpenVibe.Events: records publishes; `respond(body, n)` → status. */++async function stubEvents(respond = () => 201) {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', c => chunks.push(c));++ req.on('end', () => {++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');++ calls.push({ headers: req.headers, body });++ const status = respond(body, calls.length);++ res.statusCode = status;++ res.setHeader('Content-Type', 'application/json');++ res.end(JSON.stringify(status < 300 ? { ok: true } : { code: 'events.bad_request' }));++ });++ });++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ return { calls, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(r => server.close(() => r())) };++}++++const tokenClient = { async authHeaders() { return { Authorization: 'Bearer relay-token' }; }, invalidate() {} };++const WIKI = serviceToken('wiki', ['search.document.write']);++++t('outbox envelopes are valid events.event-envelope@1 and are enqueued only with the change', async () => {++ const svc = await boot();++ try {++ const d = doc({ canonical_url: 'https://openvibe.wiki/p/a' });++ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });++ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, revision: 0 } }); // stale: no event++ await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });++ const all = svc.outbox.all();++ assert.deepStrictEqual(all.map(e => e.event_type), ['search.document.indexed', 'search.document.removed']);++ for (const e of all) assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));++ assert.strictEqual(all[1].subject.id, `wiki/page/${d.id}`);++ assert.strictEqual(all[1].subject.revision, 2);++ assert.throws(() => svc.outbox.enqueue({ event_type: 'search.document.indexed', subject: { type: 'document', id: 'x' } }), /inside the transaction/);++ } finally {++ await svc.stop();++ }++});++++t('the relay publishes pending events with the service token and marks them sent', async () => {++ const events = await stubEvents();++ const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });++ try {++ const d = doc();++ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });++ await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });++ // a write kicks the relay; a flush already in flight may predate the second row++ for (let i = 0; i < 5 && svc.outbox.pending(); i++) await svc.relay.flush();++ assert.strictEqual(svc.outbox.pending(), 0);++ const published = events.calls.flatMap(c => (c.body.events ? c.body.events : [c.body]));++ assert.deepStrictEqual(published.map(e => e.event_type).sort(), ['search.document.indexed', 'search.document.removed']);++ assert.ok(events.calls.every(c => c.headers.authorization === 'Bearer relay-token'));++ } finally {++ await svc.stop();++ await events.close();++ }++});++++t('an unreachable or failing Events keeps rows pending for retry; a refused row does not block the rest', async () => {++ let mode = 'down';++ const events = await stubEvents((body) => {++ if (mode === 'down') return 503;++ const list = body.events || [body];++ return list.some(e => e.payload && e.payload.id === 'poison') ? 422 : 201;++ });++ const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });++ try {++ await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/poison', { token: WIKI, body: doc({ id: 'poison' }) });++ await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/fine', { token: WIKI, body: doc({ id: 'fine' }) });++ await svc.relay.flush();++ await svc.relay.flush();++ assert.strictEqual(svc.outbox.pending(), 2, 'kept for retry');++ mode = 'up';++ svc.db.prepare('UPDATE event_outbox SET next_attempt_at = 0').run();++ await svc.relay.flush();++ await svc.relay.flush();++ assert.strictEqual(svc.outbox.pending(), 0);++ assert.strictEqual(svc.outbox.rejected(), 1, 'the poison row is set aside');++ } finally {++ await svc.stop();++ await events.close();++ }++});++++t('readiness reports the outbox and the webhook state', async () => {++ const svc = await boot();++ try {++ const r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.engine, 'sqlite-fts5');++ assert.match(r.body.outbox.relay, /^off/);++ assert.strictEqual(r.body.webhook, 'on');++ } finally {++ await svc.stop();++ }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * The proposals for OpenVibe.Contracts are valid against the contracts' own schemas, and match++ * what the code enforces and emits.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { CAPS } = require('../server/auth');++const { validate } = require('../server/document');++const { suite, doc } = require('./helpers');++++const t = suite('proposals');++const DOCS = path.join(__dirname, '..', 'docs');++++t('capability proposals are valid capabilities.capability@1 and cover every enforced id', () => {++ const dir = path.join(DOCS, 'capabilities-proposal');++ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));++ const ids = [];++ for (const f of files) {++ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));++ const v = contracts.validate('capabilities.capability@1', m);++ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);++ assert.strictEqual(`${m.id}.json`, f);++ assert.strictEqual(m.owner, 'search');++ ids.push(m.id);++ }++ assert.deepStrictEqual(ids.sort(), Object.values(CAPS).sort());++});++++t('the service manifest proposal is a valid registry.service-manifest@1', () => {++ const m = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));++ const v = contracts.validate('registry.service-manifest@1', m);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.deepStrictEqual([...m.capabilities].sort(), Object.values(CAPS).sort());++ assert.deepStrictEqual(m.eventsProduced, ['search.document.indexed', 'search.document.removed']);++});++++t('the index-document schema accepts documents and tombstones and refuses malformed ones', () => {++ assert.ok(validate(doc()).valid);++ assert.ok(validate({ owner: 'wiki', type: 'page', id: 'p1', revision: 3, deleted: true }).valid);++ assert.ok(!validate({ owner: 'wiki', type: 'page', id: 'p1', revision: 3 }).valid, 'a live document needs visibility, state, title, indexability');++ assert.ok(!validate(doc({ revision: -1 })).valid);++ assert.ok(!validate(doc({ owner: 'Wiki' })).valid);++ assert.ok(!validate(doc({ extra: 1 })).valid);++ assert.ok(!validate(doc({ facets: { 'Bad Key': 'x' } })).valid);++ assert.ok(!validate(doc({ acl: { subjects: ['svc:wiki'] } })).valid);++ assert.ok(!validate(doc({ provenance: [{ service: 'sources', type: 'item' }] })).valid);++ assert.ok(!validate(doc({ body: 'x'.repeat(48001) })).valid);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,56 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails++ * if any of them fails. They use temp SQLite databases, generated RSA keys and stub subscribers++ * on random ports; none of them needs the network or a running OpenVibe.Network.++ *++ * npm test # everything++ * npm test -- publish sse # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++/** Events consumer: signatures, inbox dedupe, revision order across deliveries, tombstones, refusals. */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, request, serviceToken, doc, indexEvent, deliver, suite } = require('./helpers');++++const t = suite('webhook-inbox');++let svc;++const search = async (q) => (await request(svc.base, 'GET', `/api/v1/search?q=${encodeURIComponent(q)}`)).body.results;++const receipts = () => svc.db.prepare('SELECT COUNT(*) AS n FROM idempotency_receipts').get().n;++++t('boot', async () => { svc = await boot(); });++++t('an unsigned or wrongly signed delivery is refused and changes nothing', async () => {++ const e = indexEvent(doc({ title: 'Unsigned muword' }));++ const r = await deliver(svc.base, e, { badSignature: true });++ assert.strictEqual(r.status, 401);++ const r2 = await request(svc.base, 'POST', '/internal/events', { raw: JSON.stringify({ event: e }), headers: { 'Content-Type': 'application/json' } });++ assert.strictEqual(r2.status, 401);++ assert.strictEqual((await search('muword')).length, 0);++ assert.strictEqual(receipts(), 0);++});++++t('a delivered upsert is indexed; the same event again is a no-op', async () => {++ const d = doc({ title: 'Delivered nuword' });++ const e = indexEvent(d);++ const r = await deliver(svc.base, e);++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual([r.body.duplicate, r.body.outcome], [false, 'applied']);++ const again = await deliver(svc.base, e, { seq: 2 });++ assert.deepStrictEqual([again.status, again.body.duplicate], [200, true]);++ assert.strictEqual((await search('nuword')).length, 1);++ const events = svc.outbox.all().filter(x => x.payload.id === d.id && x.event_type === 'search.document.indexed');++ assert.strictEqual(events.length, 1, 'one effect, one event');++});++++t('out-of-order deliveries converge on the newest revision', async () => {++ const d = doc({ title: 'Order xiword v1' });++ const v3 = indexEvent({ ...d, revision: 3, title: 'Order xiword v3 final' });++ const v1 = indexEvent({ ...d, revision: 1 });++ const v2 = indexEvent({ ...d, revision: 2, title: 'Order xiword v2 stale' });++ assert.strictEqual((await deliver(svc.base, v3)).body.outcome, 'applied');++ assert.strictEqual((await deliver(svc.base, v1)).body.outcome, 'stale');++ assert.strictEqual((await deliver(svc.base, v2)).body.outcome, 'stale');++ const hits = await search('xiword');++ assert.strictEqual(hits.length, 1);++ assert.strictEqual(hits[0].revision, 3);++ assert.strictEqual(hits[0].title, 'Order xiword v3 final');++});++++t('a deletion event tombstones and wins over upserts delivered after it', async () => {++ const d = doc({ title: 'Deleted omicronword', revision: 4 });++ await deliver(svc.base, indexEvent(d));++ assert.strictEqual((await search('omicronword')).length, 1);++ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }, { action: 'deleted' }))).body.outcome, 'applied');++ assert.strictEqual((await search('omicronword')).length, 0);++ // a retried older upsert and a same-revision upsert arrive late++ assert.strictEqual((await deliver(svc.base, indexEvent(d))).body.outcome, 'stale');++ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }))).body.outcome, 'stale');++ assert.strictEqual((await search('omicronword')).length, 0);++ const removed = svc.outbox.all().filter(x => x.event_type === 'search.document.removed' && x.payload.id === d.id);++ assert.deepStrictEqual(removed.map(x => x.payload.reason), ['deleted']);++});++++t('the events path and the direct API share one revision order', async () => {++ const d = doc({ title: 'Shared piword', revision: 2 });++ await deliver(svc.base, indexEvent(d));++ const token = serviceToken('wiki', ['search.document.write']);++ const r = await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token, body: { ...d, revision: 1 } });++ assert.strictEqual(r.status, 409);++ const del = await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=3`, { token });++ assert.strictEqual(del.body.outcome, 'applied');++ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 2 }))).body.outcome, 'stale');++});++++t('a source cannot write another owner\'s documents; refusals are recorded once and visible to that owner', async () => {++ const d = doc({ owner: 'wiki', title: 'Forged rhoword' });++ // blog publishes an event claiming a wiki document++ const forged = indexEvent({ ...d }, { source: 'blog', eventType: 'blog.index_document.upserted' });++ const r = await deliver(svc.base, forged);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.outcome, 'rejected:owner_mismatch');++ assert.strictEqual((await deliver(svc.base, forged)).body.duplicate, true);++ // a prefix that is not the source++ const prefix = indexEvent({ ...d, owner: undefined }, { source: 'blog', eventType: 'wiki.index_document.upserted' });++ assert.strictEqual((await deliver(svc.base, prefix)).body.outcome, 'rejected:owner_mismatch');++ assert.strictEqual((await search('rhoword')).length, 0);++++ const blogToken = serviceToken('blog', ['search.document.write']);++ const rej = await request(svc.base, 'GET', '/api/v1/owners/blog/rejections', { token: blogToken });++ assert.strictEqual(rej.status, 200);++ assert.deepStrictEqual(rej.body.rejections.map(x => x.code), ['owner_mismatch', 'owner_mismatch']);++});++++t('unknown owners, subject mismatches and invalid documents are refused', async () => {++ const stranger = doc({ owner: 'nobody', title: 'Stranger sigmaword' });++ assert.strictEqual((await deliver(svc.base, indexEvent(stranger))).body.outcome, 'rejected:owner_not_accepted');++ const d = doc({ title: 'Mismatch sigmaword' });++ const mismatch = indexEvent(d, { subject: { type: 'page', id: 'someone_else', revision: 1 } });++ assert.strictEqual((await deliver(svc.base, mismatch)).body.outcome, 'rejected:subject_mismatch');++ const badRev = indexEvent(d, { subject: { type: 'page', id: d.id, revision: 9 } });++ assert.strictEqual((await deliver(svc.base, badRev)).body.outcome, 'rejected:subject_mismatch');++ const invalid = indexEvent({ ...d, visibility: 'world' });++ assert.strictEqual((await deliver(svc.base, invalid)).body.outcome, 'rejected:invalid_document');++ assert.strictEqual((await search('sigmaword')).length, 0);++});++++t('events that are not index documents are acknowledged and ignored', async () => {++ const e = { ...indexEvent(doc()), event_type: 'wiki.page.published' };++ const r = await deliver(svc.base, e);++ assert.deepStrictEqual([r.status, r.body.outcome], [200, 'ignored']);++});++++t('a failure while applying leaves no receipt, so the redelivery applies exactly once', async () => {++ const d = doc({ title: 'Crash tauword' });++ const e = indexEvent(d);++ const original = svc.store.apply;++ let calls = 0;++ svc.store.apply = (...args) => { calls++; if (calls === 1) throw new Error('disk on fire'); return original(...args); };++ try {++ const r1 = await deliver(svc.base, e);++ assert.strictEqual(r1.status, 500);++ assert.strictEqual((await search('tauword')).length, 0);++ assert.strictEqual(svc.db.prepare('SELECT COUNT(*) AS n FROM idempotency_receipts WHERE event_id = ?').get(e.event_id).n, 0);++ const r2 = await deliver(svc.base, e, { seq: 2 });++ assert.deepStrictEqual([r2.status, r2.body.outcome], [200, 'applied']);++ const r3 = await deliver(svc.base, e, { seq: 3 });++ assert.strictEqual(r3.body.duplicate, true);++ } finally {++ svc.store.apply = original;++ }++ assert.strictEqual((await search('tauword')).length, 1);++ assert.strictEqual(svc.outbox.all().filter(x => x.payload.id === d.id).length, 1);++});++++t('without SEARCH_EVENTS_SECRET the webhook is off', async () => {++ const off = await boot({ env: { SEARCH_EVENTS_SECRET: '' } });++ try {++ const r = await deliver(off.base, indexEvent(doc()));++ assert.strictEqual(r.status, 503);++ } finally {++ await off.stop();++ }++});++++t('secret rotation: both configured secrets verify', async () => {++ const other = 'whsec_rotated_' + 'z'.repeat(40);++ const svc2 = await boot({ env: { SEARCH_EVENTS_SECRET: `${other},whsec_test_${'x'.repeat(40)}` } });++ try {++ assert.strictEqual((await deliver(svc2.base, indexEvent(doc()), { secret: other })).status, 200);++ assert.strictEqual((await deliver(svc2.base, indexEvent(doc()))).status, 200);++ } finally {++ await svc2.stop();++ }++});++++t('done', async () => { await svc.stop(); void ids; });++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
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,1362 @@
@@ -0,0 +1,1362 @@
+{
+ "name": "openvibe-search",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-search",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "better-sqlite3": "^11.10.0",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "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/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "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/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "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": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "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",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "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/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "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-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "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/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "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/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/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/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "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/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=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/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/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "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/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "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/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "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/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/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "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": "BSD-3-Clause"
+ },
+ "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/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "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/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/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "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/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "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/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.7.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",
+ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",
+ "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/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/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "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/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "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/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "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/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "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",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "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/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "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/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "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/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+{
+ "name": "openvibe-search",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Search: the canonical index document, event-fed and direct indexing with revision ordering and tombstones, and a permission-aware query API over SQLite FTS5.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "subscribe": "node scripts/subscribe.js"
+ },
+ "keywords": ["openvibe", "search", "fts5", "acl"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Search",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "better-sqlite3": "^11.10.0",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Create Search's OpenVibe.Events subscription for index documents.
+ *
+ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4710/internal/events] [--pattern '*.index_document.*']
+ *
+ * Reads the environment (.env or /etc/openvibe/search.env):
+ * EVENTS_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the search
+ * principal needs events.subscription.manage for audience openvibe.events)
+ * SEARCH_EVENTS_SECRET the delivery signing secret; the first value is handed to Events, so
+ * generate it before running this (e.g. `openssl rand -hex 32`). Nothing
+ * secret is printed.
+ * An existing identical subscription is reported, not duplicated.
+ */
+require('dotenv').config();
+const { serviceAuth } = require('openvibe-contracts');
+const { load } = require('../server/config');
+
+const args = process.argv.slice(2);
+const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
+
+(async () => {
+ const config = load();
+ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
+ const pattern = opt('pattern', '*.index_document.*');
+ const secret = config.events.webhookSecrets[0];
+ if (!config.events.url) throw new Error('EVENTS_URL is not set');
+ if (!secret || secret.length < 32) throw new Error('SEARCH_EVENTS_SECRET must be set (32+ characters) before subscribing');
+ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events',
+ scope: 'events.subscription.manage',
+ });
+ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 409 && body.subscription_id) {
+ console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`);
+ return;
+ }
+ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());
+ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
+})().catch((err) => {
+ console.error(`subscribe failed: ${err.message}`);
+ process.exit(1);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * Owner-side API (service token with search.document.write; a service writes and reads only the
+ * documents it owns: svc:wiki → owner "wiki").
+ *
+ * PUT /api/v1/documents/:owner/:type/:id upsert (search.index-document@1 body)
+ * DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone
+ * GET /api/v1/owners/:owner/documents?type=&after=&limit=
+ * reconciliation: (type, id, revision, deleted, hash)
+ * GET /api/v1/owners/:owner/documents/:type/:id the stored document + effective indexability
+ * GET /api/v1/owners/:owner/rejections?after= index-document events Search refused
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { validate, effectiveIndexability } = require('../document');
+const { CAPS } = require('../auth');
+
+const OWNER_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
+const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;
+
+function validIdentity(p) {
+ return OWNER_RE.test(p.owner) && TYPE_RE.test(p.type) && (p.id === undefined || ID_RE.test(p.id));
+}
+
+function outcomeResponse(res, ctx, r) {
+ if (r.outcome === 'stale') {
+ return http.sendProblem(res, 409, 'search.stale_revision', {
+ detail: `revision ${r.revision} is older than the stored revision ${r.stored_revision}${r.deleted ? ' (tombstone)' : ''}`,
+ ctx, extra: { stored_revision: r.stored_revision, deleted: r.deleted },
+ });
+ }
+ if (r.outcome === 'conflict') {
+ return http.sendProblem(res, 409, 'search.revision_conflict', {
+ detail: `revision ${r.revision} is already indexed with different content; send a new revision`,
+ ctx, extra: { stored_revision: r.stored_revision },
+ });
+ }
+ return res.json(r);
+}
+
+function documentsRouter({ store, auth, db, relay }) {
+ const router = express.Router();
+ const guard = auth.requireCap(CAPS.write);
+
+ function ownerCheck(req, res) {
+ const ctx = req.ov;
+ if (!validIdentity(req.params)) {
+ http.sendProblem(res, 400, 'search.bad_identity', { detail: 'owner, type or id is malformed', ctx });
+ return false;
+ }
+ if (req.params.owner !== req.principal.service) {
+ http.sendProblem(res, 403, 'search.not_owner', { detail: `svc:${req.principal.service} cannot write or read documents owned by ${req.params.owner}`, ctx });
+ return false;
+ }
+ return true;
+ }
+
+ router.put('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {
+ const ctx = req.ov;
+ if (!ownerCheck(req, res)) return;
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
+ if (!body) return http.sendProblem(res, 400, 'search.bad_document', { detail: 'body must be an index document object', ctx });
+ for (const k of ['owner', 'type', 'id']) {
+ if (body[k] !== undefined && body[k] !== req.params[k]) {
+ return http.sendProblem(res, 422, 'search.bad_document', { detail: `body ${k} does not match the path`, ctx });
+ }
+ }
+ const doc = { ...body, owner: req.params.owner, type: req.params.type, id: req.params.id };
+ const v = validate(doc);
+ if (!v.valid) return http.sendProblem(res, 422, 'search.bad_document', { detail: 'document does not match search.index-document@1', ctx, errors: v.errors });
+ const r = store.apply(doc, { via: 'api', traceId: ctx.traceId });
+ if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});
+ return outcomeResponse(res, ctx, r);
+ });
+
+ router.delete('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {
+ const ctx = req.ov;
+ if (!ownerCheck(req, res)) return;
+ let revision = null;
+ if (req.query.revision !== undefined) {
+ if (!/^\d{1,15}$/.test(String(req.query.revision))) return http.sendProblem(res, 400, 'search.bad_revision', { detail: 'revision must be a non-negative integer', ctx });
+ revision = Number(req.query.revision);
+ }
+ const r = store.remove(req.params.owner, req.params.type, req.params.id, { revision, via: 'api', traceId: ctx.traceId });
+ if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});
+ return outcomeResponse(res, ctx, r);
+ });
+
+ router.get('/api/v1/owners/:owner/documents', guard, (req, res) => {
+ const ctx = req.ov;
+ if (!ownerCheck(req, res)) return;
+ const type = req.query.type ? String(req.query.type) : null;
+ if (type && !TYPE_RE.test(type)) return http.sendProblem(res, 400, 'search.bad_identity', { detail: 'type is malformed', ctx });
+ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 500, 1), 1000);
+ res.json({ owner: req.params.owner, ...store.ownerPage(req.params.owner, { type, after, limit }) });
+ });
+
+ router.get('/api/v1/owners/:owner/documents/:type/:id', guard, (req, res) => {
+ const ctx = req.ov;
+ if (!ownerCheck(req, res)) return;
+ const found = store.get(req.params.owner, req.params.type, req.params.id);
+ if (!found) return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such document', ctx });
+ res.json({
+ document: found.doc,
+ effective_indexability: effectiveIndexability(found.doc),
+ exposure: store.EXPOSURE_NAME[found.row.exposure],
+ via: found.row.via,
+ event_id: found.row.event_id,
+ indexed_at: new Date(found.row.indexed_at).toISOString(),
+ });
+ });
+
+ router.get('/api/v1/owners/:owner/rejections', guard, (req, res) => {
+ if (!ownerCheck(req, res)) return;
+ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
+ const rows = db.prepare('SELECT * FROM ingest_rejections WHERE owner = ? AND seq > ? ORDER BY seq LIMIT 200').all(req.params.owner, after);
+ res.json({
+ rejections: rows.map(r => ({
+ seq: r.seq, event_id: r.event_id, event_type: r.event_type, type: r.type, id: r.id, revision: r.revision,
+ code: r.code, detail: r.detail, at: new Date(r.at).toISOString(),
+ })),
+ next_after: rows.length ? rows[rows.length - 1].seq : null,
+ });
+ });
+
+ return router;
+}
+
+module.exports = { documentsRouter, OWNER_RE, TYPE_RE, ID_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+'use strict';
+/**
+ * Query API. Visibility is decided by the engine SQL for every hit, count and suggestion:
+ *
+ * anonymous public, published, indexable documents only
+ * user (Network JWT) + restricted documents whose ACL names its subject (or role:<role>)
+ * service + X-OV-Subject + the ACL matches of that subject and the groups/entitlements the
+ * (search.query.delegate) service vouches for
+ *
+ * GET /api/v1/search?q=&owner=&type=&lang=&facet.<key>=<value>&facets=<k1,k2>&limit=&cursor=
+ * GET /api/v1/suggest?q=&owner=&type=&limit=
+ * GET /api/v1/documents/:owner/:type/:id one document by exact id (unlisted: any signed-in
+ * subject; public noindex: anyone)
+ *
+ * Nothing here ever returns an ACL, a draft, an unpublished or deleted document, a total count, or
+ * a facet count over documents the viewer cannot see. A document the viewer may not see and a
+ * document that does not exist give the same 404.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { toMatch, snippetHtml } = require('../engine/fts5');
+const { EXPOSURE } = require('../document');
+const { AuthError } = require('../auth');
+const { OWNER_RE, TYPE_RE, ID_RE } = require('./documents');
+
+const FACET_KEY_RE = /^[a-z][a-z0-9_]{0,39}$/;
+const LANG_RE = /^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
+
+class QueryError extends Error {
+ constructor(code, detail) { super(detail); this.code = code; }
+}
+
+function one(v) {
+ return Array.isArray(v) ? v[0] : v;
+}
+
+/** Filters from the query string (identity never comes from here). */
+function parseFilters(query, config) {
+ const filters = { facets: [] };
+ const owner = one(query.owner);
+ const type = one(query.type);
+ const lang = one(query.lang);
+ if (owner !== undefined) { if (!OWNER_RE.test(String(owner))) throw new QueryError('search.bad_query', 'owner is malformed'); filters.owner = String(owner); }
+ if (type !== undefined) { if (!TYPE_RE.test(String(type))) throw new QueryError('search.bad_query', 'type is malformed'); filters.type = String(type); }
+ if (lang !== undefined) { if (!LANG_RE.test(String(lang))) throw new QueryError('search.bad_query', 'lang is malformed'); filters.language = String(lang); }
+ for (const [k, v] of Object.entries(query)) {
+ if (!k.startsWith('facet.')) continue;
+ const key = k.slice(6);
+ if (!FACET_KEY_RE.test(key)) throw new QueryError('search.bad_query', `facet key ${key} is malformed`);
+ const values = (Array.isArray(v) ? v : [v]).map(String).filter(x => x.length && x.length <= 200).slice(0, 20);
+ if (!values.length) continue;
+ filters.facets.push([key, values]);
+ }
+ if (filters.facets.length > config.query.maxFacetFilters) throw new QueryError('search.bad_query', `at most ${config.query.maxFacetFilters} facet filters`);
+ return filters;
+}
+
+function queryHash(parts) {
+ return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('base64url').slice(0, 16);
+}
+
+function encodeCursor(obj) {
+ return Buffer.from(JSON.stringify(obj)).toString('base64url');
+}
+
+function decodeCursor(s, hash) {
+ if (s === undefined || s === '') return null;
+ let c;
+ try { c = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8')); } catch { c = null; }
+ if (!c || c.h !== hash || !Number.isInteger(c.i)) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');
+ if (c.k === 'r' && typeof c.r === 'number') return { rank: c.r, rid: c.i };
+ if (c.k === 't' && typeof c.t === 'string') return { sort_at: c.t, rid: c.i };
+ throw new QueryError('search.bad_cursor', 'cursor is malformed');
+}
+
+/** What a viewer gets for one document: never the ACL, the hash or the owner's internals. */
+function projection(doc, row, snip) {
+ const out = {
+ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
+ visibility: doc.visibility,
+ title: doc.title,
+ summary: doc.summary || null,
+ canonical_url: doc.canonical_url,
+ facets: doc.facets,
+ language: doc.language,
+ authorship: doc.authorship,
+ provenance: doc.provenance,
+ published_at: doc.published_at,
+ updated_at: doc.updated_at,
+ indexable: row.index_decision === 'index',
+ };
+ if (snip !== undefined) out.snippet_html = snippetHtml(snip);
+ return out;
+}
+
+/** May this viewer see this stored document by exact id? */
+function canView(viewer, row, doc) {
+ if (row.deleted || row.exposure === EXPOSURE.none) return false;
+ if (row.exposure >= EXPOSURE.public_unlisted) return true;
+ if (!viewer.subject) return false;
+ const acl = doc.acl || {};
+ const subj = (acl.subjects || []).includes(viewer.subject);
+ if (doc.visibility === 'private') return subj;
+ if (doc.visibility === 'unlisted') return true; // a signed-in subject holding the exact id
+ if (doc.visibility === 'members') {
+ return subj
+ || (acl.groups || []).some(g => (viewer.groups || []).includes(g))
+ || (acl.entitlements || []).some(e => (viewer.entitlements || []).includes(e));
+ }
+ return false;
+}
+
+function queryRouter({ config, store, engine, auth }) {
+ const router = express.Router();
+
+ function withViewer(handler) {
+ return (req, res) => {
+ const ctx = req.ov;
+ let viewer;
+ try {
+ viewer = auth.viewer(req);
+ } catch (err) {
+ if (err instanceof AuthError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });
+ throw err;
+ }
+ // Never cached: a visibility change or deletion must leave results immediately, and a
+ // personalised answer must never reach a shared cache.
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');
+ try {
+ return handler(req, res, viewer);
+ } catch (err) {
+ if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });
+ throw err;
+ }
+ };
+ }
+
+ router.get('/api/v1/search', withViewer((req, res, viewer) => {
+ const q = one(req.query.q);
+ const text = q === undefined ? '' : String(q);
+ if (text.length > 500) throw new QueryError('search.bad_query', 'q is longer than 500 characters');
+ const filters = parseFilters(req.query, config);
+ const limit = Math.min(Math.max(parseInt(one(req.query.limit), 10) || config.query.defaultLimit, 1), config.query.maxLimit);
+ const facetKeys = String(one(req.query.facets) || '').split(',').map(s => s.trim()).filter(Boolean);
+ if (facetKeys.length > 5 || facetKeys.some(k => !FACET_KEY_RE.test(k))) throw new QueryError('search.bad_query', 'facets: at most 5 well-formed keys');
+
+ const match = text.trim() ? toMatch(text, { maxTerms: config.query.maxTerms }) : null;
+ const hash = queryHash([match, filters]);
+ const after = decodeCursor(one(req.query.cursor), hash);
+ if (text.trim() && !match) {
+ return res.json({ results: [], next_cursor: null, ...(facetKeys.length ? { facets: Object.fromEntries(facetKeys.map(k => [k, []])) } : {}) });
+ }
+ let page;
+ if (match) {
+ if (after && after.rank === undefined) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');
+ page = engine.search({ match, filters, viewer, limit, after });
+ } else {
+ if (after && after.sort_at === undefined) throw new QueryError('search.bad_cursor', 'cursor does not belong to this query');
+ page = engine.browse({ filters, viewer, limit, after });
+ }
+ const results = [];
+ for (const h of page.hits) {
+ const found = store.byRid(h.rid);
+ // Defence in depth: the engine already filtered; re-check against the stored row.
+ if (!found || !canView(viewer, found.row, found.doc)) continue;
+ results.push(projection(found.doc, found.row, match ? h.snip : undefined));
+ }
+ const next = page.next
+ ? encodeCursor(match ? { k: 'r', r: page.next.rank, i: page.next.rid, h: hash } : { k: 't', t: page.next.sort_at, i: page.next.rid, h: hash })
+ : null;
+ const body = { results, next_cursor: next };
+ if (facetKeys.length) body.facets = engine.facetCounts({ match, filters, viewer, keys: facetKeys });
+ res.json(body);
+ }));
+
+ router.get('/api/v1/suggest', withViewer((req, res, viewer) => {
+ const text = String(one(req.query.q) || '');
+ if (text.length > 100) throw new QueryError('search.bad_query', 'q is longer than 100 characters');
+ const filters = parseFilters(req.query, config);
+ const limit = Math.min(Math.max(parseInt(one(req.query.limit), 10) || 8, 1), 20);
+ const match = toMatch(text, { maxTerms: 6, column: 'title', prefixLast: true });
+ if (!match) return res.json({ suggestions: [] });
+ const rows = engine.suggest({ match, filters, viewer, limit });
+ const suggestions = [];
+ for (const r of rows) {
+ const found = store.byRid(r.rid);
+ if (!found || !canView(viewer, found.row, found.doc)) continue;
+ suggestions.push({ owner: found.doc.owner, type: found.doc.type, id: found.doc.id, title: found.doc.title, canonical_url: found.doc.canonical_url });
+ }
+ res.json({ suggestions });
+ }));
+
+ router.get('/api/v1/documents/:owner/:type/:id', withViewer((req, res, viewer) => {
+ const { owner, type, id } = req.params;
+ if (!OWNER_RE.test(owner) || !TYPE_RE.test(type) || !ID_RE.test(id)) throw new QueryError('search.bad_identity', 'owner, type or id is malformed');
+ const found = store.get(owner, type, id);
+ if (!found || !canView(viewer, found.row, found.doc)) {
+ return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such document', ctx: req.ov });
+ }
+ res.json({ document: projection(found.doc, found.row) });
+ }));
+
+ return router;
+}
+
+module.exports = { queryRouter, canView, projection, toMatch };
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * Events consumer: POST /internal/events, the endpoint of Search's OpenVibe.Events subscription
+ * (topic pattern `*.index_document.*`, see scripts/subscribe.js).
+ *
+ * Owners publish, through their own outbox:
+ * <owner>.index_document.upserted payload = search.index-document@1 (owner may be omitted)
+ * <owner>.index_document.deleted payload = { type, id, revision } (a tombstone)
+ * with envelope subject { type, id, revision } and visibility "internal".
+ *
+ * Exactly once: the inbox receipt (consumer, event_id) and the document change commit in one
+ * SQLite transaction. A redelivery of a processed event is answered 204 and changes nothing.
+ * Revision order is the store's rule, so out-of-order delivery is harmless.
+ *
+ * Refusals (unknown owner, a source writing another owner's document, a bad document, a subject
+ * that does not match the payload) are acknowledged, recorded once in ingest_rejections and
+ * visible to the owner through GET /api/v1/owners/:owner/rejections: retrying cannot fix them.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { validate } = require('../document');
+
+const CONSUMER = 'search-index';
+const TYPE_RE = /^([a-z][a-z0-9-]{1,39})\.index_document\.(upserted|deleted)$/;
+
+function sign(raw, secret) {
+ return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');
+}
+
+function verifySignature(raw, header, secrets) {
+ if (!raw || typeof header !== 'string') return false;
+ const given = Buffer.from(header.trim());
+ return secrets.some((s) => {
+ const expected = Buffer.from(sign(raw, s));
+ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
+ });
+}
+
+function createInbox(db, { now = () => Date.now() } = {}) {
+ const claim = db.prepare('INSERT OR IGNORE INTO idempotency_receipts (consumer, event_id, outcome, processed_at) VALUES (?, ?, ?, ?)');
+ const setOutcome = db.prepare('UPDATE idempotency_receipts SET outcome = ? WHERE consumer = ? AND event_id = ?');
+ const reject = db.prepare(`INSERT INTO ingest_rejections (event_id, event_type, owner, type, id, revision, code, detail, at)
+ VALUES (@event_id, @event_type, @owner, @type, @id, @revision, @code, @detail, @at)`);
+
+ /** fn() runs inside the transaction and returns an outcome string. */
+ function once(eventId, fn) {
+ return db.transaction(() => {
+ if (claim.run(CONSUMER, eventId, 'processing', now()).changes === 0) return { duplicate: true };
+ const outcome = fn();
+ setOutcome.run(outcome, CONSUMER, eventId);
+ return { duplicate: false, outcome };
+ })();
+ }
+
+ function recordRejection(r) {
+ reject.run({ owner: null, type: null, id: null, revision: null, detail: null, event_type: null, ...r, at: now() });
+ }
+
+ return { once, recordRejection };
+}
+
+/** Turn one delivered envelope into { doc } or { reject: { code, detail } } or { ignore }. */
+function documentFromEvent(event, owners) {
+ const m = TYPE_RE.exec(String(event.event_type || ''));
+ if (!m) return { ignore: 'not an index_document event' };
+ const [, prefixOwner, action] = m;
+ const source = event.source;
+ if (prefixOwner !== source) return { reject: { code: 'owner_mismatch', detail: `event_type ${event.event_type} from source ${source}` } };
+ if (!owners.includes(source)) return { reject: { code: 'owner_not_accepted', detail: `${source} is not in SEARCH_EVENT_OWNERS` } };
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ if (payload.owner !== undefined && payload.owner !== source) {
+ return { reject: { code: 'owner_mismatch', detail: `payload owner ${payload.owner} from source ${source}` } };
+ }
+ const doc = action === 'deleted'
+ ? { owner: source, type: payload.type, id: payload.id, revision: payload.revision, deleted: true }
+ : { ...payload, owner: source };
+ const subject = event.subject || {};
+ if (subject.type !== doc.type || subject.id !== doc.id || (subject.revision !== undefined && subject.revision !== doc.revision)) {
+ return { reject: { code: 'subject_mismatch', detail: 'envelope subject {type, id, revision} must match the document' } };
+ }
+ const v = validate(doc);
+ if (!v.valid) return { reject: { code: 'invalid_document', detail: v.errors.map(e => `${e.path} ${e.message}`).join('; ').slice(0, 1000) } };
+ return { doc };
+}
+
+function webhookRouter({ config, db, store, relay, log = console, now }) {
+ const router = express.Router();
+ const inbox = createInbox(db, { now });
+
+ router.post('/internal/events',
+ express.raw({ type: () => true, limit: config.maxBodyBytes }),
+ (req, res) => {
+ const ctx = req.ov;
+ const secrets = config.events.webhookSecrets;
+ if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ if (!verifySignature(raw, req.get('x-openvibe-signature'), secrets)) {
+ return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });
+ }
+ let body;
+ try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
+ const event = body && body.event;
+ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
+ return http.sendProblem(res, 400, 'search.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx });
+ }
+
+ const r = inbox.once(event.event_id, () => {
+ const parsed = documentFromEvent(event, config.events.owners);
+ if (parsed.ignore) return 'ignored';
+ if (parsed.reject) {
+ const p = event.payload || {};
+ inbox.recordRejection({
+ event_id: event.event_id, event_type: event.event_type, owner: event.source,
+ type: typeof p.type === 'string' ? p.type.slice(0, 40) : null,
+ id: typeof p.id === 'string' ? p.id.slice(0, 128) : null,
+ revision: Number.isInteger(p.revision) ? p.revision : null,
+ code: parsed.reject.code, detail: parsed.reject.detail,
+ });
+ log.warn(`[inbox] refused ${event.event_type} ${event.event_id}: ${parsed.reject.code}`);
+ return `rejected:${parsed.reject.code}`;
+ }
+ const traceId = typeof event.trace_id === 'string' ? event.trace_id : null;
+ const out = store.apply(parsed.doc, { via: 'event', eventId: event.event_id, traceId });
+ return out.outcome;
+ });
+ if (!r.duplicate && r.outcome === 'applied' && relay) relay.flush().catch(() => {});
+ res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.outcome || null });
+ });
+
+ return router;
+}
+
+module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/** Express app: request context, the v1 API, the Events webhook, health/readiness. */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { documentsRouter } = require('./api/documents');
+const { queryRouter } = require('./api/query');
+const { webhookRouter } = require('./api/webhook');
+const pkg = require('../package.json');
+
+function createApp({ config, db, store, engine, auth, keys, outbox, relay, log = console, now }) {
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', 'loopback');
+ app.set('query parser', 'extended');
+ app.use(http.middleware());
+ app.use((req, res, next) => {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ next();
+ });
+
+ // The webhook reads its raw body itself (the signature covers the exact bytes).
+ app.use(webhookRouter({ config, db, store, relay, log, now }));
+
+ app.use('/api', express.json({ limit: config.maxBodyBytes, type: ['application/json', 'application/*+json'] }));
+
+ app.get('/api/health', (_req, res) => {
+ res.json({ status: 'ok', service: 'openvibe-search', version: pkg.version });
+ });
+
+ app.get('/api/ready', (_req, res) => {
+ let dbOk = false;
+ try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }
+ const checks = { db: dbOk, key: keys.loaded() };
+ const ready = Object.values(checks).every(Boolean);
+ res.status(ready ? 200 : 503).json({
+ status: ready ? 'ready' : 'not_ready',
+ checks,
+ engine: engine.name,
+ documents: dbOk ? store.counts() : null,
+ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
+ webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',
+ });
+ });
+
+ app.use(documentsRouter({ store, auth, db, relay }));
+ app.use(queryRouter({ config, store, engine, auth }));
+
+ app.get('/', (_req, res) => {
+ res.type('text/plain').send([
+ 'OpenVibe.Search: permission-aware search over documents the network\'s services index.',
+ '',
+ 'GET /api/v1/search?q=&owner=&type=&facet.<key>=&cursor= query (anonymous: public only)',
+ 'GET /api/v1/suggest?q= title suggestions',
+ 'GET /api/v1/documents/:owner/:type/:id one document the caller may see',
+ 'PUT /api/v1/documents/:owner/:type/:id index (owner, search.document.write)',
+ 'DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone (owner)',
+ 'GET /api/v1/owners/:owner/documents reconciliation (owner)',
+ 'POST /internal/events OpenVibe.Events delivery (signed)',
+ 'GET /api/health, /api/ready',
+ '',
+ 'Source: https://github.com/OpenVibers/OpenVibe.Search',
+ '',
+ ].join('\n'));
+ });
+
+ app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ if (err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'search.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });
+ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'search.too_large', { detail: 'request body too large', ctx: req.ov });
+ log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);
+ if (res.headersSent) return res.end();
+ return http.sendProblem(res, 500, 'search.internal', { detail: 'internal error', ctx: req.ov });
+ });
+
+ return app;
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+/**
+ * Authentication, capabilities and the query viewer.
+ *
+ * - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience
+ * openvibe.search), verified offline with openvibe-contracts serviceAuth.verifyServiceToken.
+ * - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.
+ *
+ * search.document.write and search.query.delegate are proposed in docs/capabilities-proposal/ and
+ * are not in openvibe-contracts yet. checkCapability() decides them with the library's own grant
+ * rule (exact id or a `family.*` grant) until a contracts release knows them; from then on the
+ * library decides and nothing changes here.
+ */
+const crypto = require('crypto');
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const CAPS = Object.freeze({
+ write: 'search.document.write',
+ delegate: 'search.query.delegate',
+});
+const PROPOSED = new Set(Object.values(CAPS));
+
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const GROUP_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;
+const MAX_VIEWER_KEYS = 100;
+
+// ── Network public key ─────────────────────────────────────
+
+/**
+ * Loads the Network signing key from GET /api/.well-known/jwks ({ keys: [jwk] } or the older
+ * { public_key: PEM }), retrying every 30 s until it loads and refreshing every 6 h after that.
+ * A PEM given in config (OV_NETWORK_PUBLIC_KEY) is used as is and never fetched.
+ */
+function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {
+ let key = pem ? toPem(pem) : null;
+ let retryTimer = null;
+ let refreshTimer = null;
+
+ function toPem(value) {
+ return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' });
+ }
+
+ async function fetchOnce() {
+ for (const base of urls) {
+ if (!base) continue;
+ const url = `${base}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find(k => k.kty === 'RSA');
+ if (jwk) key = toPem({ key: jwk, format: 'jwk' });
+ else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);
+ else throw new Error('no RSA key in response');
+ log.log(`[auth] Network public key loaded from ${base}`);
+ return key;
+ } catch (err) {
+ log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);
+ }
+ }
+ return null;
+ }
+
+ function start() {
+ if (pem) return Promise.resolve(key);
+ const attempt = async () => {
+ const k = await fetchOnce();
+ if (!k && !key) {
+ retryTimer = setTimeout(attempt, 30 * 1000);
+ retryTimer.unref?.();
+ }
+ return k;
+ };
+ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
+ refreshTimer.unref?.();
+ return attempt();
+ }
+
+ function stop() {
+ clearTimeout(retryTimer);
+ clearInterval(refreshTimer);
+ }
+
+ return { get: () => key, loaded: () => Boolean(key), start, stop, fetchOnce };
+}
+
+// ── Capabilities ───────────────────────────────────────────
+
+/** → { allowed, code, reason } like capabilities.check(). */
+function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
+ return capabilities.check(claims, capabilityId);
+}
+
+// ── Tokens ─────────────────────────────────────────────────
+
+const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
+
+function decodePayload(token) {
+ const parts = typeof token === 'string' ? token.split('.') : [];
+ if (parts.length !== 3) return null;
+ try { return b64json(parts[1]); } catch { return null; }
+}
+
+/** Network user JWT (RS256). Returns claims or null. Service tokens are never accepted as users. */
+function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {
+ if (!publicKey || typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const header = b64json(parts[0]);
+ if (header.alg !== 'RS256') return null;
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'));
+ if (!ok) return null;
+ const claims = b64json(parts[1]);
+ const t = Math.floor(now / 1000);
+ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;
+ if (issuer && claims.iss !== issuer) return null;
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
+ if (!aud.some(a => audiences.includes(a))) return null;
+ if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;
+ return claims;
+ } catch {
+ return null;
+ }
+}
+
+function bearer(req) {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
+}
+
+function cookie(req, name) {
+ const raw = String(req.headers.cookie || '');
+ for (const part of raw.split(';')) {
+ const i = part.indexOf('=');
+ if (i > 0 && part.slice(0, i).trim() === name) {
+ try { return decodeURIComponent(part.slice(i + 1).trim()); } catch { return null; }
+ }
+ }
+ return null;
+}
+
+/** Service slug for a principal sub: svc:wiki -> 'wiki'; app:/mod: principals have none. */
+function serviceSlug(sub) {
+ const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String(sub || ''));
+ return m ? m[1] : null;
+}
+
+class AuthError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+/** Comma-separated group/entitlement keys from a header; a malformed key refuses the request. */
+function keyList(value, header) {
+ if (value == null || value === '') return [];
+ const out = [...new Set(String(value).split(',').map(s => s.trim()).filter(Boolean))];
+ if (out.length > MAX_VIEWER_KEYS) throw new AuthError(400, 'search.bad_viewer', `${header}: at most ${MAX_VIEWER_KEYS} keys`);
+ for (const k of out) if (!GROUP_RE.test(k)) throw new AuthError(400, 'search.bad_viewer', `${header}: malformed key`);
+ return out;
+}
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, groups: [], entitlements: [] });
+
+function createAuth({ config, keys }) {
+ function verifyService(token) {
+ const publicKey = keys.get();
+ if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
+ return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
+ }
+
+ function verifyUser(token) {
+ return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });
+ }
+
+ /**
+ * Express guard for service routes: one capability. Sets req.principal = { sub, service, cap }.
+ * Only service principals (svc:<slug>) pass: documents are always owned by a service.
+ */
+ function requireCap(id) {
+ return function capGuard(req, res, next) {
+ const ctx = req.ov;
+ const token = bearer(req);
+ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
+ const r = verifyService(token);
+ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
+ const c = checkCapability(r.claims, id);
+ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
+ const service = serviceSlug(r.claims.sub);
+ if (!service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });
+ req.principal = { sub: r.claims.sub, service, cap: r.claims.cap, jti: r.claims.jti };
+ return next();
+ };
+ }
+
+ function userViewer(claims) {
+ const subject = typeof claims.subject_id === 'string' && ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ // Without a canonical subject the user is treated like anyone else (public only).
+ if (!subject) return ANONYMOUS;
+ const groups = [];
+ if (typeof claims.role === 'string' && /^[a-z][a-z0-9_]{0,31}$/.test(claims.role)) groups.push(`role:${claims.role}`);
+ return { kind: 'user', subject, groups, entitlements: [] };
+ }
+
+ /**
+ * Who is asking a query. Identity never comes from a body or query string.
+ * anonymous public only
+ * user (Network JWT, subject_id) + ACL matches on its subject and role:<role>
+ * service with search.query.delegate + X-OV-Subject (usr_/gst_), X-OV-Groups, X-OV-Entitlements
+ * vouched for by the calling first-party service
+ * A presented Bearer must verify (never downgraded to anonymous); an unverifiable cookie is
+ * treated as signed out.
+ */
+ function viewer(req) {
+ const token = bearer(req);
+ if (token) {
+ const payload = decodePayload(token);
+ if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
+ const r = verifyService(token);
+ if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
+ const c = checkCapability(r.claims, CAPS.delegate);
+ if (!c.allowed) throw new AuthError(403, c.code, c.reason);
+ const subjectHeader = req.get('x-ov-subject');
+ if (!subjectHeader) {
+ if (req.get('x-ov-groups') || req.get('x-ov-entitlements')) {
+ throw new AuthError(400, 'search.bad_viewer', 'X-OV-Groups and X-OV-Entitlements need an X-OV-Subject');
+ }
+ return { ...ANONYMOUS, kind: 'service', service: r.claims.sub };
+ }
+ if (!ids.isSubjectId('user', subjectHeader) && !ids.isSubjectId('guest', subjectHeader)) {
+ throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
+ }
+ return {
+ kind: 'service',
+ service: r.claims.sub,
+ subject: subjectHeader,
+ groups: keyList(req.get('x-ov-groups'), 'X-OV-Groups'),
+ entitlements: keyList(req.get('x-ov-entitlements'), 'X-OV-Entitlements'),
+ };
+ }
+ if (!keys.get()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
+ const user = verifyUser(token);
+ if (!user) throw new AuthError(401, 'token.invalid', 'token does not verify');
+ return userViewer(user);
+ }
+ const fromCookie = cookie(req, 'ov_token');
+ const user = fromCookie ? verifyUser(fromCookie) : null;
+ return user ? userViewer(user) : ANONYMOUS;
+ }
+
+ return { verifyService, verifyUser, requireCap, viewer };
+}
+
+module.exports = { CAPS, PROPOSED, ANONYMOUS, AuthError, createKeyStore, createAuth, checkCapability, verifyUserJwt, serviceSlug, bearer, cookie };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * OpenVibe.Search configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/search.env in production); see .env.example for the documented list.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+
+const int = (v, d) => {
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : d;
+};
+const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
+const strip = (v) => String(v || '').replace(/\/$/, '');
+
+/**
+ * Services whose index-document events are accepted from OpenVibe.Events. Events already
+ * guarantees an event's `source` is the service that published it; this list decides which
+ * sources may feed the index at all. The direct API is decided by the search.document.write grant.
+ */
+const DEFAULT_EVENT_OWNERS = ['wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade',
+ 'community', 'live', 'media', 'codes', 'games', 'tools', 'sources'];
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4710);
+ return {
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ serviceId: 'search',
+ baseUrl: strip(env.BASE_URL || (isProduction ? 'https://search.openvibe.network' : `http://localhost:${port}`)),
+
+ // Identity: OpenVibe.Network signs service tokens (audience openvibe.search) and user JWTs.
+ networkUrl: strip(env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkInternalUrl: strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
+ audience: 'openvibe.search',
+ // A browser's user JWT is accepted when its aud contains one of these.
+ userAudiences: list(env.SEARCH_USER_AUDIENCES, ['openvibe.search', 'openvibe.network']),
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'search',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ },
+
+ dbPath: env.SEARCH_DB_PATH || './data/search.db',
+
+ // Events: the outbox relays only when EVENTS_URL is set (rows wait otherwise).
+ events: {
+ url: strip(env.EVENTS_URL || ''),
+ relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // Subscription secrets for POST /internal/events (comma-separated: rotation keeps both).
+ webhookSecrets: list(env.SEARCH_EVENTS_SECRET, []),
+ owners: list(env.SEARCH_EVENT_OWNERS, DEFAULT_EVENT_OWNERS),
+ },
+
+ query: {
+ maxLimit: 50,
+ defaultLimit: 20,
+ maxTerms: 16,
+ maxFacetFilters: 8,
+ },
+ maxBodyBytes: int(env.SEARCH_MAX_BODY_BYTES, 256 * 1024),
+ };
+}
+
+module.exports = { load, DEFAULT_EVENT_OWNERS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * The Search database (SQLite, WAL). One file owned by this service only; created on boot,
+ * idempotently. The FTS5 tables belong to the engine (server/engine/fts5.js).
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const outbox = require('./events/outbox');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS documents (
+ rid INTEGER PRIMARY KEY,
+ owner TEXT NOT NULL,
+ type TEXT NOT NULL,
+ id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ deleted INTEGER NOT NULL DEFAULT 0,
+ visibility TEXT,
+ publication_state TEXT,
+ exposure INTEGER NOT NULL DEFAULT 0, -- document.js EXPOSURE (0 none .. 3 public listed)
+ index_decision TEXT NOT NULL,
+ noindex_reasons TEXT NOT NULL DEFAULT '[]',
+ canonical_url TEXT,
+ title TEXT,
+ language TEXT,
+ published_at TEXT,
+ updated_at TEXT,
+ sort_at TEXT NOT NULL DEFAULT '', -- updated_at, else published_at: browse order
+ doc TEXT NOT NULL, -- normalized document JSON (tombstone: identity only)
+ hash TEXT NOT NULL,
+ via TEXT NOT NULL, -- event | api
+ event_id TEXT,
+ indexed_at INTEGER NOT NULL,
+ UNIQUE (owner, type, id)
+);
+CREATE INDEX IF NOT EXISTS idx_documents_exposure ON documents (exposure, sort_at, rid);
+CREATE INDEX IF NOT EXISTS idx_documents_owner ON documents (owner, type, id);
+
+-- ACL keys of servable non-public documents: kind s = subject, g = group, e = entitlement.
+CREATE TABLE IF NOT EXISTS doc_acl (
+ rid INTEGER NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('s', 'g', 'e')),
+ value TEXT NOT NULL,
+ PRIMARY KEY (rid, kind, value)
+) WITHOUT ROWID;
+CREATE INDEX IF NOT EXISTS idx_doc_acl_value ON doc_acl (kind, value, rid);
+
+CREATE TABLE IF NOT EXISTS doc_facets (
+ rid INTEGER NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY (rid, key, value)
+) WITHOUT ROWID;
+CREATE INDEX IF NOT EXISTS idx_doc_facets_value ON doc_facets (key, value, rid);
+
+-- Inbox: one receipt per consumed event (exactly-once effects), with what happened.
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer TEXT NOT NULL,
+ event_id TEXT NOT NULL,
+ outcome TEXT NOT NULL,
+ processed_at INTEGER NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- Index-document events Search refused (bad document, wrong owner, subject mismatch). Owners
+-- read their own through GET /api/v1/owners/:owner/rejections.
+CREATE TABLE IF NOT EXISTS ingest_rejections (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id TEXT NOT NULL,
+ event_type TEXT,
+ owner TEXT,
+ type TEXT,
+ id TEXT,
+ revision INTEGER,
+ code TEXT NOT NULL,
+ detail TEXT,
+ at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_ingest_rejections_owner ON ingest_rejections (owner, seq);
+`;
+
+function openDb(dbPath) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('synchronous = NORMAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ outbox.ensureSchema(db);
+ return db;
+}
+
+module.exports = { openDb };
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * The index document (docs/contracts-proposal/contracts/search/index-document.v1.json):
+ * validation, normalization, the effective indexability decision and the audience a document
+ * reaches. Pure functions; the store and the engine build on them.
+ *
+ * The schema file is the proposal for OpenVibe.Contracts (`search.index-document@1`). Once a
+ * contracts release ships it, validate() switches to contracts.validate and the file here goes.
+ */
+const crypto = require('crypto');
+const path = require('path');
+const Ajv2020 = require('ajv/dist/2020');
+const addFormats = require('ajv-formats');
+
+const SCHEMA_PATH = path.join(__dirname, '..', 'docs', 'contracts-proposal', 'contracts', 'search', 'index-document.v1.json');
+const schema = require(SCHEMA_PATH);
+const ajv = new Ajv2020({ allErrors: true, strict: false });
+addFormats(ajv);
+const validateFn = ajv.compile(schema);
+
+const VISIBILITIES = ['public', 'unlisted', 'members', 'private', 'draft'];
+
+/**
+ * How far a document reaches, lowest first. A drop in exposure is a removal (it leaves results
+ * or public delivery) and is announced with search.document.removed.
+ */
+const EXPOSURE = Object.freeze({ none: 0, restricted: 1, public_unlisted: 2, public_listed: 3 });
+
+/** → { valid: true } | { valid: false, errors: [{ path, message }] } */
+function validate(doc) {
+ const ok = validateFn(doc);
+ if (ok) return { valid: true, errors: [] };
+ return {
+ valid: false,
+ errors: (validateFn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })),
+ };
+}
+
+// C0/C1 control characters except tab/newline; they have no place in indexed text.
+// eslint-disable-next-line no-control-regex
+const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
+const clean = (s) => (typeof s === 'string' ? s.replace(CONTROL_RE, '').trim() : '');
+
+function canonicalJson(value) {
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function sha256(s) {
+ return crypto.createHash('sha256').update(s).digest('hex');
+}
+
+/** Facet values as strings (numbers/booleans stringified, arrays flattened), for exact filtering. */
+function facetPairs(facets) {
+ const out = [];
+ for (const [key, v] of Object.entries(facets || {})) {
+ const values = Array.isArray(v) ? v : [v];
+ for (const x of new Set(values.map(String))) out.push([key, x]);
+ }
+ return out;
+}
+
+/**
+ * The effective indexability: the owner's decision plus the reasons Search enforces. It can only
+ * get stricter here; nothing the owner sends can make a draft, private or stub document indexable.
+ */
+function effectiveIndexability(doc) {
+ const reasons = new Set((doc.indexability && doc.indexability.reasons) || []);
+ if (doc.deleted) reasons.add('deleted');
+ else {
+ if (doc.visibility === 'draft') reasons.add('draft');
+ if (doc.visibility === 'private') reasons.add('private');
+ if (doc.visibility === 'members') reasons.add('members_only');
+ if (doc.visibility === 'unlisted') reasons.add('unlisted');
+ if (doc.publication_state !== 'published') reasons.add('not_published');
+ if ((doc.provenance || []).some(p => p.stub === true)) reasons.add('stub_provider');
+ if (doc.visibility === 'public' && !doc.canonical_url) reasons.add('missing_canonical_url');
+ }
+ const ownerSaysIndex = Boolean(doc.indexability && doc.indexability.decision === 'index');
+ if (!ownerSaysIndex && !reasons.size) reasons.add('owner_decision');
+ const decision = ownerSaysIndex && reasons.size === 0 ? 'index' : 'noindex';
+ return { decision, reasons: [...reasons].sort() };
+}
+
+/** Is the document served to anyone at all? Drafts, unpublished and deleted documents never are. */
+function servable(doc) {
+ return !doc.deleted && doc.visibility !== 'draft' && doc.publication_state === 'published';
+}
+
+function exposureOf(doc) {
+ if (!doc || !servable(doc)) return EXPOSURE.none;
+ if (doc.visibility !== 'public') return EXPOSURE.restricted;
+ return effectiveIndexability(doc).decision === 'index' ? EXPOSURE.public_listed : EXPOSURE.public_unlisted;
+}
+
+/**
+ * Normalize a valid document into what the store keeps. A tombstone keeps only its identity.
+ * Returns { doc, hash } where hash covers every stored field (same revision + same hash = replay).
+ */
+function normalize(input) {
+ const base = { owner: input.owner, type: input.type, id: input.id, revision: input.revision };
+ if (input.deleted === true) {
+ const doc = { ...base, deleted: true };
+ return { doc, hash: sha256(canonicalJson(doc)) };
+ }
+ const acl = input.acl || {};
+ const doc = {
+ ...base,
+ deleted: false,
+ visibility: input.visibility,
+ acl: {
+ subjects: [...new Set(acl.subjects || [])].sort(),
+ groups: [...new Set(acl.groups || [])].sort(),
+ entitlements: [...new Set(acl.entitlements || [])].sort(),
+ },
+ canonical_url: input.canonical_url || null,
+ title: clean(input.title),
+ summary: clean(input.summary),
+ body: clean(input.body),
+ facets: input.facets || {},
+ language: input.language || null,
+ authorship: input.authorship || null,
+ provenance: input.provenance || [],
+ publication_state: input.publication_state,
+ published_at: input.published_at || null,
+ updated_at: input.updated_at || null,
+ indexability: {
+ decision: input.indexability.decision,
+ reasons: [...new Set(input.indexability.reasons || [])].sort(),
+ },
+ };
+ return { doc, hash: sha256(canonicalJson(doc)) };
+}
+
+module.exports = {
+ schema, SCHEMA_PATH, VISIBILITIES, EXPOSURE,
+ validate, normalize, effectiveIndexability, servable, exposureOf, facetPairs, canonicalJson, sha256,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+'use strict';
+/**
+ * Search engine: SQLite FTS5 in the service's own database (decision: docs/adr-engine.md).
+ *
+ * Two full-text tables, split by audience so an anonymous query never touches, ranks against or
+ * counts anything but public listed documents:
+ *
+ * fts_public documents with exposure public_listed (public, published, indexable)
+ * fts_restricted documents with exposure restricted (unlisted/members/private, published)
+ *
+ * Drafts, unpublished, deleted and public-noindex documents are in neither, so no MATCH can
+ * reach them. Every query is also filtered by `documents.exposure` and, for restricted rows, by
+ * the viewer's ACL keys (doc_acl) in the same SQL: visibility is decided before anything is
+ * ranked, counted, snippeted or suggested.
+ *
+ * The interface (put/remove/search/browse/facetCounts/suggest) is what a PostgreSQL FTS engine
+ * would implement; nothing outside this file writes SQL against the FTS tables.
+ */
+const { EXPOSURE } = require('../document');
+
+const TOKENIZE = "tokenize = 'unicode61 remove_diacritics 2'";
+const WEIGHTS = '10.0, 4.0, 1.0'; // title, summary, body
+const MARK_OPEN = '\u0001';
+const MARK_CLOSE = '\u0002';
+
+function createEngine(db) {
+ db.exec(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_public USING fts5(title, summary, body, ${TOKENIZE});
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_restricted USING fts5(title, summary, body, ${TOKENIZE});
+ `);
+ const st = {
+ delPublic: db.prepare('DELETE FROM fts_public WHERE rowid = ?'),
+ delRestricted: db.prepare('DELETE FROM fts_restricted WHERE rowid = ?'),
+ insPublic: db.prepare('INSERT INTO fts_public (rowid, title, summary, body) VALUES (?, ?, ?, ?)'),
+ insRestricted: db.prepare('INSERT INTO fts_restricted (rowid, title, summary, body) VALUES (?, ?, ?, ?)'),
+ };
+
+ /** (Re)index one document according to its exposure. Call inside the store's transaction. */
+ function put(rid, doc, exposure) {
+ remove(rid);
+ if (exposure === EXPOSURE.public_listed) st.insPublic.run(rid, doc.title || '', doc.summary || '', doc.body || '');
+ else if (exposure === EXPOSURE.restricted) st.insRestricted.run(rid, doc.title || '', doc.summary || '', doc.body || '');
+ }
+
+ function remove(rid) {
+ st.delPublic.run(rid);
+ st.delRestricted.run(rid);
+ }
+
+ // ── SQL building ─────────────────────────────────────────
+
+ /**
+ * The ACL predicate for restricted rows (alias d). private: the viewer's subject is in
+ * acl.subjects. members/unlisted: subject, group or entitlement match. Returns null when the
+ * viewer has no subject (then the restricted branch is not queried at all).
+ */
+ function aclClause(viewer, params) {
+ if (!viewer || !viewer.subject) return null;
+ params.v_subject = viewer.subject;
+ params.v_groups = JSON.stringify(viewer.groups || []);
+ params.v_ents = JSON.stringify(viewer.entitlements || []);
+ return `(
+ (d.visibility = 'private' AND EXISTS (SELECT 1 FROM doc_acl a WHERE a.rid = d.rid AND a.kind = 's' AND a.value = @v_subject))
+ OR (d.visibility IN ('members', 'unlisted') AND EXISTS (SELECT 1 FROM doc_acl a WHERE a.rid = d.rid AND (
+ (a.kind = 's' AND a.value = @v_subject)
+ OR (a.kind = 'g' AND a.value IN (SELECT value FROM json_each(@v_groups)))
+ OR (a.kind = 'e' AND a.value IN (SELECT value FROM json_each(@v_ents))))))
+ )`;
+ }
+
+ function filterClause(filters, params) {
+ const parts = [];
+ if (filters.owner) { parts.push('d.owner = @f_owner'); params.f_owner = filters.owner; }
+ if (filters.type) { parts.push('d.type = @f_type'); params.f_type = filters.type; }
+ if (filters.language) { parts.push('d.language = @f_lang'); params.f_lang = filters.language; }
+ (filters.facets || []).forEach(([key, values], i) => {
+ params[`f_fk${i}`] = key;
+ params[`f_fv${i}`] = JSON.stringify(values);
+ parts.push(`EXISTS (SELECT 1 FROM doc_facets x WHERE x.rid = d.rid AND x.key = @f_fk${i} AND x.value IN (SELECT value FROM json_each(@f_fv${i})))`);
+ });
+ return parts.length ? ` AND ${parts.join(' AND ')}` : '';
+ }
+
+ /** The visible, matching set as (rid, rank, snip) rows. match = FTS5 expression. */
+ function matchedSet({ match, filters, viewer, snippets = true }, params) {
+ params.match = match;
+ const where = filterClause(filters, params);
+ const snip = (t) => (snippets ? `snippet(${t}, -1, char(1), char(2), '…', 16)` : 'NULL');
+ const branches = [`SELECT d.rid AS rid, bm25(fts_public, ${WEIGHTS}) AS rank, ${snip('fts_public')} AS snip
+ FROM fts_public JOIN documents d ON d.rid = fts_public.rowid
+ WHERE fts_public MATCH @match AND d.exposure = ${EXPOSURE.public_listed} AND d.deleted = 0${where}`];
+ const acl = aclClause(viewer, params);
+ if (acl) {
+ branches.push(`SELECT d.rid AS rid, bm25(fts_restricted, ${WEIGHTS}) AS rank, ${snip('fts_restricted')} AS snip
+ FROM fts_restricted JOIN documents d ON d.rid = fts_restricted.rowid
+ WHERE fts_restricted MATCH @match AND d.exposure = ${EXPOSURE.restricted} AND d.deleted = 0${where} AND ${acl}`);
+ }
+ return branches.join('\nUNION ALL\n');
+ }
+
+ /** The visible set without a text query (browse). */
+ function visibleSet({ filters, viewer }, params) {
+ const where = filterClause(filters, params);
+ const acl = aclClause(viewer, params);
+ const vis = acl
+ ? `(d.exposure = ${EXPOSURE.public_listed} OR (d.exposure = ${EXPOSURE.restricted} AND ${acl}))`
+ : `d.exposure = ${EXPOSURE.public_listed}`;
+ return `SELECT d.rid AS rid, d.sort_at AS sort_at FROM documents d WHERE d.deleted = 0 AND ${vis}${where}`;
+ }
+
+ // ── Queries ──────────────────────────────────────────────
+
+ /** Ranked full-text search. after = { rank, rid } keyset cursor. */
+ function search({ match, filters = {}, viewer, limit, after }) {
+ const params = { limit: limit + 1 };
+ let sql = `SELECT rid, rank, snip FROM (${matchedSet({ match, filters, viewer }, params)})`;
+ if (after) {
+ sql += ' WHERE (rank > @c_rank OR (rank = @c_rank AND rid > @c_rid))';
+ params.c_rank = after.rank;
+ params.c_rid = after.rid;
+ }
+ sql += ' ORDER BY rank, rid LIMIT @limit';
+ const rows = db.prepare(sql).all(params);
+ const more = rows.length > limit;
+ const hits = rows.slice(0, limit);
+ const last = hits[hits.length - 1];
+ return { hits, next: more && last ? { rank: last.rank, rid: last.rid } : null };
+ }
+
+ /** Newest first, no text query. after = { sort_at, rid }. */
+ function browse({ filters = {}, viewer, limit, after }) {
+ const params = { limit: limit + 1 };
+ let sql = `SELECT rid, sort_at FROM (${visibleSet({ filters, viewer }, params)})`;
+ if (after) {
+ sql += ' WHERE (sort_at < @c_sort OR (sort_at = @c_sort AND rid < @c_rid))';
+ params.c_sort = after.sort_at;
+ params.c_rid = after.rid;
+ }
+ sql += ' ORDER BY sort_at DESC, rid DESC LIMIT @limit';
+ const rows = db.prepare(sql).all(params);
+ const more = rows.length > limit;
+ const hits = rows.slice(0, limit).map(r => ({ rid: r.rid, rank: null, snip: null, sort_at: r.sort_at }));
+ const last = hits[hits.length - 1];
+ return { hits, next: more && last ? { sort_at: last.sort_at, rid: last.rid } : null };
+ }
+
+ /**
+ * Value counts for facet keys over exactly the visible matching set (same SQL as the hits),
+ * so a count can never include a document the viewer cannot see.
+ */
+ function facetCounts({ match, filters = {}, viewer, keys, perKey = 20 }) {
+ const out = {};
+ for (const key of keys) {
+ const params = { fkey: key, per: perKey };
+ const set = match ? matchedSet({ match, filters, viewer, snippets: false }, params) : visibleSet({ filters, viewer }, params);
+ const rows = db.prepare(`SELECT f.value AS value, COUNT(*) AS count FROM doc_facets f
+ WHERE f.key = @fkey AND f.rid IN (SELECT rid FROM (${set}))
+ GROUP BY f.value ORDER BY count DESC, value LIMIT @per`).all(params);
+ out[key] = rows;
+ }
+ return out;
+ }
+
+ /** Title prefix suggestions over the visible set. */
+ function suggest({ match, filters = {}, viewer, limit }) {
+ const params = { limit };
+ const sql = `SELECT rid, rank FROM (${matchedSet({ match, filters, viewer, snippets: false }, params)}) ORDER BY rank, rid LIMIT @limit`;
+ return db.prepare(sql).all(params);
+ }
+
+ function counts() {
+ return {
+ public_listed: db.prepare('SELECT COUNT(*) AS n FROM fts_public').get().n,
+ restricted: db.prepare('SELECT COUNT(*) AS n FROM fts_restricted').get().n,
+ };
+ }
+
+ return { name: 'sqlite-fts5', put, remove, search, browse, facetCounts, suggest, counts };
+}
+
+// ── Query text → FTS5 expression ─────────────────────────────
+
+/**
+ * Turns user text into a safe FTS5 expression: word tokens only, each quoted (no operators,
+ * column filters or syntax errors reach FTS5), all required. A trailing `*` on the last word
+ * makes it a prefix. Returns null when nothing searchable is left.
+ */
+function toMatch(q, { maxTerms = 16, column = null, prefixLast = false } = {}) {
+ const text = String(q || '').normalize('NFKC');
+ const words = (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) || []).slice(0, maxTerms);
+ if (!words.length) return null;
+ const wantsPrefix = prefixLast || /[\p{L}\p{N}_]\*\s*$/u.test(text);
+ const parts = words.map((w, i) => `"${w}"${wantsPrefix && i === words.length - 1 ? '*' : ''}`);
+ const expr = parts.join(' ');
+ return column ? `{${column}} : (${expr})` : expr;
+}
+
+/** Plain text with the engine's snippet marks → HTML with <mark>, everything else escaped. */
+function snippetHtml(snip) {
+ if (!snip) return null;
+ const esc = String(snip).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ return esc.split(MARK_OPEN).join('<mark>').split(MARK_CLOSE).join('</mark>');
+}
+
+module.exports = { createEngine, toMatch, snippetHtml };
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+/**
+ * Transactional outbox (events.event-envelope@1), OpenVibe.Events client semantics:
+ *
+ * enqueue(envelope) INSIDE the transaction that makes the change, so an event exists exactly
+ * when its effect does (throws outside a transaction)
+ * relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service
+ * token for audience openvibe.events with events.event.publish), marks them
+ * sent, backs off on failure; a permanently refused row (4xx other than
+ * 401/408/429) is marked rejected so it cannot block the rest
+ *
+ * The relay runs only when EVENTS_URL is set; without it rows wait and are relayed later. Events
+ * deduplicates on event_id, so a retry after a lost response never publishes twice.
+ */
+const { ids, validate, serviceAuth } = require('openvibe-contracts');
+
+const BACKOFF_MS = [1000, 5000, 30000, 120000, 600000];
+
+function ensureSchema(db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS event_outbox (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id TEXT NOT NULL UNIQUE,
+ event_type TEXT NOT NULL,
+ envelope TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
+ sent_at INTEGER,
+ rejected_at INTEGER,
+ last_error TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);`);
+}
+
+function createOutbox(db, { source, now = () => Date.now() }) {
+ const insert = db.prepare('INSERT INTO event_outbox (event_id, event_type, envelope, created_at) VALUES (?, ?, ?, ?)');
+
+ /** { event_type, subject, payload, visibility?, priority?, actor?, trace_id? } → envelope */
+ function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actor, trace_id }) {
+ if (!db.inTransaction) throw new Error('outbox.enqueue() must run inside the transaction that makes the change');
+ const ms = now();
+ const env = {
+ event_id: ids.newId('event', ms),
+ event_type,
+ version: 1,
+ source,
+ actor: actor || { type: 'service', id: source },
+ timestamp: new Date(ms).toISOString(),
+ priority,
+ visibility,
+ subject,
+ payload: payload || {},
+ };
+ if (trace_id && /^[0-9a-f]{32}$/.test(trace_id)) env.trace_id = trace_id;
+ const v = validate('events.event-envelope@1', env);
+ if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ insert.run(env.event_id, event_type, JSON.stringify(env), ms);
+ return env;
+ }
+
+ const q = {
+ due: db.prepare('SELECT seq, event_id, envelope, attempts FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY seq LIMIT ?'),
+ sent: db.prepare('UPDATE event_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?'),
+ failed: db.prepare('UPDATE event_outbox SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE seq = ?'),
+ rejected: db.prepare('UPDATE event_outbox SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE seq = ?'),
+ pending: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL'),
+ rejectedCount: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE rejected_at IS NOT NULL'),
+ list: db.prepare('SELECT envelope FROM event_outbox ORDER BY seq'),
+ prune: db.prepare('DELETE FROM event_outbox WHERE sent_at IS NOT NULL AND sent_at < ?'),
+ };
+
+ return {
+ enqueue,
+ pending: () => q.pending.get().n,
+ rejected: () => q.rejectedCount.get().n,
+ /** Every envelope in order (tests and operators). */
+ all: () => q.list.all().map(r => JSON.parse(r.envelope)),
+ prune: (olderThanMs = 7 * 24 * 3600 * 1000) => q.prune.run(now() - olderThanMs).changes,
+ _q: q,
+ };
+}
+
+/**
+ * createRelay({ db, outbox, eventsUrl, intervalMs?, tokenClient? | tokenOpts?, fetchImpl?, log?, now? })
+ * → { start, stop, flush, running }
+ * tokenOpts = { tokenUrl, clientId, clientSecret } builds a client-credentials token client for
+ * audience openvibe.events. Without eventsUrl or a token source the relay does nothing.
+ */
+function createRelay({ outbox, eventsUrl, intervalMs = 2000, tokenClient, tokenOpts, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), db }) {
+ const q = outbox._q;
+ const tokens = tokenClient || (tokenOpts && serviceAuth.createTokenClient({ ...tokenOpts, audience: 'openvibe.events', scope: 'events.event.publish', fetchImpl }));
+ let timer = null;
+ let flushing = null;
+
+ function permanent(status) {
+ return status >= 400 && status < 500 && ![401, 408, 429].includes(status);
+ }
+
+ async function post(rows) {
+ const body = rows.length === 1 ? JSON.parse(rows[0].envelope) : { events: rows.map(r => JSON.parse(r.envelope)) };
+ const res = await fetchImpl(`${eventsUrl}/api/v1/events`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 401 && typeof tokens.invalidate === 'function') tokens.invalidate();
+ const text = await res.text().catch(() => '');
+ return { ok: res.ok, status: res.status, text };
+ }
+
+ function mark(rows, fn) { db.transaction(() => { for (const r of rows) fn(r); })(); }
+
+ async function publishRows(rows) {
+ let r;
+ try {
+ r = await post(rows);
+ } catch (err) {
+ mark(rows, row => q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err.message).slice(0, 500), row.seq));
+ return { sent: 0, failed: rows.length, rejected: 0 };
+ }
+ if (r.ok) {
+ mark(rows, row => q.sent.run(now(), row.seq));
+ return { sent: rows.length, failed: 0, rejected: 0 };
+ }
+ if (permanent(r.status) && rows.length > 1) {
+ // One bad envelope refuses the batch: isolate it.
+ const total = { sent: 0, failed: 0, rejected: 0 };
+ for (const row of rows) {
+ const s = await publishRows([row]);
+ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
+ }
+ return total;
+ }
+ const msg = `${r.status} ${r.text.slice(0, 300)}`;
+ if (permanent(r.status)) {
+ mark(rows, row => q.rejected.run(now(), msg, row.seq));
+ log.warn(`[outbox] event refused by Events: ${msg}`);
+ return { sent: 0, failed: 0, rejected: rows.length };
+ }
+ mark(rows, row => q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], msg, row.seq));
+ return { sent: 0, failed: rows.length, rejected: 0 };
+ }
+
+ async function doFlush() {
+ const total = { sent: 0, failed: 0, rejected: 0 };
+ if (!eventsUrl || !tokens) return total;
+ for (;;) {
+ const rows = q.due.all(now(), 50);
+ if (!rows.length) break;
+ const s = await publishRows(rows);
+ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
+ if (s.failed || rows.length < 50) break;
+ }
+ if (total.failed) log.warn(`[outbox] ${total.failed} event(s) not published yet; will retry`);
+ return total;
+ }
+
+ function flush() {
+ if (!flushing) flushing = doFlush().finally(() => { flushing = null; });
+ return flushing;
+ }
+
+ function start() {
+ if (timer || !eventsUrl) return;
+ timer = setInterval(() => { flush().catch(err => log.warn(`[outbox] relay: ${err.message}`)); }, intervalMs);
+ timer.unref?.();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ return flushing || Promise.resolve();
+ }
+
+ return { start, stop, flush, running: () => Boolean(timer) };
+}
+
+module.exports = { ensureSchema, createOutbox, createRelay };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * OpenVibe.Search entry point.
+ *
+ * node server/index.js (systemd: openvibe-search.service)
+ *
+ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
+ * clock/fetch/log, and returns handles to every part.
+ */
+const { load } = require('./config');
+const { openDb } = require('./db');
+const { createEngine } = require('./engine/fts5');
+const { createStore } = require('./store');
+const { createOutbox, createRelay } = require('./events/outbox');
+const { createKeyStore, createAuth } = require('./auth');
+const { createApp } = require('./app');
+
+async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, log = console, listen = true } = {}) {
+ config = config || load();
+ const db = openDb(config.dbPath);
+ const engine = createEngine(db);
+ const outbox = createOutbox(db, { source: config.serviceId, now });
+ const store = createStore({ db, engine, outbox, now });
+ const relay = createRelay({
+ db, outbox, eventsUrl: config.events.url, intervalMs: config.events.relayIntervalMs, fetchImpl, log, now,
+ tokenClient,
+ tokenOpts: config.oauth.clientSecret ? { tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret } : null,
+ });
+ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
+ const auth = createAuth({ config, keys });
+ const app = createApp({ config, db, store, engine, auth, keys, outbox, relay, log, now });
+
+ const keyLoaded = keys.start().catch(() => null);
+ relay.start();
+ const pruneTimer = setInterval(() => { try { outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
+ pruneTimer.unref?.();
+
+ let server = null;
+ if (listen) {
+ server = await new Promise((resolve, reject) => {
+ const s = app.listen(config.port, config.host, () => resolve(s));
+ s.on('error', reject);
+ });
+ log.log(`[search] listening on http://${config.host}:${server.address().port} (engine ${engine.name})`);
+ }
+
+ async function close() {
+ clearInterval(pruneTimer);
+ keys.stop();
+ await relay.stop();
+ if (server) {
+ server.closeAllConnections?.();
+ await new Promise(resolve => server.close(() => resolve()));
+ }
+ db.close();
+ }
+
+ return { config, db, engine, store, outbox, relay, keys, keyLoaded, auth, app, server, close };
+}
+
+if (require.main === module) {
+ require('dotenv').config();
+ start().then((handles) => {
+ const shutdown = (sig) => {
+ console.log(`[search] ${sig}: shutting down`);
+ handles.close().then(() => process.exit(0), () => process.exit(1));
+ setTimeout(() => process.exit(1), 10000).unref();
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ }).catch((err) => {
+ console.error(`[search] failed to start: ${err.stack || err}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { start };
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+'use strict';
+/**
+ * The document store: revision ordering, tombstones, ACL and facet rows, and the removal /
+ * indexed events, all in one transaction with the engine update.
+ *
+ * Ordering rule (both ingestion paths):
+ * - an incoming revision lower than the stored one is stale and changes nothing;
+ * - at the same revision a deletion wins: a tombstone replaces a document, an upsert never
+ * replaces a tombstone; the same content again is `unchanged`; different content with the
+ * same revision is a `conflict` (first write wins);
+ * - a higher revision replaces whatever is stored, including a tombstone (restore).
+ * A tombstone keeps only the identity and revision, so a late, older upsert is still refused.
+ */
+const { normalize, effectiveIndexability, exposureOf, facetPairs, EXPOSURE } = require('./document');
+
+const EXPOSURE_NAME = Object.fromEntries(Object.entries(EXPOSURE).map(([k, v]) => [v, k]));
+
+function createStore({ db, engine, outbox, now = () => Date.now() }) {
+ const st = {
+ get: db.prepare('SELECT * FROM documents WHERE owner = ? AND type = ? AND id = ?'),
+ byRid: db.prepare('SELECT * FROM documents WHERE rid = ?'),
+ insert: db.prepare(`INSERT INTO documents (owner, type, id, revision, deleted, visibility, publication_state, exposure,
+ index_decision, noindex_reasons, canonical_url, title, language, published_at, updated_at, sort_at, doc, hash, via, event_id, indexed_at)
+ VALUES (@owner, @type, @id, @revision, @deleted, @visibility, @publication_state, @exposure,
+ @index_decision, @noindex_reasons, @canonical_url, @title, @language, @published_at, @updated_at, @sort_at, @doc, @hash, @via, @event_id, @indexed_at)`),
+ update: db.prepare(`UPDATE documents SET revision = @revision, deleted = @deleted, visibility = @visibility,
+ publication_state = @publication_state, exposure = @exposure, index_decision = @index_decision,
+ noindex_reasons = @noindex_reasons, canonical_url = @canonical_url, title = @title, language = @language,
+ published_at = @published_at, updated_at = @updated_at, sort_at = @sort_at, doc = @doc, hash = @hash,
+ via = @via, event_id = @event_id, indexed_at = @indexed_at WHERE rid = @rid`),
+ delAcl: db.prepare('DELETE FROM doc_acl WHERE rid = ?'),
+ insAcl: db.prepare('INSERT OR IGNORE INTO doc_acl (rid, kind, value) VALUES (?, ?, ?)'),
+ delFacets: db.prepare('DELETE FROM doc_facets WHERE rid = ?'),
+ insFacet: db.prepare('INSERT OR IGNORE INTO doc_facets (rid, key, value) VALUES (?, ?, ?)'),
+ ownerPage: db.prepare(`SELECT rid, type, id, revision, deleted, hash, exposure, indexed_at FROM documents
+ WHERE owner = @owner AND (@type IS NULL OR type = @type) AND rid > @after ORDER BY rid LIMIT @limit`),
+ counts: db.prepare(`SELECT COUNT(*) AS total, SUM(deleted) AS tombstones,
+ SUM(CASE WHEN exposure = 3 THEN 1 ELSE 0 END) AS public_listed,
+ SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,
+ SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),
+ };
+
+ function rowValues(doc, hash, { via, eventId }) {
+ const idx = effectiveIndexability(doc);
+ return {
+ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
+ deleted: doc.deleted ? 1 : 0,
+ visibility: doc.deleted ? null : doc.visibility,
+ publication_state: doc.deleted ? null : doc.publication_state,
+ exposure: exposureOf(doc),
+ index_decision: idx.decision,
+ noindex_reasons: JSON.stringify(idx.reasons),
+ canonical_url: doc.deleted ? null : doc.canonical_url,
+ title: doc.deleted ? null : doc.title,
+ language: doc.deleted ? null : doc.language,
+ published_at: doc.deleted ? null : doc.published_at,
+ updated_at: doc.deleted ? null : doc.updated_at,
+ sort_at: doc.deleted ? '' : (doc.updated_at || doc.published_at || ''),
+ doc: JSON.stringify(doc),
+ hash,
+ via,
+ event_id: eventId || null,
+ indexed_at: now(),
+ };
+ }
+
+ function writeSideRows(rid, doc, exposure) {
+ st.delAcl.run(rid);
+ st.delFacets.run(rid);
+ if (exposure === EXPOSURE.none) return;
+ if (exposure === EXPOSURE.restricted) {
+ for (const s of doc.acl.subjects) st.insAcl.run(rid, 's', s);
+ for (const g of doc.acl.groups) st.insAcl.run(rid, 'g', g);
+ for (const e of doc.acl.entitlements) st.insAcl.run(rid, 'e', e);
+ }
+ for (const [k, v] of facetPairs(doc.facets)) st.insFacet.run(rid, k, v);
+ }
+
+ function removalReason(prev, doc) {
+ if (doc.deleted) return 'deleted';
+ if (doc.visibility === 'draft') return 'draft';
+ if (doc.publication_state !== 'published') return 'not_published';
+ if (prev && prev.visibility !== doc.visibility) return 'visibility_changed';
+ return 'noindex';
+ }
+
+ function announce(prevRow, prevDoc, doc, exposure, traceId) {
+ const prevExposure = prevRow ? prevRow.exposure : EXPOSURE.none;
+ const subject = { type: 'document', id: `${doc.owner}/${doc.type}/${doc.id}`, revision: doc.revision };
+ const wasPublic = prevExposure >= EXPOSURE.public_unlisted;
+ if (exposure < prevExposure) {
+ outbox.enqueue({
+ event_type: 'search.document.removed',
+ subject,
+ trace_id: traceId,
+ payload: {
+ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
+ reason: removalReason(prevDoc, doc),
+ previous_exposure: EXPOSURE_NAME[prevExposure],
+ exposure: EXPOSURE_NAME[exposure],
+ // Only a URL that was public already: caches and sitemaps purge it.
+ canonical_url: wasPublic ? prevRow.canonical_url : null,
+ },
+ });
+ }
+ if (!doc.deleted && exposure > EXPOSURE.none) {
+ outbox.enqueue({
+ event_type: 'search.document.indexed',
+ subject,
+ trace_id: traceId,
+ payload: {
+ owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
+ exposure: EXPOSURE_NAME[exposure],
+ reindexed: Boolean(prevRow),
+ canonical_url: exposure >= EXPOSURE.public_unlisted ? doc.canonical_url : null,
+ },
+ });
+ }
+ }
+
+ /**
+ * Apply a valid index document (or tombstone). Returns
+ * { outcome: 'applied'|'unchanged'|'stale'|'conflict', revision, stored_revision, deleted }
+ * Must be called inside a transaction when combined with other writes (inbox); it opens its
+ * own otherwise.
+ */
+ function apply(input, { via = 'api', eventId = null, traceId = null } = {}) {
+ const run = () => {
+ const { doc, hash } = normalize(input);
+ const cur = st.get.get(doc.owner, doc.type, doc.id);
+ if (cur) {
+ const base = { revision: doc.revision, stored_revision: cur.revision, deleted: Boolean(cur.deleted) };
+ if (doc.revision < cur.revision) return { outcome: 'stale', ...base };
+ if (doc.revision === cur.revision) {
+ if (cur.deleted && !doc.deleted) return { outcome: 'stale', ...base };
+ if (cur.hash === hash) return { outcome: 'unchanged', ...base };
+ if (!(doc.deleted && !cur.deleted)) return { outcome: 'conflict', ...base };
+ }
+ }
+ const values = rowValues(doc, hash, { via, eventId });
+ let rid;
+ if (cur) {
+ rid = cur.rid;
+ st.update.run({ ...values, rid });
+ } else {
+ rid = Number(st.insert.run(values).lastInsertRowid);
+ }
+ writeSideRows(rid, doc, values.exposure);
+ engine.put(rid, doc, values.exposure);
+ const prevDoc = cur ? JSON.parse(cur.doc) : null;
+ announce(cur, prevDoc, doc, values.exposure, traceId);
+ return { outcome: 'applied', revision: doc.revision, stored_revision: cur ? cur.revision : null, deleted: doc.deleted };
+ };
+ return db.inTransaction ? run() : db.transaction(run)();
+ }
+
+ /**
+ * Tombstone a document. revision omitted = the stored revision (a deletion wins the tie), or
+ * 0 for a document Search has never seen (so a late older upsert is still refused).
+ */
+ function remove(owner, type, id, { revision = null, via = 'api', eventId = null, traceId = null } = {}) {
+ const run = () => {
+ const cur = st.get.get(owner, type, id);
+ const rev = revision != null ? revision : (cur ? cur.revision : 0);
+ return apply({ owner, type, id, revision: rev, deleted: true }, { via, eventId, traceId });
+ };
+ return db.inTransaction ? run() : db.transaction(run)();
+ }
+
+ function get(owner, type, id) {
+ const row = st.get.get(owner, type, id);
+ return row ? { row, doc: JSON.parse(row.doc) } : null;
+ }
+
+ function byRid(rid) {
+ const row = st.byRid.get(rid);
+ return row ? { row, doc: JSON.parse(row.doc) } : null;
+ }
+
+ /** Reconciliation page for an owner: (type, id, revision, deleted, hash) by insertion order. */
+ function ownerPage(owner, { type = null, after = 0, limit = 500 } = {}) {
+ const rows = st.ownerPage.all({ owner, type, after, limit: limit + 1 });
+ const more = rows.length > limit;
+ const page = rows.slice(0, limit);
+ return {
+ documents: page.map(r => ({
+ type: r.type, id: r.id, revision: r.revision, deleted: Boolean(r.deleted),
+ exposure: EXPOSURE_NAME[r.exposure], hash: r.hash, indexed_at: new Date(r.indexed_at).toISOString(),
+ })),
+ next_after: more && page.length ? page[page.length - 1].rid : null,
+ };
+ }
+
+ function counts() {
+ const c = st.counts.get();
+ return {
+ total: c.total || 0, tombstones: c.tombstones || 0, public_listed: c.public_listed || 0,
+ public_unlisted: c.public_unlisted || 0, restricted: c.restricted || 0,
+ };
+ }
+
+ return { apply, remove, get, byRid, ownerPage, counts, EXPOSURE_NAME };
+}
+
+module.exports = { createStore, EXPOSURE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
+'use strict';
+/**
+ * Adversarial visibility tests. Every non-public document carries the same rare word, so any
+ * leak — a hit, a snippet, a facet count, a suggestion, a direct get, a cursor — shows up.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, request, serviceToken, userToken, doc, suite } = require('./helpers');
+
+const t = suite('acl-leak');
+let svc;
+const WIKI = serviceToken('wiki', ['search.document.write']);
+const DELEGATE = serviceToken('blog', ['search.query.delegate']);
+
+const alice = ids.newId('user'); // named in ACLs
+const mallory = ids.newId('user'); // named nowhere
+const guest = ids.newId('guest');
+
+const put = (d) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token: WIKI, body: d });
+const get = (p, opts = {}) => request(svc.base, 'GET', p, opts);
+const q = (query, opts) => get(`/api/v1/search?${query}`, opts);
+const asUser = (subjectId, role) => ({ token: userToken({ subjectId, role }) });
+const asDelegate = (headers = {}) => ({ token: DELEGATE, headers });
+
+// the corpus
+const SECRET = 'quuxsecret';
+const D = {
+ public: doc({ id: 'pub', title: `Public ${SECRET} page`, facets: { category: 'open', tags: ['shared'] } }),
+ publicNoindex: doc({ id: 'pubnoidx', title: `Public noindex ${SECRET}`, indexability: { decision: 'noindex', reasons: ['thin_content'] }, facets: { category: 'thin' } }),
+ unlisted: doc({ id: 'unl', visibility: 'unlisted', acl: { subjects: [alice] }, title: `Unlisted ${SECRET}`, facets: { category: 'hiddencat-unlisted' } }),
+ unlistedNoAcl: doc({ id: 'unl2', visibility: 'unlisted', title: `Unlisted no acl ${SECRET}`, facets: { category: 'hiddencat-unlisted2' } }),
+ members: doc({ id: 'mem', visibility: 'members', acl: { groups: ['wiki.space:spc_1:member'], entitlements: ['vip.plan:gold'] }, title: `Members ${SECRET}`, facets: { category: 'hiddencat-members' } }),
+ private: doc({ id: 'prv', visibility: 'private', acl: { subjects: [alice], groups: ['role:admin'] }, title: `Private ${SECRET} diary`, body: `private body ${SECRET} with lots of words`, facets: { category: 'hiddencat-private', tags: ['shared'] } }),
+ draft: doc({ id: 'drf', visibility: 'draft', acl: { subjects: [alice] }, title: `Draft ${SECRET}`, facets: { category: 'hiddencat-draft' } }),
+ unpublished: doc({ id: 'unp', visibility: 'private', acl: { subjects: [alice] }, publication_state: 'draft', title: `Unpublished ${SECRET}`, facets: { category: 'hiddencat-unpub' } }),
+ retracted: doc({ id: 'ret', publication_state: 'retracted', title: `Retracted ${SECRET}`, facets: { category: 'hiddencat-retracted' } }),
+ deleted: doc({ id: 'del', visibility: 'private', acl: { subjects: [alice] }, title: `Deleted ${SECRET}`, facets: { category: 'hiddencat-deleted' } }),
+};
+
+function idsOf(r) {
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ return r.body.results.map(x => x.id).sort();
+}
+
+function assertNoLeak(r, allowed) {
+ const text = JSON.stringify(r.body);
+ for (const [name, d] of Object.entries(D)) {
+ if (allowed.includes(d.id)) continue;
+ assert.ok(!text.includes(`"${d.id}"`), `${name} leaked by id`);
+ assert.ok(!text.includes(d.title), `${name} leaked by title`);
+ if (d.facets && d.facets.category && d.facets.category.startsWith('hiddencat')) {
+ assert.ok(!text.includes(d.facets.category), `${name} leaked through a facet value`);
+ }
+ }
+ assert.ok(!text.includes(alice), 'an ACL subject leaked');
+ assert.ok(!text.includes('vip.plan:gold') && !text.includes('role:admin'), 'an ACL key leaked');
+}
+
+t('boot and index the corpus', async () => {
+ svc = await boot();
+ for (const d of Object.values(D)) assert.strictEqual((await put(d)).status, 200, d.id);
+ const r = await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${D.deleted.id}?revision=2`, { token: WIKI });
+ assert.strictEqual(r.body.outcome, 'applied');
+});
+
+t('anonymous sees public listed documents only — hits, snippets, facets, suggestions', async () => {
+ const r = await q(`q=${SECRET}&facets=category,tags`);
+ assert.deepStrictEqual(idsOf(r), ['pub']);
+ assertNoLeak(r, ['pub']);
+ assert.deepStrictEqual(r.body.facets.category, [{ value: 'open', count: 1 }]);
+ assert.deepStrictEqual(r.body.facets.tags, [{ value: 'shared', count: 1 }], 'the private document also has tag shared: it must not be counted');
+ const s = await get(`/api/v1/suggest?q=${SECRET.slice(0, 5)}`);
+ assert.strictEqual(s.status, 200);
+ assert.deepStrictEqual(s.body.suggestions.map(x => x.id), ['pub'], 'only the public title completes');
+ assertNoLeak(s, ['pub']);
+ const s2 = await get('/api/v1/suggest?q=pub');
+ assert.deepStrictEqual(s2.body.suggestions.map(x => x.id), ['pub']);
+ const browse = await q('facets=category');
+ assert.deepStrictEqual(idsOf(browse), ['pub']);
+ assertNoLeak(browse, ['pub']);
+});
+
+t('filtering on a hidden facet value or owner finds nothing and counts nothing', async () => {
+ for (const cat of ['hiddencat-private', 'hiddencat-members', 'hiddencat-draft', 'hiddencat-deleted', 'thin']) {
+ const r = await q(`facet.category=${cat}&facets=category`);
+ assert.deepStrictEqual(idsOf(r), [], cat);
+ assert.deepStrictEqual(r.body.facets.category, [], cat);
+ }
+ const r = await q(`q=diary&owner=wiki&type=page`);
+ assert.deepStrictEqual(idsOf(r), []);
+});
+
+t('a signed-in stranger sees exactly what anonymous sees in results', async () => {
+ const r = await q(`q=${SECRET}&facets=category`, asUser(mallory));
+ assert.deepStrictEqual(idsOf(r), ['pub']);
+ assertNoLeak(r, ['pub']);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+});
+
+t('the ACL subject sees its private and unlisted documents, never drafts, unpublished or deleted', async () => {
+ const r = await q(`q=${SECRET}&facets=category`, asUser(alice));
+ assert.deepStrictEqual(idsOf(r), ['prv', 'pub', 'unl']);
+ const text = JSON.stringify(r.body);
+ for (const hidden of ['drf', 'unp', 'ret', 'del', 'mem', 'unl2', 'pubnoidx']) assert.ok(!text.includes(`"${hidden}"`), hidden);
+ assert.ok(!text.includes('"acl"'), 'the ACL itself is never returned');
+ const cats = r.body.facets.category.map(c => c.value).sort();
+ assert.deepStrictEqual(cats, ['hiddencat-private', 'hiddencat-unlisted', 'open']);
+ const s = await get('/api/v1/suggest?q=priv', asUser(alice));
+ assert.deepStrictEqual(s.body.suggestions.map(x => x.id), ['prv']);
+});
+
+t('a role group from the Network token matches role:<role> ACL keys (private needs the subject)', async () => {
+ const r = await q(`q=${SECRET}`, asUser(mallory, 'admin'));
+ assert.deepStrictEqual(idsOf(r), ['pub'], 'private is subject-only; an admin role is not a subject');
+});
+
+t('members documents need a vouched group or entitlement', async () => {
+ const none = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory }));
+ assert.deepStrictEqual(idsOf(none), ['pub']);
+ const grp = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory, 'X-OV-Groups': 'wiki.space:spc_1:member' }));
+ assert.deepStrictEqual(idsOf(grp), ['mem', 'pub']);
+ const ent = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': guest, 'X-OV-Entitlements': 'vip.plan:gold' }));
+ assert.deepStrictEqual(idsOf(ent), ['mem', 'pub']);
+ const wrong = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': mallory, 'X-OV-Groups': 'wiki.space:spc_2:member', 'X-OV-Entitlements': 'vip.plan:silver' }));
+ assert.deepStrictEqual(idsOf(wrong), ['pub']);
+ const forAlice = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': alice }));
+ assert.deepStrictEqual(idsOf(forAlice), ['prv', 'pub', 'unl']);
+});
+
+t('delegation headers are ignored for browsers and refused without the capability', async () => {
+ const forged = await q(`q=${SECRET}`, { token: userToken({ subjectId: mallory }), headers: { 'X-OV-Subject': alice, 'X-OV-Groups': 'role:admin', 'X-OV-Entitlements': 'vip.plan:gold' } });
+ assert.deepStrictEqual(idsOf(forged), ['pub']);
+ const anonForged = await q(`q=${SECRET}`, { headers: { 'X-OV-Subject': alice } });
+ assert.deepStrictEqual(idsOf(anonForged), ['pub']);
+ const noCap = await q(`q=${SECRET}`, { token: serviceToken('blog', ['search.document.write']), headers: { 'X-OV-Subject': alice } });
+ assert.strictEqual(noCap.status, 403);
+ const groupsWithoutSubject = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Groups': 'wiki.space:spc_1:member' }));
+ assert.strictEqual(groupsWithoutSubject.status, 400);
+ const badSubject = await q(`q=${SECRET}`, asDelegate({ 'X-OV-Subject': '57' }));
+ assert.strictEqual(badSubject.status, 400);
+ const asItself = await q(`q=${SECRET}`, asDelegate());
+ assert.deepStrictEqual(idsOf(asItself), ['pub']);
+});
+
+t('a bad Bearer is refused, never downgraded; an unverifiable cookie is signed out', async () => {
+ const forgedKey = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+ const bad = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, key: forgedKey.privateKey }) });
+ assert.strictEqual(bad.status, 401);
+ const badSvc = await q(`q=${SECRET}`, { token: serviceToken('blog', ['search.query.delegate'], { key: forgedKey.privateKey }), headers: { 'X-OV-Subject': alice } });
+ assert.strictEqual(badSvc.status, 401);
+ const expired = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, exp: Math.floor(Date.now() / 1000) - 3600 }) });
+ assert.strictEqual(expired.status, 401);
+ const wrongIss = await q(`q=${SECRET}`, { token: userToken({ subjectId: alice, iss: 'https://evil.example' }) });
+ assert.strictEqual(wrongIss.status, 401);
+ const cookie = await q(`q=${SECRET}`, { headers: { Cookie: `ov_token=${userToken({ subjectId: alice, key: forgedKey.privateKey })}` } });
+ assert.deepStrictEqual(idsOf(cookie), ['pub']);
+ const goodCookie = await q(`q=${SECRET}`, { headers: { Cookie: `ov_token=${userToken({ subjectId: alice })}` } });
+ assert.deepStrictEqual(idsOf(goodCookie), ['prv', 'pub', 'unl']);
+});
+
+t('direct gets: same 404 for hidden and missing; unlisted by id needs a signed-in subject', async () => {
+ const missing = await get('/api/v1/documents/wiki/page/nope');
+ assert.strictEqual(missing.status, 404);
+ for (const id of ['prv', 'mem', 'drf', 'unp', 'ret', 'del', 'unl', 'unl2']) {
+ const r = await get(`/api/v1/documents/wiki/page/${id}`);
+ assert.strictEqual(r.status, 404, `anonymous ${id}`);
+ assert.deepStrictEqual(Object.keys(r.body).sort(), Object.keys(missing.body).sort());
+ assert.strictEqual(r.body.code, missing.body.code);
+ }
+ for (const id of ['prv', 'mem', 'drf', 'unp', 'ret', 'del']) {
+ assert.strictEqual((await get(`/api/v1/documents/wiki/page/${id}`, asUser(mallory))).status, 404, `stranger ${id}`);
+ }
+ assert.strictEqual((await get('/api/v1/documents/wiki/page/unl2', asUser(mallory))).status, 200, 'unlisted by exact id');
+ assert.strictEqual((await get('/api/v1/documents/wiki/page/pubnoidx')).status, 200, 'public noindex is public by id');
+ const prv = await get('/api/v1/documents/wiki/page/prv', asUser(alice));
+ assert.strictEqual(prv.status, 200);
+ assert.strictEqual(prv.body.document.acl, undefined);
+ for (const id of ['drf', 'unp', 'del']) {
+ assert.strictEqual((await get(`/api/v1/documents/wiki/page/${id}`, asUser(alice))).status, 404, `ACL subject cannot see ${id}`);
+ }
+});
+
+t('FTS syntax in q cannot widen the query', async () => {
+ for (const bad of [`${SECRET} OR private`, `title:${SECRET}`, '*', `"${SECRET}" NOT pub`, `{title body}: ${SECRET}`, `${SECRET}) OR (diary`, 'NEAR(quuxsecret diary)', `${SECRET}*`]) {
+ const r = await q(`q=${encodeURIComponent(bad)}&facets=category`);
+ assert.strictEqual(r.status, 200, bad);
+ assertNoLeak(r, ['pub']);
+ }
+});
+
+t('cursors are bound to their query and never widen it', async () => {
+ for (let i = 0; i < 5; i++) await put(doc({ id: `page_${i}`, title: `Paging thetaword ${i}` }));
+ const p1 = await q('q=thetaword&limit=2');
+ assert.strictEqual(p1.body.results.length, 2);
+ const p2 = await q(`q=thetaword&limit=2&cursor=${p1.body.next_cursor}`);
+ const p3 = await q(`q=thetaword&limit=2&cursor=${p2.body.next_cursor}`);
+ const seen = [...p1.body.results, ...p2.body.results, ...p3.body.results].map(x => x.id);
+ assert.strictEqual(new Set(seen).size, 5);
+ assert.strictEqual(p3.body.next_cursor, null);
+ const reused = await q(`q=${SECRET}&cursor=${p1.body.next_cursor}`);
+ assert.strictEqual(reused.status, 400);
+ assert.strictEqual(reused.body.code, 'search.bad_cursor');
+ const forged = Buffer.from(JSON.stringify({ k: 'r', r: -1e9, i: 0, h: 'x' })).toString('base64url');
+ assert.strictEqual((await q(`q=${SECRET}&cursor=${forged}`)).status, 400);
+});
+
+t('a visibility change or deletion leaves results at once and announces search.document.removed', async () => {
+ const d = doc({ id: 'flip', title: 'Flipping iotaword', canonical_url: 'https://openvibe.wiki/p/flip', revision: 1 });
+ await put(d);
+ assert.deepStrictEqual(idsOf(await q('q=iotaword')), ['flip']);
+ await put({ ...d, revision: 2, visibility: 'private', acl: { subjects: [alice] } });
+ assert.deepStrictEqual(idsOf(await q('q=iotaword')), []);
+ assert.strictEqual((await get('/api/v1/documents/wiki/page/flip')).status, 404);
+ assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), ['flip']);
+ await request(svc.base, 'DELETE', '/api/v1/documents/wiki/page/flip?revision=3', { token: WIKI });
+ assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), []);
+
+ const removed = svc.outbox.all().filter(e => e.event_type === 'search.document.removed' && e.payload.id === 'flip');
+ assert.strictEqual(removed.length, 2);
+ assert.deepStrictEqual(removed.map(e => [e.payload.reason, e.payload.previous_exposure, e.payload.exposure]), [
+ ['visibility_changed', 'public_listed', 'restricted'],
+ ['deleted', 'restricted', 'none'],
+ ]);
+ assert.strictEqual(removed[0].payload.canonical_url, 'https://openvibe.wiki/p/flip', 'the public URL to purge');
+ assert.strictEqual(removed[1].payload.canonical_url, null, 'a URL that was never public is not broadcast');
+ assert.ok(removed.every(e => e.visibility === 'internal' && e.source === 'search'));
+});
+
+t('publishing → draft and public → noindex also announce removal', async () => {
+ const d = doc({ id: 'unpub', title: 'Kappaword', revision: 1 });
+ await put(d);
+ await put({ ...d, revision: 2, publication_state: 'unpublished' });
+ const d2 = doc({ id: 'noidx', title: 'Lambdaword', revision: 1 });
+ await put(d2);
+ await put({ ...d2, revision: 2, indexability: { decision: 'noindex', reasons: ['duplicate_without_canonical'] } });
+ const reasons = svc.outbox.all().filter(e => e.event_type === 'search.document.removed' && ['unpub', 'noidx'].includes(e.payload.id)).map(e => `${e.payload.id}:${e.payload.reason}`);
+ assert.deepStrictEqual(reasons, ['unpub:not_published', 'noidx:noindex']);
+ assert.deepStrictEqual(idsOf(await q('q=kappaword')), []);
+ assert.deepStrictEqual(idsOf(await q('q=lambdaword')), []);
+});
+
+t('done', async () => { await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+/** Owner API: capability and ownership, validation, revision ordering, tombstones, reconciliation. */
+const assert = require('assert');
+const { boot, request, serviceToken, doc, suite } = require('./helpers');
+
+const t = suite('documents');
+let svc;
+const WIKI = serviceToken('wiki', ['search.document.write']);
+const BLOG = serviceToken('blog', ['search.document.write']);
+
+const put = (d, token = WIKI) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token, body: d });
+const del = (d, rev, token = WIKI) => request(svc.base, 'DELETE', `/api/v1/documents/${d.owner}/${d.type}/${d.id}${rev === undefined ? '' : `?revision=${rev}`}`, { token });
+const search = (q) => request(svc.base, 'GET', `/api/v1/search?q=${encodeURIComponent(q)}`);
+
+t('boot', async () => { svc = await boot(); });
+
+t('writing needs a service token with search.document.write', async () => {
+ const d = doc();
+ assert.strictEqual((await put(d, null)).status, 401);
+ const noCap = serviceToken('wiki', ['search.query.delegate']);
+ const r = await put(d, noCap);
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.body.code, 'capability.denied');
+ const wrongAud = serviceToken('wiki', ['search.document.write'], { aud: 'openvibe.events' });
+ assert.strictEqual((await put(d, wrongAud)).status, 401);
+ assert.strictEqual((await put(d)).status, 200);
+});
+
+t('a service writes only its own documents', async () => {
+ const d = doc();
+ const r = await put(d, BLOG);
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.body.code, 'search.not_owner');
+ const r2 = await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, owner: 'blog' } });
+ assert.strictEqual(r2.status, 422);
+});
+
+t('invalid documents are refused with the schema errors', async () => {
+ const r = await put(doc({ visibility: 'everyone' }));
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.body.code, 'search.bad_document');
+ assert.ok(r.body.errors.length);
+ assert.strictEqual((await put(doc({ acl: { subjects: ['57'] } }))).status, 422, 'integer user ids are not subjects');
+ assert.strictEqual((await put(doc({ canonical_url: 'javascript:alert(1)' }))).status, 422);
+ const noTitle = doc();
+ delete noTitle.title;
+ assert.strictEqual((await put(noTitle)).status, 422);
+});
+
+t('an older revision never overwrites a newer one', async () => {
+ const d = doc({ title: 'Revision ordering alphaone', revision: 5 });
+ assert.strictEqual((await put(d)).body.outcome, 'applied');
+ const older = await put({ ...d, revision: 4, title: 'Revision ordering betatwo' });
+ assert.strictEqual(older.status, 409);
+ assert.strictEqual(older.body.code, 'search.stale_revision');
+ assert.strictEqual(older.body.stored_revision, 5);
+ assert.strictEqual((await search('alphaone')).body.results.length, 1);
+ assert.strictEqual((await search('betatwo')).body.results.length, 0);
+});
+
+t('the same revision again is unchanged; different content at that revision is a conflict', async () => {
+ const d = doc({ revision: 3 });
+ await put(d);
+ const again = await put(d);
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.body.outcome, 'unchanged');
+ const other = await put({ ...d, title: 'Something else' });
+ assert.strictEqual(other.status, 409);
+ assert.strictEqual(other.body.code, 'search.revision_conflict');
+});
+
+t('a newer revision replaces the document and its search terms', async () => {
+ const d = doc({ title: 'Gammaword original', revision: 1 });
+ await put(d);
+ assert.strictEqual((await put({ ...d, revision: 2, title: 'Deltaword replacement' })).body.outcome, 'applied');
+ assert.strictEqual((await search('gammaword')).body.results.length, 0);
+ const r = await search('deltaword');
+ assert.strictEqual(r.body.results.length, 1);
+ assert.strictEqual(r.body.results[0].revision, 2);
+});
+
+t('a tombstone removes the document and wins over the same and older revisions', async () => {
+ const d = doc({ title: 'Tombstoned epsilonword', revision: 7 });
+ await put(d);
+ assert.strictEqual((await search('epsilonword')).body.results.length, 1);
+ const r = await del(d, 7);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.outcome, 'applied');
+ assert.strictEqual((await search('epsilonword')).body.results.length, 0);
+ // a late upsert with the same revision, or an older one, cannot resurrect it
+ assert.strictEqual((await put(d)).status, 409);
+ assert.strictEqual((await put({ ...d, revision: 6 })).status, 409);
+ assert.strictEqual((await search('epsilonword')).body.results.length, 0);
+ assert.strictEqual((await request(svc.base, 'GET', `/api/v1/documents/wiki/page/${d.id}`)).status, 404);
+ // a newer revision is a deliberate restore
+ assert.strictEqual((await put({ ...d, revision: 8 })).body.outcome, 'applied');
+ assert.strictEqual((await search('epsilonword')).body.results.length, 1);
+});
+
+t('a deletion at the stored revision wins the tie; an older deletion is stale', async () => {
+ const d = doc({ title: 'Tie zetaword', revision: 3 });
+ await put(d);
+ assert.strictEqual((await del(d, 2)).status, 409);
+ assert.strictEqual((await search('zetaword')).body.results.length, 1);
+ assert.strictEqual((await del(d)).body.outcome, 'applied', 'no revision = the stored revision');
+ assert.strictEqual((await search('zetaword')).body.results.length, 0);
+ assert.strictEqual((await del(d)).body.outcome, 'unchanged');
+});
+
+t('deleting a document Search never saw leaves a tombstone that refuses late upserts', async () => {
+ const d = doc({ title: 'Never seen etaword', revision: 4 });
+ assert.strictEqual((await del(d, 4)).body.outcome, 'applied');
+ assert.strictEqual((await put(d)).status, 409);
+ assert.strictEqual((await search('etaword')).body.results.length, 0);
+});
+
+t('reconciliation lists the owner\'s (id, revision) pairs, tombstones included, with paging', async () => {
+ const own = serviceToken('recon', ['search.document.write']);
+ const made = [];
+ for (let i = 0; i < 5; i++) {
+ const d = doc({ owner: 'recon', type: 'item', id: `it_${i}`, revision: i + 1 });
+ await put(d, own);
+ made.push(d);
+ }
+ await del(made[2], 3, own);
+ const p1 = await request(svc.base, 'GET', '/api/v1/owners/recon/documents?limit=3', { token: own });
+ assert.strictEqual(p1.status, 200);
+ assert.strictEqual(p1.body.documents.length, 3);
+ assert.ok(p1.body.next_after);
+ const p2 = await request(svc.base, 'GET', `/api/v1/owners/recon/documents?limit=3&after=${p1.body.next_after}`, { token: own });
+ const all = [...p1.body.documents, ...p2.body.documents];
+ assert.deepStrictEqual(all.map(x => [x.id, x.revision, x.deleted]), [
+ ['it_0', 1, false], ['it_1', 2, false], ['it_2', 3, true], ['it_3', 4, false], ['it_4', 5, false],
+ ]);
+ assert.strictEqual(p2.body.next_after, null);
+ assert.ok(all.every(x => /^[0-9a-f]{64}$/.test(x.hash)));
+ // another service cannot read this owner's inventory
+ assert.strictEqual((await request(svc.base, 'GET', '/api/v1/owners/recon/documents', { token: WIKI })).status, 403);
+ const detail = await request(svc.base, 'GET', '/api/v1/owners/recon/documents/item/it_1', { token: own });
+ assert.strictEqual(detail.body.document.revision, 2);
+ assert.strictEqual(detail.body.effective_indexability.decision, 'index');
+});
+
+t('Search only makes indexability stricter', async () => {
+ const own = serviceToken('gate', ['search.document.write']);
+ const cases = [
+ [{ visibility: 'members' }, 'members_only'],
+ [{ publication_state: 'scheduled' }, 'not_published'],
+ [{ provenance: [{ service: 'sources', type: 'item', id: 'x', stub: true }] }, 'stub_provider'],
+ [{ canonical_url: undefined }, 'missing_canonical_url'],
+ ];
+ for (const [i, [over, reason]] of cases.entries()) {
+ const d = doc({ owner: 'gate', type: 'thing', id: `g${i}`, ...over });
+ if ('canonical_url' in over && over.canonical_url === undefined) delete d.canonical_url;
+ assert.strictEqual((await put(d, own)).status, 200);
+ const r = await request(svc.base, 'GET', `/api/v1/owners/gate/documents/thing/g${i}`, { token: own });
+ assert.strictEqual(r.body.effective_indexability.decision, 'noindex', reason);
+ assert.ok(r.body.effective_indexability.reasons.includes(reason), reason);
+ }
+ const d = doc({ owner: 'gate', type: 'thing', id: 'g9', indexability: { decision: 'noindex' } });
+ await put(d, own);
+ const r = await request(svc.base, 'GET', '/api/v1/owners/gate/documents/thing/g9', { token: own });
+ assert.deepStrictEqual(r.body.effective_indexability, { decision: 'noindex', reasons: ['owner_decision'] });
+});
+
+t('done', async () => { await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+/**
+ * Shared test fixtures: a generated Network signing key, service/user token minting, a booted
+ * Search service on a random port with a temp database, index-document builders and a signed
+ * Events delivery helper. Nothing here needs the network or a running OpenVibe.Network.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { start } = require('../server/index');
+const { sign } = require('../server/api/webhook');
+
+const ISSUER = 'https://openvibe.network';
+const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+});
+
+const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
+
+function serviceToken(slug, cap, { aud = 'openvibe.search', exp = Math.floor(Date.now() / 1000) + 300, iss = ISSUER, key = privateKey, sub } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
+ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, key);
+}
+
+function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey, iss = ISSUER } = {}) {
+ return serviceAuth.signServiceToken({
+ sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role, iss, aud, iat: Math.floor(Date.now() / 1000), exp,
+ }, key);
+}
+
+const made = [];
+process.on('exit', () => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
+
+function tmpDir() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-search-test-'));
+ made.push(d);
+ return d;
+}
+
+async function boot({ env = {}, fetchImpl, tokenClient, now } = {}) {
+ const dir = tmpDir();
+ const config = load({
+ NODE_ENV: 'test',
+ PORT: '0',
+ SEARCH_DB_PATH: path.join(dir, 'search.db'),
+ OV_NETWORK_PUBLIC_KEY: publicKey,
+ SEARCH_EVENTS_SECRET: WEBHOOK_SECRET,
+ ...env,
+ });
+ const h = await start({ config, log: silent, fetchImpl, tokenClient, now });
+ const base = `http://127.0.0.1:${h.server.address().port}`;
+ return { ...h, base, dir, async stop() { await h.close(); } };
+}
+
+async function request(base, method, p, { token, body, headers = {}, raw } = {}) {
+ const h = { ...headers };
+ if (token) h.Authorization = `Bearer ${token}`;
+ if (body !== undefined) h['Content-Type'] = 'application/json';
+ const res = await fetch(base + p, { method, headers: h, body: raw !== undefined ? raw : body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
+ return { status: res.status, body: json, text, headers: res.headers };
+}
+
+/** A valid, public, published, indexable document. Override anything. */
+function doc(overrides = {}) {
+ return {
+ owner: 'wiki',
+ type: 'page',
+ id: 'pg_' + crypto.randomBytes(6).toString('hex'),
+ revision: 1,
+ visibility: 'public',
+ acl: {},
+ canonical_url: 'https://openvibe.wiki/p/example',
+ title: 'Example page',
+ summary: 'A short summary.',
+ body: 'Body text.',
+ facets: { category: 'guides', tags: ['alpha', 'beta'] },
+ language: 'en',
+ authorship: 'human',
+ provenance: [],
+ publication_state: 'published',
+ published_at: '2026-09-01T10:00:00Z',
+ updated_at: '2026-09-02T10:00:00Z',
+ indexability: { decision: 'index', reasons: [] },
+ ...overrides,
+ };
+}
+
+/** A delivered Events envelope carrying an index document. */
+function indexEvent(document, { action = 'upserted', source = document.owner, eventId = ids.newId('event'), subject, eventType } = {}) {
+ const payload = action === 'deleted' ? { type: document.type, id: document.id, revision: document.revision } : { ...document };
+ return {
+ event_id: eventId,
+ event_type: eventType || `${source}.index_document.${action}`,
+ version: 1,
+ source,
+ actor: { type: 'service', id: source },
+ timestamp: new Date().toISOString(),
+ visibility: 'internal',
+ subject: subject || { type: document.type, id: document.id, revision: document.revision },
+ payload,
+ };
+}
+
+/** POST a signed delivery to /internal/events. */
+async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false } = {}) {
+ const raw = JSON.stringify({ event, seq });
+ const signature = badSignature ? sign(raw, 'wrong-secret-' + 'y'.repeat(32)) : sign(raw, secret);
+ return request(base, 'POST', '/internal/events', {
+ raw,
+ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signature, 'X-OpenVibe-Event-Id': event.event_id },
+ });
+}
+
+function suite(name) {
+ const tests = [];
+ const t = (n, fn) => tests.push([n, fn]);
+ t.run = async () => {
+ let failed = 0;
+ for (const [n, fn] of tests) {
+ try { await fn(); console.log(` ok ${n}`); } catch (err) { failed++; console.log(` FAIL ${n}\n${err.stack}`); }
+ }
+ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);
+ if (failed) process.exit(1);
+ };
+ return t;
+}
+
+module.exports = {
+ ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, tmpDir, boot, request,
+ doc, indexEvent, deliver, suite,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/** Outbox relay: removal events reach OpenVibe.Events, failures back off, refusals do not block. */
+const assert = require('assert');
+const http = require('http');
+const { validate } = require('openvibe-contracts');
+const { boot, request, serviceToken, doc, suite } = require('./helpers');
+
+const t = suite('outbox');
+
+/** Stub OpenVibe.Events: records publishes; `respond(body, n)` → status. */
+async function stubEvents(respond = () => 201) {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', c => chunks.push(c));
+ req.on('end', () => {
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
+ calls.push({ headers: req.headers, body });
+ const status = respond(body, calls.length);
+ res.statusCode = status;
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify(status < 300 ? { ok: true } : { code: 'events.bad_request' }));
+ });
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ return { calls, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(r => server.close(() => r())) };
+}
+
+const tokenClient = { async authHeaders() { return { Authorization: 'Bearer relay-token' }; }, invalidate() {} };
+const WIKI = serviceToken('wiki', ['search.document.write']);
+
+t('outbox envelopes are valid events.event-envelope@1 and are enqueued only with the change', async () => {
+ const svc = await boot();
+ try {
+ const d = doc({ canonical_url: 'https://openvibe.wiki/p/a' });
+ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });
+ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, revision: 0 } }); // stale: no event
+ await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });
+ const all = svc.outbox.all();
+ assert.deepStrictEqual(all.map(e => e.event_type), ['search.document.indexed', 'search.document.removed']);
+ for (const e of all) assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
+ assert.strictEqual(all[1].subject.id, `wiki/page/${d.id}`);
+ assert.strictEqual(all[1].subject.revision, 2);
+ assert.throws(() => svc.outbox.enqueue({ event_type: 'search.document.indexed', subject: { type: 'document', id: 'x' } }), /inside the transaction/);
+ } finally {
+ await svc.stop();
+ }
+});
+
+t('the relay publishes pending events with the service token and marks them sent', async () => {
+ const events = await stubEvents();
+ const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });
+ try {
+ const d = doc();
+ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });
+ await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });
+ // a write kicks the relay; a flush already in flight may predate the second row
+ for (let i = 0; i < 5 && svc.outbox.pending(); i++) await svc.relay.flush();
+ assert.strictEqual(svc.outbox.pending(), 0);
+ const published = events.calls.flatMap(c => (c.body.events ? c.body.events : [c.body]));
+ assert.deepStrictEqual(published.map(e => e.event_type).sort(), ['search.document.indexed', 'search.document.removed']);
+ assert.ok(events.calls.every(c => c.headers.authorization === 'Bearer relay-token'));
+ } finally {
+ await svc.stop();
+ await events.close();
+ }
+});
+
+t('an unreachable or failing Events keeps rows pending for retry; a refused row does not block the rest', async () => {
+ let mode = 'down';
+ const events = await stubEvents((body) => {
+ if (mode === 'down') return 503;
+ const list = body.events || [body];
+ return list.some(e => e.payload && e.payload.id === 'poison') ? 422 : 201;
+ });
+ const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });
+ try {
+ await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/poison', { token: WIKI, body: doc({ id: 'poison' }) });
+ await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/fine', { token: WIKI, body: doc({ id: 'fine' }) });
+ await svc.relay.flush();
+ await svc.relay.flush();
+ assert.strictEqual(svc.outbox.pending(), 2, 'kept for retry');
+ mode = 'up';
+ svc.db.prepare('UPDATE event_outbox SET next_attempt_at = 0').run();
+ await svc.relay.flush();
+ await svc.relay.flush();
+ assert.strictEqual(svc.outbox.pending(), 0);
+ assert.strictEqual(svc.outbox.rejected(), 1, 'the poison row is set aside');
+ } finally {
+ await svc.stop();
+ await events.close();
+ }
+});
+
+t('readiness reports the outbox and the webhook state', async () => {
+ const svc = await boot();
+ try {
+ const r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.engine, 'sqlite-fts5');
+ assert.match(r.body.outbox.relay, /^off/);
+ assert.strictEqual(r.body.webhook, 'on');
+ } finally {
+ await svc.stop();
+ }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * The proposals for OpenVibe.Contracts are valid against the contracts' own schemas, and match
+ * what the code enforces and emits.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { CAPS } = require('../server/auth');
+const { validate } = require('../server/document');
+const { suite, doc } = require('./helpers');
+
+const t = suite('proposals');
+const DOCS = path.join(__dirname, '..', 'docs');
+
+t('capability proposals are valid capabilities.capability@1 and cover every enforced id', () => {
+ const dir = path.join(DOCS, 'capabilities-proposal');
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
+ const ids = [];
+ for (const f of files) {
+ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
+ const v = contracts.validate('capabilities.capability@1', m);
+ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);
+ assert.strictEqual(`${m.id}.json`, f);
+ assert.strictEqual(m.owner, 'search');
+ ids.push(m.id);
+ }
+ assert.deepStrictEqual(ids.sort(), Object.values(CAPS).sort());
+});
+
+t('the service manifest proposal is a valid registry.service-manifest@1', () => {
+ const m = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
+ const v = contracts.validate('registry.service-manifest@1', m);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.deepStrictEqual([...m.capabilities].sort(), Object.values(CAPS).sort());
+ assert.deepStrictEqual(m.eventsProduced, ['search.document.indexed', 'search.document.removed']);
+});
+
+t('the index-document schema accepts documents and tombstones and refuses malformed ones', () => {
+ assert.ok(validate(doc()).valid);
+ assert.ok(validate({ owner: 'wiki', type: 'page', id: 'p1', revision: 3, deleted: true }).valid);
+ assert.ok(!validate({ owner: 'wiki', type: 'page', id: 'p1', revision: 3 }).valid, 'a live document needs visibility, state, title, indexability');
+ assert.ok(!validate(doc({ revision: -1 })).valid);
+ assert.ok(!validate(doc({ owner: 'Wiki' })).valid);
+ assert.ok(!validate(doc({ extra: 1 })).valid);
+ assert.ok(!validate(doc({ facets: { 'Bad Key': 'x' } })).valid);
+ assert.ok(!validate(doc({ acl: { subjects: ['svc:wiki'] } })).valid);
+ assert.ok(!validate(doc({ provenance: [{ service: 'sources', type: 'item' }] })).valid);
+ assert.ok(!validate(doc({ body: 'x'.repeat(48001) })).valid);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
+ * if any of them fails. They use temp SQLite databases, generated RSA keys and stub subscribers
+ * on random ports; none of them needs the network or a running OpenVibe.Network.
+ *
+ * npm test # everything
+ * npm test -- publish sse # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+/** Events consumer: signatures, inbox dedupe, revision order across deliveries, tombstones, refusals. */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, request, serviceToken, doc, indexEvent, deliver, suite } = require('./helpers');
+
+const t = suite('webhook-inbox');
+let svc;
+const search = async (q) => (await request(svc.base, 'GET', `/api/v1/search?q=${encodeURIComponent(q)}`)).body.results;
+const receipts = () => svc.db.prepare('SELECT COUNT(*) AS n FROM idempotency_receipts').get().n;
+
+t('boot', async () => { svc = await boot(); });
+
+t('an unsigned or wrongly signed delivery is refused and changes nothing', async () => {
+ const e = indexEvent(doc({ title: 'Unsigned muword' }));
+ const r = await deliver(svc.base, e, { badSignature: true });
+ assert.strictEqual(r.status, 401);
+ const r2 = await request(svc.base, 'POST', '/internal/events', { raw: JSON.stringify({ event: e }), headers: { 'Content-Type': 'application/json' } });
+ assert.strictEqual(r2.status, 401);
+ assert.strictEqual((await search('muword')).length, 0);
+ assert.strictEqual(receipts(), 0);
+});
+
+t('a delivered upsert is indexed; the same event again is a no-op', async () => {
+ const d = doc({ title: 'Delivered nuword' });
+ const e = indexEvent(d);
+ const r = await deliver(svc.base, e);
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual([r.body.duplicate, r.body.outcome], [false, 'applied']);
+ const again = await deliver(svc.base, e, { seq: 2 });
+ assert.deepStrictEqual([again.status, again.body.duplicate], [200, true]);
+ assert.strictEqual((await search('nuword')).length, 1);
+ const events = svc.outbox.all().filter(x => x.payload.id === d.id && x.event_type === 'search.document.indexed');
+ assert.strictEqual(events.length, 1, 'one effect, one event');
+});
+
+t('out-of-order deliveries converge on the newest revision', async () => {
+ const d = doc({ title: 'Order xiword v1' });
+ const v3 = indexEvent({ ...d, revision: 3, title: 'Order xiword v3 final' });
+ const v1 = indexEvent({ ...d, revision: 1 });
+ const v2 = indexEvent({ ...d, revision: 2, title: 'Order xiword v2 stale' });
+ assert.strictEqual((await deliver(svc.base, v3)).body.outcome, 'applied');
+ assert.strictEqual((await deliver(svc.base, v1)).body.outcome, 'stale');
+ assert.strictEqual((await deliver(svc.base, v2)).body.outcome, 'stale');
+ const hits = await search('xiword');
+ assert.strictEqual(hits.length, 1);
+ assert.strictEqual(hits[0].revision, 3);
+ assert.strictEqual(hits[0].title, 'Order xiword v3 final');
+});
+
+t('a deletion event tombstones and wins over upserts delivered after it', async () => {
+ const d = doc({ title: 'Deleted omicronword', revision: 4 });
+ await deliver(svc.base, indexEvent(d));
+ assert.strictEqual((await search('omicronword')).length, 1);
+ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }, { action: 'deleted' }))).body.outcome, 'applied');
+ assert.strictEqual((await search('omicronword')).length, 0);
+ // a retried older upsert and a same-revision upsert arrive late
+ assert.strictEqual((await deliver(svc.base, indexEvent(d))).body.outcome, 'stale');
+ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }))).body.outcome, 'stale');
+ assert.strictEqual((await search('omicronword')).length, 0);
+ const removed = svc.outbox.all().filter(x => x.event_type === 'search.document.removed' && x.payload.id === d.id);
+ assert.deepStrictEqual(removed.map(x => x.payload.reason), ['deleted']);
+});
+
+t('the events path and the direct API share one revision order', async () => {
+ const d = doc({ title: 'Shared piword', revision: 2 });
+ await deliver(svc.base, indexEvent(d));
+ const token = serviceToken('wiki', ['search.document.write']);
+ const r = await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token, body: { ...d, revision: 1 } });
+ assert.strictEqual(r.status, 409);
+ const del = await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=3`, { token });
+ assert.strictEqual(del.body.outcome, 'applied');
+ assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 2 }))).body.outcome, 'stale');
+});
+
+t('a source cannot write another owner\'s documents; refusals are recorded once and visible to that owner', async () => {
+ const d = doc({ owner: 'wiki', title: 'Forged rhoword' });
+ // blog publishes an event claiming a wiki document
+ const forged = indexEvent({ ...d }, { source: 'blog', eventType: 'blog.index_document.upserted' });
+ const r = await deliver(svc.base, forged);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.outcome, 'rejected:owner_mismatch');
+ assert.strictEqual((await deliver(svc.base, forged)).body.duplicate, true);
+ // a prefix that is not the source
+ const prefix = indexEvent({ ...d, owner: undefined }, { source: 'blog', eventType: 'wiki.index_document.upserted' });
+ assert.strictEqual((await deliver(svc.base, prefix)).body.outcome, 'rejected:owner_mismatch');
+ assert.strictEqual((await search('rhoword')).length, 0);
+
+ const blogToken = serviceToken('blog', ['search.document.write']);
+ const rej = await request(svc.base, 'GET', '/api/v1/owners/blog/rejections', { token: blogToken });
+ assert.strictEqual(rej.status, 200);
+ assert.deepStrictEqual(rej.body.rejections.map(x => x.code), ['owner_mismatch', 'owner_mismatch']);
+});
+
+t('unknown owners, subject mismatches and invalid documents are refused', async () => {
+ const stranger = doc({ owner: 'nobody', title: 'Stranger sigmaword' });
+ assert.strictEqual((await deliver(svc.base, indexEvent(stranger))).body.outcome, 'rejected:owner_not_accepted');
+ const d = doc({ title: 'Mismatch sigmaword' });
+ const mismatch = indexEvent(d, { subject: { type: 'page', id: 'someone_else', revision: 1 } });
+ assert.strictEqual((await deliver(svc.base, mismatch)).body.outcome, 'rejected:subject_mismatch');
+ const badRev = indexEvent(d, { subject: { type: 'page', id: d.id, revision: 9 } });
+ assert.strictEqual((await deliver(svc.base, badRev)).body.outcome, 'rejected:subject_mismatch');
+ const invalid = indexEvent({ ...d, visibility: 'world' });
+ assert.strictEqual((await deliver(svc.base, invalid)).body.outcome, 'rejected:invalid_document');
+ assert.strictEqual((await search('sigmaword')).length, 0);
+});
+
+t('events that are not index documents are acknowledged and ignored', async () => {
+ const e = { ...indexEvent(doc()), event_type: 'wiki.page.published' };
+ const r = await deliver(svc.base, e);
+ assert.deepStrictEqual([r.status, r.body.outcome], [200, 'ignored']);
+});
+
+t('a failure while applying leaves no receipt, so the redelivery applies exactly once', async () => {
+ const d = doc({ title: 'Crash tauword' });
+ const e = indexEvent(d);
+ const original = svc.store.apply;
+ let calls = 0;
+ svc.store.apply = (...args) => { calls++; if (calls === 1) throw new Error('disk on fire'); return original(...args); };
+ try {
+ const r1 = await deliver(svc.base, e);
+ assert.strictEqual(r1.status, 500);
+ assert.strictEqual((await search('tauword')).length, 0);
+ assert.strictEqual(svc.db.prepare('SELECT COUNT(*) AS n FROM idempotency_receipts WHERE event_id = ?').get(e.event_id).n, 0);
+ const r2 = await deliver(svc.base, e, { seq: 2 });
+ assert.deepStrictEqual([r2.status, r2.body.outcome], [200, 'applied']);
+ const r3 = await deliver(svc.base, e, { seq: 3 });
+ assert.strictEqual(r3.body.duplicate, true);
+ } finally {
+ svc.store.apply = original;
+ }
+ assert.strictEqual((await search('tauword')).length, 1);
+ assert.strictEqual(svc.outbox.all().filter(x => x.payload.id === d.id).length, 1);
+});
+
+t('without SEARCH_EVENTS_SECRET the webhook is off', async () => {
+ const off = await boot({ env: { SEARCH_EVENTS_SECRET: '' } });
+ try {
+ const r = await deliver(off.base, indexEvent(doc()));
+ assert.strictEqual(r.status, 503);
+ } finally {
+ await off.stop();
+ }
+});
+
+t('secret rotation: both configured secrets verify', async () => {
+ const other = 'whsec_rotated_' + 'z'.repeat(40);
+ const svc2 = await boot({ env: { SEARCH_EVENTS_SECRET: `${other},whsec_test_${'x'.repeat(40)}` } });
+ try {
+ assert.strictEqual((await deliver(svc2.base, indexEvent(doc()), { secret: other })).status, 200);
+ assert.strictEqual((await deliver(svc2.base, indexEvent(doc()))).status, 200);
+ } finally {
+ await svc2.stop();
+ }
+});
+
+t('done', async () => { await svc.stop(); void ids; });
+
+t.run();