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

OP OpenVibers committed 23 Sept 2026 · a9d48430eb · 24 files, +4830 −0

Owners write search.index-document@1 documents by PUT/DELETE (search.document.write, own owner
only) or through OpenVibe.Events (<owner>.index_document.upserted|deleted, signed webhook with an
inbox). Anonymous queries read only public listed documents; signed-in viewers add ACL matches;
first-party services query for a subject with search.query.delegate. Drafts, unpublished and
deleted documents are never served, and facets, suggestions and direct gets use the same rules.

Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
++ Everyone is permitted to copy and distribute verbatim copies
++ of this license document, but changing it is not allowed.
++
++ Preamble
++
++ The GNU Affero General Public License is a free, copyleft license for
++software and other kinds of works, specifically designed to ensure
++cooperation with the community in the case of network server software.
++
++ The licenses for most software and other practical works are designed
++to take away your freedom to share and change the works. By contrast,
++our General Public Licenses are intended to guarantee your freedom to
++share and change all versions of a program--to make sure it remains free
++software for all its users.
++
++ When we speak of free software, we are referring to freedom, not
++price. Our General Public Licenses are designed to make sure that you
++have the freedom to distribute copies of free software (and charge for
++them if you wish), that you receive source code or can get it if you
++want it, that you can change the software or use pieces of it in new
++free programs, and that you know you can do these things.
++
++ Developers that use our General Public Licenses protect your rights
++with two steps: (1) assert copyright on the software, and (2) offer
++you this License which gives you legal permission to copy, distribute
++and/or modify the software.
++
++ A secondary benefit of defending all users' freedom is that
++improvements made in alternate versions of the program, if they
++receive widespread use, become available for other developers to
++incorporate. Many developers of free software are heartened and
++encouraged by the resulting cooperation. However, in the case of
++software used on network servers, this result may fail to come about.
++The GNU General Public License permits making a modified version and
++letting the public access it on a server without ever releasing its
++source code to the public.
++
++ The GNU Affero General Public License is designed specifically to
++ensure that, in such cases, the modified source code becomes available
++to the community. It requires the operator of a network server to
++provide the source code of the modified version running there to the
++users of that server. Therefore, public use of a modified version, on
++a publicly accessible server, gives the public access to the source
++code of the modified version.
++
++ An older license, called the Affero General Public License and
++published by Affero, was designed to accomplish similar goals. This is
++a different license, not a version of the Affero GPL, but Affero has
++released a new version of the Affero GPL which permits relicensing under
++this license.
++
++ The precise terms and conditions for copying, distribution and
++modification follow.
++
++ TERMS AND CONDITIONS
++
++ 0. Definitions.
++
++ "This License" refers to version 3 of the GNU Affero General Public License.
++
++ "Copyright" also means copyright-like laws that apply to other kinds of
++works, such as semiconductor masks.
++
++ "The Program" refers to any copyrightable work licensed under this
++License. Each licensee is addressed as "you". "Licensees" and
++"recipients" may be individuals or organizations.
++
++ To "modify" a work means to copy from or adapt all or part of the work
++in a fashion requiring copyright permission, other than the making of an
++exact copy. The resulting work is called a "modified version" of the
++earlier work or a work "based on" the earlier work.
++
++ A "covered work" means either the unmodified Program or a work based
++on the Program.
++
++ To "propagate" a work means to do anything with it that, without
++permission, would make you directly or secondarily liable for
++infringement under applicable copyright law, except executing it on a
++computer or modifying a private copy. Propagation includes copying,
++distribution (with or without modification), making available to the
++public, and in some countries other activities as well.
++
++ To "convey" a work means any kind of propagation that enables other
++parties to make or receive copies. Mere interaction with a user through
++a computer network, with no transfer of a copy, is not conveying.
++
++ An interactive user interface displays "Appropriate Legal Notices"
++to the extent that it includes a convenient and prominently visible
++feature that (1) displays an appropriate copyright notice, and (2)
++tells the user that there is no warranty for the work (except to the
++extent that warranties are provided), that licensees may convey the
++work under this License, and how to view a copy of this License. If
++the interface presents a list of user commands or options, such as a
++menu, a prominent item in the list meets this criterion.
++
++ 1. Source Code.
++
++ The "source code" for a work means the preferred form of the work
++for making modifications to it. "Object code" means any non-source
++form of a work.
++
++ A "Standard Interface" means an interface that either is an official
++standard defined by a recognized standards body, or, in the case of
++interfaces specified for a particular programming language, one that
++is widely used among developers working in that language.
++
++ The "System Libraries" of an executable work include anything, other
++than the work as a whole, that (a) is included in the normal form of
++packaging a Major Component, but which is not part of that Major
++Component, and (b) serves only to enable use of the work with that
++Major Component, or to implement a Standard Interface for which an
++implementation is available to the public in source code form. A
++"Major Component", in this context, means a major essential component
++(kernel, window system, and so on) of the specific operating system
++(if any) on which the executable work runs, or a compiler used to
++produce the work, or an object code interpreter used to run it.
++
++ The "Corresponding Source" for a work in object code form means all
++the source code needed to generate, install, and (for an executable
++work) run the object code and to modify the work, including scripts to
++control those activities. However, it does not include the work's
++System Libraries, or general-purpose tools or generally available free
++programs which are used unmodified in performing those activities but
++which are not part of the work. For example, Corresponding Source
++includes interface definition files associated with source files for
++the work, and the source code for shared libraries and dynamically
++linked subprograms that the work is specifically designed to require,
++such as by intimate data communication or control flow between those
++subprograms and other parts of the work.
++
++ The Corresponding Source need not include anything that users
++can regenerate automatically from other parts of the Corresponding
++Source.
++
++ The Corresponding Source for a work in source code form is that
++same work.
++
++ 2. Basic Permissions.
++
++ All rights granted under this License are granted for the term of
++copyright on the Program, and are irrevocable provided the stated
++conditions are met. This License explicitly affirms your unlimited
++permission to run the unmodified Program. The output from running a
++covered work is covered by this License only if the output, given its
++content, constitutes a covered work. This License acknowledges your
++rights of fair use or other equivalent, as provided by copyright law.
++
++ You may make, run and propagate covered works that you do not
++convey, without conditions so long as your license otherwise remains
++in force. You may convey covered works to others for the sole purpose
++of having them make modifications exclusively for you, or provide you
++with facilities for running those works, provided that you comply with
++the terms of this License in conveying all material for which you do
++not control copyright. Those thus making or running the covered works
++for you must do so exclusively on your behalf, under your direction
++and control, on terms that prohibit them from making any copies of
++your copyrighted material outside their relationship with you.
++
++ Conveying under any other circumstances is permitted solely under
++the conditions stated below. Sublicensing is not allowed; section 10
++makes it unnecessary.
++
++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
++
++ No covered work shall be deemed part of an effective technological
++measure under any applicable law fulfilling obligations under article
++11 of the WIPO copyright treaty adopted on 20 December 1996, or
++similar laws prohibiting or restricting circumvention of such
++measures.
++
++ When you convey a covered work, you waive any legal power to forbid
++circumvention of technological measures to the extent such circumvention
++is effected by exercising rights under this License with respect to
++the covered work, and you disclaim any intention to limit operation or
++modification of the work as a means of enforcing, against the work's
++users, your or third parties' legal rights to forbid circumvention of
++technological measures.
++
++ 4. Conveying Verbatim Copies.
++
++ You may convey verbatim copies of the Program's source code as you
++receive it, in any medium, provided that you conspicuously and
++appropriately publish on each copy an appropriate copyright notice;
++keep intact all notices stating that this License and any
++non-permissive terms added in accord with section 7 apply to the code;
++keep intact all notices of the absence of any warranty; and give all
++recipients a copy of this License along with the Program.
++
++ You may charge any price or no price for each copy that you convey,
++and you may offer support or warranty protection for a fee.
++
++ 5. Conveying Modified Source Versions.
++
++ You may convey a work based on the Program, or the modifications to
++produce it from the Program, in the form of source code under the
++terms of section 4, provided that you also meet all of these conditions:
++
++ a) The work must carry prominent notices stating that you modified
++ it, and giving a relevant date.
++
++ b) The work must carry prominent notices stating that it is
++ released under this License and any conditions added under section
++ 7. This requirement modifies the requirement in section 4 to
++ "keep intact all notices".
++
++ c) You must license the entire work, as a whole, under this
++ License to anyone who comes into possession of a copy. This
++ License will therefore apply, along with any applicable section 7
++ additional terms, to the whole of the work, and all its parts,
++ regardless of how they are packaged. This License gives no
++ permission to license the work in any other way, but it does not
++ invalidate such permission if you have separately received it.
++
++ d) If the work has interactive user interfaces, each must display
++ Appropriate Legal Notices; however, if the Program has interactive
++ interfaces that do not display Appropriate Legal Notices, your
++ work need not make them do so.
++
++ A compilation of a covered work with other separate and independent
++works, which are not by their nature extensions of the covered work,
++and which are not combined with it such as to form a larger program,
++in or on a volume of a storage or distribution medium, is called an
++"aggregate" if the compilation and its resulting copyright are not
++used to limit the access or legal rights of the compilation's users
++beyond what the individual works permit. Inclusion of a covered work
++in an aggregate does not cause this License to apply to the other
++parts of the aggregate.
++
++ 6. Conveying Non-Source Forms.
++
++ You may convey a covered work in object code form under the terms
++of sections 4 and 5, provided that you also convey the
++machine-readable Corresponding Source under the terms of this License,
++in one of these ways:
++
++ a) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by the
++ Corresponding Source fixed on a durable physical medium
++ customarily used for software interchange.
++
++ b) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by a
++ written offer, valid for at least three years and valid for as
++ long as you offer spare parts or customer support for that product
++ model, to give anyone who possesses the object code either (1) a
++ copy of the Corresponding Source for all the software in the
++ product that is covered by this License, on a durable physical
++ medium customarily used for software interchange, for a price no
++ more than your reasonable cost of physically performing this
++ conveying of source, or (2) access to copy the
++ Corresponding Source from a network server at no charge.
++
++ c) Convey individual copies of the object code with a copy of the
++ written offer to provide the Corresponding Source. This
++ alternative is allowed only occasionally and noncommercially, and
++ only if you received the object code with such an offer, in accord
++ with subsection 6b.
++
++ d) Convey the object code by offering access from a designated
++ place (gratis or for a charge), and offer equivalent access to the
++ Corresponding Source in the same way through the same place at no
++ further charge. You need not require recipients to copy the
++ Corresponding Source along with the object code. If the place to
++ copy the object code is a network server, the Corresponding Source
++ may be on a different server (operated by you or a third party)
++ that supports equivalent copying facilities, provided you maintain
++ clear directions next to the object code saying where to find the
++ Corresponding Source. Regardless of what server hosts the
++ Corresponding Source, you remain obligated to ensure that it is
++ available for as long as needed to satisfy these requirements.
++
++ e) Convey the object code using peer-to-peer transmission, provided
++ you inform other peers where the object code and Corresponding
++ Source of the work are being offered to the general public at no
++ charge under subsection 6d.
++
++ A separable portion of the object code, whose source code is excluded
++from the Corresponding Source as a System Library, need not be
++included in conveying the object code work.
++
++ A "User Product" is either (1) a "consumer product", which means any
++tangible personal property which is normally used for personal, family,
++or household purposes, or (2) anything designed or sold for incorporation
++into a dwelling. In determining whether a product is a consumer product,
++doubtful cases shall be resolved in favor of coverage. For a particular
++product received by a particular user, "normally used" refers to a
++typical or common use of that class of product, regardless of the status
++of the particular user or of the way in which the particular user
++actually uses, or expects or is expected to use, the product. A product
++is a consumer product regardless of whether the product has substantial
++commercial, industrial or non-consumer uses, unless such uses represent
++the only significant mode of use of the product.
++
++ "Installation Information" for a User Product means any methods,
++procedures, authorization keys, or other information required to install
++and execute modified versions of a covered work in that User Product from
++a modified version of its Corresponding Source. The information must
++suffice to ensure that the continued functioning of the modified object
++code is in no case prevented or interfered with solely because
++modification has been made.
++
++ If you convey an object code work under this section in, or with, or
++specifically for use in, a User Product, and the conveying occurs as
++part of a transaction in which the right of possession and use of the
++User Product is transferred to the recipient in perpetuity or for a
++fixed term (regardless of how the transaction is characterized), the
++Corresponding Source conveyed under this section must be accompanied
++by the Installation Information. But this requirement does not apply
++if neither you nor any third party retains the ability to install
++modified object code on the User Product (for example, the work has
++been installed in ROM).
++
++ The requirement to provide Installation Information does not include a
++requirement to continue to provide support service, warranty, or updates
++for a work that has been modified or installed by the recipient, or for
++the User Product in which it has been modified or installed. Access to a
++network may be denied when the modification itself materially and
++adversely affects the operation of the network or violates the rules and
++protocols for communication across the network.
++
++ Corresponding Source conveyed, and Installation Information provided,
++in accord with this section must be in a format that is publicly
++documented (and with an implementation available to the public in
++source code form), and must require no special password or key for
++unpacking, reading or copying.
++
++ 7. Additional Terms.
++
++ "Additional permissions" are terms that supplement the terms of this
++License by making exceptions from one or more of its conditions.
++Additional permissions that are applicable to the entire Program shall
++be treated as though they were included in this License, to the extent
++that they are valid under applicable law. If additional permissions
++apply only to part of the Program, that part may be used separately
++under those permissions, but the entire Program remains governed by
++this License without regard to the additional permissions.
++
++ When you convey a copy of a covered work, you may at your option
++remove any additional permissions from that copy, or from any part of
++it. (Additional permissions may be written to require their own
++removal in certain cases when you modify the work.) You may place
++additional permissions on material, added by you to a covered work,
++for which you have or can give appropriate copyright permission.
++
++ Notwithstanding any other provision of this License, for material you
++add to a covered work, you may (if authorized by the copyright holders of
++that material) supplement the terms of this License with terms:
++
++ a) Disclaiming warranty or limiting liability differently from the
++ terms of sections 15 and 16 of this License; or
++
++ b) Requiring preservation of specified reasonable legal notices or
++ author attributions in that material or in the Appropriate Legal
++ Notices displayed by works containing it; or
++
++ c) Prohibiting misrepresentation of the origin of that material, or
++ requiring that modified versions of such material be marked in
++ reasonable ways as different from the original version; or
++
++ d) Limiting the use for publicity purposes of names of licensors or
++ authors of the material; or
++
++ e) Declining to grant rights under trademark law for use of some
++ trade names, trademarks, or service marks; or
++
++ f) Requiring indemnification of licensors and authors of that
++ material by anyone who conveys the material (or modified versions of
++ it) with contractual assumptions of liability to the recipient, for
++ any liability that these contractual assumptions directly impose on
++ those licensors and authors.
++
++ All other non-permissive additional terms are considered "further
++restrictions" within the meaning of section 10. If the Program as you
++received it, or any part of it, contains a notice stating that it is
++governed by this License along with a term that is a further
++restriction, you may remove that term. If a license document contains
++a further restriction but permits relicensing or conveying under this
++License, you may add to a covered work material governed by the terms
++of that license document, provided that the further restriction does
++not survive such relicensing or conveying.
++
++ If you add terms to a covered work in accord with this section, you
++must place, in the relevant source files, a statement of the
++additional terms that apply to those files, or a notice indicating
++where to find the applicable terms.
++
++ Additional terms, permissive or non-permissive, may be stated in the
++form of a separately written license, or stated as exceptions;
++the above requirements apply either way.
++
++ 8. Termination.
++
++ You may not propagate or modify a covered work except as expressly
++provided under this License. Any attempt otherwise to propagate or
++modify it is void, and will automatically terminate your rights under
++this License (including any patent licenses granted under the third
++paragraph of section 11).
++
++ However, if you cease all violation of this License, then your
++license from a particular copyright holder is reinstated (a)
++provisionally, unless and until the copyright holder explicitly and
++finally terminates your license, and (b) permanently, if the copyright
++holder fails to notify you of the violation by some reasonable means
++prior to 60 days after the cessation.
++
++ Moreover, your license from a particular copyright holder is
++reinstated permanently if the copyright holder notifies you of the
++violation by some reasonable means, this is the first time you have
++received notice of violation of this License (for any work) from that
++copyright holder, and you cure the violation prior to 30 days after
++your receipt of the notice.
++
++ Termination of your rights under this section does not terminate the
++licenses of parties who have received copies or rights from you under
++this License. If your rights have been terminated and not permanently
++reinstated, you do not qualify to receive new licenses for the same
++material under section 10.
++
++ 9. Acceptance Not Required for Having Copies.
++
++ You are not required to accept this License in order to receive or
++run a copy of the Program. Ancillary propagation of a covered work
++occurring solely as a consequence of using peer-to-peer transmission
++to receive a copy likewise does not require acceptance. However,
++nothing other than this License grants you permission to propagate or
++modify any covered work. These actions infringe copyright if you do
++not accept this License. Therefore, by modifying or propagating a
++covered work, you indicate your acceptance of this License to do so.
++
++ 10. Automatic Licensing of Downstream Recipients.
++
++ Each time you convey a covered work, the recipient automatically
++receives a license from the original licensors, to run, modify and
++propagate that work, subject to this License. You are not responsible
++for enforcing compliance by third parties with this License.
++
++ An "entity transaction" is a transaction transferring control of an
++organization, or substantially all assets of one, or subdividing an
++organization, or merging organizations. If propagation of a covered
++work results from an entity transaction, each party to that
++transaction who receives a copy of the work also receives whatever
++licenses to the work the party's predecessor in interest had or could
++give under the previous paragraph, plus a right to possession of the
++Corresponding Source of the work from the predecessor in interest, if
++the predecessor has it or can get it with reasonable efforts.
++
++ You may not impose any further restrictions on the exercise of the
++rights granted or affirmed under this License. For example, you may
++not impose a license fee, royalty, or other charge for exercise of
++rights granted under this License, and you may not initiate litigation
++(including a cross-claim or counterclaim in a lawsuit) alleging that
++any patent claim is infringed by making, using, selling, offering for
++sale, or importing the Program or any portion of it.
++
++ 11. Patents.
++
++ A "contributor" is a copyright holder who authorizes use under this
++License of the Program or a work on which the Program is based. The
++work thus licensed is called the contributor's "contributor version".
++
++ A contributor's "essential patent claims" are all patent claims
++owned or controlled by the contributor, whether already acquired or
++hereafter acquired, that would be infringed by some manner, permitted
++by this License, of making, using, or selling its contributor version,
++but do not include claims that would be infringed only as a
++consequence of further modification of the contributor version. For
++purposes of this definition, "control" includes the right to grant
++patent sublicenses in a manner consistent with the requirements of
++this License.
++
++ Each contributor grants you a non-exclusive, worldwide, royalty-free
++patent license under the contributor's essential patent claims, to
++make, use, sell, offer for sale, import and otherwise run, modify and
++propagate the contents of its contributor version.
++
++ In the following three paragraphs, a "patent license" is any express
++agreement or commitment, however denominated, not to enforce a patent
++(such as an express permission to practice a patent or covenant not to
++sue for patent infringement). To "grant" such a patent license to a
++party means to make such an agreement or commitment not to enforce a
++patent against the party.
++
++ If you convey a covered work, knowingly relying on a patent license,
++and the Corresponding Source of the work is not available for anyone
++to copy, free of charge and under the terms of this License, through a
++publicly available network server or other readily accessible means,
++then you must either (1) cause the Corresponding Source to be so
++available, or (2) arrange to deprive yourself of the benefit of the
++patent license for this particular work, or (3) arrange, in a manner
++consistent with the requirements of this License, to extend the patent
++license to downstream recipients. "Knowingly relying" means you have
++actual knowledge that, but for the patent license, your conveying the
++covered work in a country, or your recipient's use of the covered work
++in a country, would infringe one or more identifiable patents in that
++country that you have reason to believe are valid.
++
++ If, pursuant to or in connection with a single transaction or
++arrangement, you convey, or propagate by procuring conveyance of, a
++covered work, and grant a patent license to some of the parties
++receiving the covered work authorizing them to use, propagate, modify
++or convey a specific copy of the covered work, then the patent license
++you grant is automatically extended to all recipients of the covered
++work and works based on it.
++
++ A patent license is "discriminatory" if it does not include within
++the scope of its coverage, prohibits the exercise of, or is
++conditioned on the non-exercise of one or more of the rights that are
++specifically granted under this License. You may not convey a covered
++work if you are a party to an arrangement with a third party that is
++in the business of distributing software, under which you make payment
++to the third party based on the extent of your activity of conveying
++the work, and under which the third party grants, to any of the
++parties who would receive the covered work from you, a discriminatory
++patent license (a) in connection with copies of the covered work
++conveyed by you (or copies made from those copies), or (b) primarily
++for and in connection with specific products or compilations that
++contain the covered work, unless you entered into that arrangement,
++or that patent license was granted, prior to 28 March 2007.
++
++ Nothing in this License shall be construed as excluding or limiting
++any implied license or other defenses to infringement that may
++otherwise be available to you under applicable patent law.
++
++ 12. No Surrender of Others' Freedom.
++
++ If conditions are imposed on you (whether by court order, agreement or
++otherwise) that contradict the conditions of this License, they do not
++excuse you from the conditions of this License. If you cannot convey a
++covered work so as to satisfy simultaneously your obligations under this
++License and any other pertinent obligations, then as a consequence you may
++not convey it at all. For example, if you agree to terms that obligate you
++to collect a royalty for further conveying from those to whom you convey
++the Program, the only way you could satisfy both those terms and this
++License would be to refrain entirely from conveying the Program.
++
++ 13. Remote Network Interaction; Use with the GNU General Public License.
++
++ Notwithstanding any other provision of this License, if you modify the
++Program, your modified version must prominently offer all users
++interacting with it remotely through a computer network (if your version
++supports such interaction) an opportunity to receive the Corresponding
++Source of your version by providing access to the Corresponding Source
++from a network server at no charge, through some standard or customary
++means of facilitating copying of software. This Corresponding Source
++shall include the Corresponding Source for any work covered by version 3
++of the GNU General Public License that is incorporated pursuant to the
++following paragraph.
++
++ Notwithstanding any other provision of this License, you have
++permission to link or combine any covered work with a work licensed
++under version 3 of the GNU General Public License into a single
++combined work, and to convey the resulting work. The terms of this
++License will continue to apply to the part which is the covered work,
++but the work with which it is combined will remain governed by version
++3 of the GNU General Public License.
++
++ 14. Revised Versions of this License.
++
++ The Free Software Foundation may publish revised and/or new versions of
++the GNU Affero General Public License from time to time. Such new versions
++will be similar in spirit to the present version, but may differ in detail to
++address new problems or concerns.
++
++ Each version is given a distinguishing version number. If the
++Program specifies that a certain numbered version of the GNU Affero General
++Public License "or any later version" applies to it, you have the
++option of following the terms and conditions either of that numbered
++version or of any later version published by the Free Software
++Foundation. If the Program does not specify a version number of the
++GNU Affero General Public License, you may choose any version ever published
++by the Free Software Foundation.
++
++ If the Program specifies that a proxy can decide which future
++versions of the GNU Affero General Public License can be used, that proxy's
++public statement of acceptance of a version permanently authorizes you
++to choose that version for the Program.
++
++ Later license versions may give you additional or different
++permissions. However, no additional obligations are imposed on any
++author or copyright holder as a result of your choosing to follow a
++later version.
++
++ 15. Disclaimer of Warranty.
++
++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
++
++ 16. Limitation of Liability.
++
++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
++SUCH DAMAGES.
++
++ 17. Interpretation of Sections 15 and 16.
++
++ If the disclaimer of warranty and limitation of liability provided
++above cannot be given local legal effect according to their terms,
++reviewing courts shall apply local law that most closely approximates
++an absolute waiver of all civil liability in connection with the
++Program, unless a warranty or assumption of liability accompanies a
++copy of the Program in return for a fee.
++
++ END OF TERMS AND CONDITIONS
++
++ How to Apply These Terms to Your New Programs
++
++ If you develop a new program, and you want it to be of the greatest
++possible use to the public, the best way to achieve this is to make it
++free software which everyone can redistribute and change under these terms.
++
++ To do so, attach the following notices to the program. It is safest
++to attach them to the start of each source file to most effectively
++state the exclusion of warranty; and each file should have at least
++the "copyright" line and a pointer to where the full notice is found.
++
++ <one line to give the program's name and a brief idea of what it does.>
++ Copyright (C) <year> <name of author>
++
++ This program is free software: you can redistribute it and/or modify
++ it under the terms of the GNU Affero General Public License as published
++ by the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ This program is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU Affero General Public License for more details.
++
++ You should have received a copy of the GNU Affero General Public License
++ along with this program. If not, see <https://www.gnu.org/licenses/>.
++
++Also add information on how to contact you by electronic and paper mail.
++
++ If your software can interact with users remotely through a computer
++network, you should also make sure that it provides a way for users to
++get its source. For example, if your program is a web application, its
++interface could display a "Source" link that leads users to an archive
++of the code. There are many ways you could offer source, and different
++solutions will be better for different programs; see section 13 for the
++specific requirements.
++
++ You should also get your employer (if you work as a programmer) or school,
++if any, to sign a "copyright disclaimer" for the program, if necessary.
++For more information on this, and how to apply and follow the GNU AGPL, see
++<https://www.gnu.org/licenses/>.
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..f436cd6
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..5a7981a
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..2349ee0
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -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);
++});
ddiff --git a/server/api/documents.js b/server/api/documents.js
nnew file mode 100644
iindex 0000000..b29fefe
---- /dev/null
++++ b/server/api/documents.js
@@@ -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 };
ddiff --git a/server/api/query.js b/server/api/query.js
nnew file mode 100644
iindex 0000000..dfa5d72
---- /dev/null
++++ b/server/api/query.js
@@@ -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 };
ddiff --git a/server/api/webhook.js b/server/api/webhook.js
nnew file mode 100644
iindex 0000000..1b53091
---- /dev/null
++++ b/server/api/webhook.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..d9e33ed
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..36affae
---- /dev/null
++++ b/server/auth.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..1800ae6
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..4d412ea
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/document.js b/server/document.js
nnew file mode 100644
iindex 0000000..a531aa5
---- /dev/null
++++ b/server/document.js
@@@ -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,
++};
ddiff --git a/server/engine/fts5.js b/server/engine/fts5.js
nnew file mode 100644
iindex 0000000..473615a
---- /dev/null
++++ b/server/engine/fts5.js
@@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ return esc.split(MARK_OPEN).join('<mark>').split(MARK_CLOSE).join('</mark>');
++}
++
++module.exports = { createEngine, toMatch, snippetHtml };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..e056703
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..b564340
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/store.js b/server/store.js
nnew file mode 100644
iindex 0000000..644eda2
---- /dev/null
++++ b/server/store.js
@@@ -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 };
ddiff --git a/test/acl-leak.test.js b/test/acl-leak.test.js
nnew file mode 100644
iindex 0000000..4749f58
---- /dev/null
++++ b/test/acl-leak.test.js
@@@ -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();
ddiff --git a/test/documents.test.js b/test/documents.test.js
nnew file mode 100644
iindex 0000000..6296b1b
---- /dev/null
++++ b/test/documents.test.js
@@@ -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();
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..5ac6978
---- /dev/null
++++ b/test/helpers.js
@@@ -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,
++};
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
nnew file mode 100644
iindex 0000000..f242975
---- /dev/null
++++ b/test/outbox.test.js
@@@ -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();
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
nnew file mode 100644
iindex 0000000..a15ce10
---- /dev/null
++++ b/test/proposals.test.js
@@@ -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();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..1e30d26
---- /dev/null
++++ b/test/run.js
@@@ -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);
++})();
ddiff --git a/test/webhook-inbox.test.js b/test/webhook-inbox.test.js
nnew file mode 100644
iindex 0000000..b39e81f
---- /dev/null
++++ b/test/webhook-inbox.test.js
@@@ -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();