openvibers/openvibe.watch Commits
OpenVibe.Watch: the service — watch registry, scheduler, conditional-pull check engine (http/feed/api), observations and events (plan T18 steps 2-3). (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,78 @@++# ═══════════════════════════════════════════════════════════════++# OpenVibe.Watch — Environment Configuration++# Copy to .env and fill in values.++# In production this lives at /etc/openvibe/watch.env (0600); the systemd unit sets++# NODE_ENV and PORT itself.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4730++HOST=127.0.0.1++NODE_ENV=development++#BASE_URL=https://openvibe.watch++++# Identity — OpenVibe.Network signs the tokens this service verifies. Service tokens (svc:…) carry++# capability grants (audience openvibe.watch); a person's bearer token (usr_…) is verified for the++# same audience and acts on the watches it owns.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_ISSUER=https://openvibe.network++# The JWKS the service verifies tokens against; by default Network's internal JWKS++# (OV_NETWORK_INTERNAL_URL/api/.well-known/jwks). openvibe-sdk/auth caches it and keeps it fresh.++#OV_NETWORK_JWKS_URL=https://openvibe.network/api/.well-known/jwks++++# Per-actor limits (server/api/actor-limits.js): API reads one app or module (app:…, mod:…) may make per++# minute and per hour; past them 429 rate_limited with Retry-After. First-party services (svc:…) are not++# counted on reads; writes have their own numbers (README).++#WATCH_LIMITS_MINUTE=120++#WATCH_LIMITS_HOUR=3000++++# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish).++OV_OAUTH_CLIENT_ID=watch++OV_OAUTH_CLIENT_SECRET=++++# Store (PostgreSQL, ADR-035), written by OpenVibe.Host roles/data/add-service.sh watch: the runtime role through++# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++++# OpenVibe.Events. The outbox relay runs only when EVENTS_URL is set.++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++++# Check worker (in-process scheduler)++#WATCH_WORKER=on++#WATCH_TICK_MS=5000++#WATCH_MAX_CONCURRENT=8++#WATCH_MAX_BACKOFF_MS=21600000++++# Fetching (the SSRF-guarded fetcher every carrier goes through)++#WATCH_USER_AGENT=OpenVibeWatch/0.1.0 (+https://openvibe.watch; [email protected])++#WATCH_FETCH_TIMEOUT_MS=20000++#WATCH_MAX_BYTES=5242880++#WATCH_MAX_REDIRECTS=5++# Least time between two requests to one host, whatever a watch says.++#WATCH_HOST_MIN_INTERVAL_MS=1000++#WATCH_ALLOWED_PORTS=80,443++# Exact hosts/IPs allowed to resolve to private/loopback addresses. Leave EMPTY in production.++#WATCH_ALLOW_PRIVATE_HOSTS=++++# Observations++#WATCH_OBSERVATIONS_MAX_PER_CHECK=100++#WATCH_RETENTION_DEFAULT_DAYS=90++++# OpenVibe.Run — the expensive checks (browser/code), step 6. Never used until then: a run source++# answers watch.carrier_unavailable and nothing is executed.++#WATCH_RUN_URL=http://127.0.0.1:4920++#WATCH_RUN_OAUTH_CLIENT_SECRET=++++# Source credentials: a watch's source.auth names the variable (auth.env), never the value. Names++# must start with WATCH_CRED_. Read at fetch time, never stored, never logged.++#WATCH_CRED_EXAMPLE_API_KEY=++++# Webhook secrets (step 5): a watch's source.endpoint is signed with the named variable. Names must++# start with WATCH_HOOK_ and hold at least 32 characters.++#WATCH_HOOK_EXAMPLE_SECRET=nnew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ contracts-service: watch++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,10 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.log++*.pem++*.key++node_modulesnnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,310 @@++# OpenVibe.Watch++++> Persistent, user-defined observations and conditions over pages, feeds and APIs: define what to++> watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or++> the site's own ETag will do.++++**Status:** alpha (plan T18 steps 2–3). The registry, the check engine and the pull carriers exist;++the event, webhook, Node and Run rungs and the actions do not yet.++**Domain:** `openvibe.watch` (a short "internal service" page and health on this repository's vhost;++the API is loopback-only). Install steps: [Public host](#public-host).++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete),++the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison,++condition, action, budget, retention".++**License:** AGPL-3.0.++++## Purpose++++Sources ingests the sources a product chose; Watch is the place a **person or an agent** defines++their own observation: "this page's price", "this feed's latest release", "this API's stock", and++the condition on it. Sources performs permitted structured ingestion; **Watch owns user-defined++persistent observations and conditions**; Run executes expensive browser/code checks; Node observes++local/LAN things; Actor interprets and acts; Events transports the trigger.++++A Watch is a first-class OpenVibe resource (`watch.watch@1`), so products compose it instead of++each growing a watcher: Rent, Homes, Work, Deals, Reviews, price trackers, uptime, releases.++++## Running it++++```bash++npm install++cp .env.example .env++npm run dev # http://127.0.0.1:4730++npm test # every test/*.test.js against local stub sites; no internet++npm run test:pg # the same through PostgreSQL + PgBouncer (the CI path)++```++++Node 22 in production. Production: `/opt/openvibe.watch`, env `/etc/openvibe/watch.env`, unit++[deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service), database `ov_watch`++on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh watch`), nginx++[deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf).++++`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the++database fails; a Network key that has not loaded and a check worker that is off, stopped or behind++(a watch due for more than 15 minutes) degrade it. It reports watch counts by health status, checks++in flight, observations by state and the outbox backlog. `GET /metrics` (openvibe-shared/metrics)++answers direct loopback callers only: golden signals by route template, `watch_watches{status}`,++`watch_observations{state}`, the check queue (`watch_checks_due`,++`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),++`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.++++## The watch model++++A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,++`action`, `budget`, `retention`, plus `owner`, `status`, `labels`. It is validated against++`watch.watch-request@1` on the way in and served as `watch.watch@1` on the way out — both with++openvibe-contracts' own validator, never a hand copy of the schema. Rules a schema cannot carry++([server/registry.js](server/registry.js)): a create requires source, extraction, condition and++action; a source that polls (`http`, `feed`, `api`, `node`, `run`) needs `cadence.every_sec`; an++`event` or `webhook` source is pushed to and never polls (`cadence: null`); the headers the fetcher++owns (`Host`, `Cookie`, `User-Agent`, `If-None-Match`, …) cannot be overridden.++++**Ownership is the authority.** Every read and write is scoped to the acting subject — a person's++`usr_…`/`agt_…` bearer token, or a first-party service acting for a person with `X-OV-Subject` —++and another owner's watch is **404, never 403**: an id must not confirm that someone else's watch++exists. A service principal that does not name a person owns what it creates.++++## Carrier preference (binding, plan T18)++++ webhook/event → ETag/Last-Modified → feed → API → and only then browser polling through Run++++The rungs run from cheapest to most expensive, and `source.kind` fixes which one a watch uses.++This release carries the pull rungs:++++| Kind | Carrier | State |++|---|---|---|++| `http` | one URL, conditional | implemented |++| `feed` | RSS 2.0 / RSS 1.0 (RDF) / Atom 1.0 → `{ items, latest, count }` | implemented |++| `api` | JSON/XML with `items_path` + `fields`, or the body itself | implemented |++| `event` | delivered by OpenVibe.Events | step 5 |++| `webhook` | delivered to `/internal/hooks/…` | step 5 |++| `run` | browser/code polling through OpenVibe.Run | step 6 |++| `node` | a probe through OpenVibe.Node | step 7 |++++A kind whose carrier does not exist yet is **accepted by the registry but never fetched**: the++check is recorded `skipped` with `watch.carrier_unavailable` and the reason names the step. That is++what keeps a half-built rung from silently turning into polling.++++## A check++++An in-process scheduler ([server/scheduler.js](server/scheduler.js)) starts checks for active++watches whose `next_due_at` and `not_before` have passed, oldest first, at most++`WATCH_MAX_CONCURRENT` at a time; `check.run()` refuses a second check of the same watch, so a++manual `POST …/check` never overlaps a scheduled one. One check++([server/check.js](server/check.js)):++++1. **Carrier** chosen by `source.kind` (never for event/webhook/node/run — see above).++2. **Fetch** through the one way out: the SSRF guard, per-host spacing, conditional GET.++ - `If-None-Match` / `If-Modified-Since` from the stored validators; a **304 → `not_modified`**++ (no body, no observation).++ - When the watch only needs to know *whether* it changed (`condition.op: changed`), a **HEAD**++ is tried first while a validator is stored: two headers instead of a body.++ - A 200 whose body hashes to the last body → **`no_change`** (no observation).++ - 429/503 `Retry-After` → `not_before`, so Watch waits as long as the site asked.++3. **Extract** the value (table below) from the body, or from the records a feed/api carrier mapped.++4. **Compare and evaluate**: `changed` (with `tolerance`/`percent`), the predicate, and the++ `for_sec` debounce measured from the observation trail — an unchanged body still evaluates the++ condition, so "this has been true for five minutes" can fire without the page changing.++5. **Record**, in ONE transaction: the `check_runs` row, the `observations` row (value, hashes,++ changed, condition_met, capped snapshot, `retained_until`) and the events.++6. **Arm** `next_due_at` (the cadence plus jitter, or an exponential backoff after failures, capped++ at `WATCH_MAX_BACKOFF_MS`).++++A condition that fires is recorded on the check run (`state: condition_met`, `triggers: 1`) and++emits `watch.watch.triggered`; it fires again only for a value that is new or for a new run of++holding, never once per check while nothing moves. **Actions are step 4 of the plan**: a fired++condition records the trigger and emits the event, and nothing else is dispatched yet.++++Nothing is invented: a value the source did not state is `null`, a failed check is a recorded state,++and the validators/body hash are stored only after a body was read successfully, so an unreadable++body is fetched and parsed again next time.++++`check_runs.state` is one of `ok | not_modified | no_change | changed | condition_met | http_error |++timeout | parse_error | rate_limited | budget_exceeded | skipped | disabled`; every check re-arms the++watch on every terminal path.++++### Extraction (`watch.watch@1` `$defs/extraction`)++++| Kind | Reads |++|---|---|++| `text` | the whole body as text, trimmed (capped at 64 KiB) |++| `html` | the body's text, or the text of the first element `selector` matches |++| `css` | the same, with the small selector subset: `tag`, `.class`, `#id`, `[attr]`, `[attr=value]`, descendants and `>` |++| `json` / `jsonpath` | the parsed body, or a carrier's mapped `{ items, latest, count }`; `value_path` picks a part (`latest.price`, `items.0.title`), `fields` several at once |++| `regex` | the first match of `selector` (its first group when it has one), e.g. `(\d+\.\d+) EUR` |++| `ai` | **not a check**: an AI reading is an action (step 10). The watch is accepted, the check is recorded `skipped` with `watch.extraction_unavailable`, and nothing is fetched |++++A path that matches nothing and a pattern that does not match extract `null` — which `absent` then++answers truthfully.++++### Conditions (`watch.watch@1` `$defs/condition`)++++`changed` (the default), `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `contains`, `matches`, `exists`,++`absent`; `for_sec` debounces a condition that must hold that long. Comparison refuses to guess: a++value that is not a number (or a plain decimal string) is never "greater than" anything, and an++unknown value is never low — the same rule Deals' watches use. `comparison.tolerance` (absolute)++and `comparison.percent` decide whether a move counts as a change at all.++++### Observations++++Immutable rows (`watch.observation@1`): the extracted `value`, its SHA-256 `value_hash` over the++canonical JSON, `previous_hash`, `changed`, `condition_met`, a capped `snapshot` of the body it came++from, and `retained_until` (the watch's `retention.observations_days`, `WATCH_RETENTION_DEFAULT_DAYS`++= 90 by default; `keep_snapshots: false` stores no snapshot). An hourly prune drops what is past its++retention.++++## API (bearer tokens, one capability per route)++++Callers present an OpenVibe.Network token for audience `openvibe.watch`: a service's++client-credentials token (judged by its `cap` claim with the contracts grant rule) or a signed-in++person's or agent's token (`usr_…`, `agt_…` — a watch is theirs, and ownership scoping is the++authority). A first-party service (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person;++an app or module may not.++++| Capability | Routes |++|---|---|++| `watch.watch.read` | `GET /api/v1/watches?status=&project_id=&before=&limit=`, `GET /api/v1/watches/:id` |++| `watch.watch.manage` | `POST /api/v1/watches`, `PATCH /api/v1/watches/:id`, `POST /api/v1/watches/:id/pause`, `POST /api/v1/watches/:id/resume`, `DELETE /api/v1/watches/:id` |++| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |++| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`) |++++The list answer is exactly `watch.watch-result@1` (`{ watches }`): page with++`before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view,++observation view and check-run view is validated against its released contract before it is sent.++Errors are problem+json (`watch.not_found` 404, `watch.invalid` 422 with the validator's detail).++++### Per-actor limits++++Every route also limits the principal that passed its capability guard, before any work:++[server/api/actor-limits.js](server/api/actor-limits.js), openvibe-sdk/limits. Past a limit: `429`++problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and++`watch_rate_limited_total{limit,window}`.++++| Routes | Per principal, a minute / an hour |++|---|---|++| Reads by an app or module (`app:…`, `mod:…`) | `WATCH_LIMITS_MINUTE` / `WATCH_LIMITS_HOUR` (120 / 3000) |++| `POST`/`PATCH`/`DELETE /api/v1/watches[/:id]`, pause, resume | 30 / 600 |++| `POST /api/v1/watches/:id/check` | 10 / 120 |++++A first-party service (`svc:…`) is not counted on reads: the public site and agent runtimes read for++many people, and one budget for the whole service would refuse real people's work. Watch's own++worker (the scheduler, the carriers, the outbox relay) runs in-process and never passes through++HTTP. Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the home page.++++## Events (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set)++++- `watch.observation.recorded` (low) — a check recorded an observation, with the value as the source++ stated it and its hash, never the raw body.++- `watch.watch.triggered` (important) — a condition fired, with the observation that fired it. This++ is what OpenVibe.Network turns into a `WATCH_TRIGGERED` notification for `payload.recipient`.++- `watch.check.failed` (important) — a check ended in a failure state, with `consecutive_failures`++ so a consumer can tell a blip from a watch that is down.++++All `visibility: internal`, subject `{ type: watch, id: <watch_id> }`, actor `service:watch`,++envelopes validated as `events.event-envelope@1` and payloads against their released contracts. The++watch-lifecycle events (`watch.watch.created`, `watch.watch.updated`, `watch.watch.paused`,++`watch.watch.removed`) are emitted on the registry's writes; they have no payload contract yet.++++A watch owned by a service (`svc:…`) still records every observation and trigger in its own tables,++but the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), so++those two events are logged as not emitted rather than sent with a payload the contract refuses.++++## Security++++Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:++++- **Auth.** Every `/api/v1` route needs a token for audience `openvibe.watch` with that route's ONE++ capability (`svc:…` by capability grant, `usr_…`/`agt_…` by identity), then passes the per-actor++ limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*`++ loopback-only.++- **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers++ 404 on every route.++- **Egress.** A watch fetches outside URLs by design, only through the guard:++ [server/net/guard.js](server/net/guard.js), http(s) on `WATCH_ALLOWED_PORTS` (80/443), every++ resolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one++ deadline and a byte cap per response. Exactly one path reaches the network — the carriers, through++ the fetcher — and only for a kind whose carrier exists. `test/carriers.test.js`.++- **Secrets.** A source's credential is the **name** of a `WATCH_CRED_*` variable, read at fetch++ time, sent only to the configured origin, never stored or logged. `OV_OAUTH_CLIENT_SECRET` and the++ credentials live in `/etc/openvibe/watch.env` (0600).++- **Data.** An observation records what the source stated, never a guess; snapshots are capped and++ pruned by retention; a failed check never records an observation.++++## Owns++++- the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` /++ `watch.watch-request@1` / `watch.watch-result@1` shapes it serves++- checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff++- `watch.*` events++++## Does not own++++- structured source ingestion for publication (OpenVibe.Sources' registry and adapters): Watch++ observes what one person or agent asked for, not what a product publishes++- expensive browser/code execution (OpenVibe.Run, step 6) and local/LAN probes (OpenVibe.Node,++ step 7) — Watch asks them, they run++- interpreting a trigger and acting on it (OpenVibe.Actor, OpenVibe.Codes, actions = step 4)++- delivery of a notification (OpenVibe.Network consumes `watch.watch.triggered`)++++## Depends on++++- PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through++ `openvibe-sdk/db`++- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,++ the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`)++- OpenVibe.Network (signing key; service principal `watch`)++- OpenVibe.Events (outbound events)++++## Capabilities++++Implemented here (the service manifest's `capabilities`; routes in [API](#api-bearer-tokens-one-capability-per-route)):++`watch.watch.read`, `watch.watch.manage`, `watch.observation.read` and `watch.check.run`, each++checked by [server/auth.js](server/auth.js) with the contracts grant rule for audience++`openvibe.watch`.++++Called elsewhere, as the service principal `watch` (client credentials from OpenVibe.Network):++++| Service | Grant | Why |++|---|---|---|++| OpenVibe.Events | `events.event.publish` (audience `openvibe.events`) | the outbox relays `watch.observation.recorded`, `watch.watch.triggered`, `watch.check.failed` and the lifecycle events |++| OpenVibe.Run | `run.*` (plan T18 step 6) | not yet: a `run` source answers `watch.carrier_unavailable` today, and `WATCH_RUN_URL` (`http://127.0.0.1:4920`) is where it will call |++++## Acceptance (tests)++++- the release contract on both sides: bodies validated against `watch.watch-request@1`, every watch,++ observation and check-run view against `watch.watch@1` / `watch.observation@1` /++ `watch.check-run@1`, and every emitted payload against its released event contract++ (`test/api.test.js`, `test/registry.test.js`, `test/check.test.js`)++- conditional GET: validators stored, 304 → `not_modified`, identical body → `no_change`, a HEAD++ answers `changed` without a body (`test/carriers.test.js`)++- the guard refuses a private host before any socket, a redirect into a private address, a port++ outside `WATCH_ALLOWED_PORTS`, URL credentials (`test/carriers.test.js`)++- credentials by variable name, read at fetch time, never stored, missing → `disabled` with no++ request (`test/carriers.test.js`)++- feeds and mapped APIs, `items_path`/`fields`, and the parse errors they can raise++ (`test/carriers.test.js`)++- every condition op, tolerance and percent, `for_sec` debounce (`test/condition.test.js`); every++ extraction kind, the selector subset, and `ai` refusing (`test/extract.test.js`)++- a check writes its run and its observation and emits `watch.observation.recorded`; a fired++ condition emits `watch.watch.triggered`; failures emit `watch.check.failed` and back off;++ event/webhook/node/run sources and an `ai` extraction are never fetched; retention prune++ (`test/check.test.js`)++- due order, the concurrency cap, `not_before`, backoff, and the worker checking on its own tick++ (`test/scheduler.test.js`)++- the API: one capability per route, owner scoping 404, 422 from the validator, pause/resume/delete,++ X-OV-Subject delegation, the check and observation reads, per-actor 429 (`test/api.test.js`)++- the registry: required fields, cadence rules, forbidden headers, due selection, re-arm, soft++ delete, the keyset page (`test/registry.test.js`)++++Not yet demonstrated: a check against a real site from a deployed service (nothing is deployed++yet), the event/webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the++actions (step 4) — each answers `watch.carrier_unavailable` and fetches nothing today.++++## Public host++++`openvibe.watch` is an internal service, like `sources.openvibe.network`: its vhost answers only++`/`, `/api/health`, `/api/ready`, `/release.json` and `/robots.txt`, and keeps `/api/v1/*`++loopback-only (`deploy/nginx/openvibe.watch.conf`). Install: copy the unit and the vhost, write++`/etc/openvibe/watch.env` (0600) from `.env.example`, run++`sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then++`sudo ovhost deploy watch`.nnew file mode 100644@@@ -0,0 +1,21 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,++openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable++contact details are at `/.well-known/security.txt` on each site.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,52 @@++{++ "repository": "OpenVibers/OpenVibe.Watch",++ "stage": "alpha",++ "code": true,++ "wave": "W18",++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026) — T18 Watch complete, steps 2-3",++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",++ "port": 4730,++ "domain": "openvibe.watch (vhost in deploy/nginx: an internal-service page, health and JSON 404s; the API is loopback-only; README \"Public host\")",++ "contracts": "openvibe-contracts v0.107.0",++ "capabilities": [++ "watch.watch.read",++ "watch.watch.manage",++ "watch.observation.read",++ "watch.check.run"++ ],++ "capabilitiesRegistered": true,++ "contractsUsed": [++ "watch.watch@1, watch.watch-request@1, watch.watch-result@1",++ "watch.observation@1, watch.check-run@1",++ "watch.observation.recorded@1, watch.watch.triggered@1, watch.check.failed@1"++ ],++ "carriers": [++ "http",++ "feed",++ "api"++ ],++ "carriersPending": [++ "event (step 5)",++ "webhook (step 5)",++ "run (step 6)",++ "node (step 7)"++ ],++ "seededWatches": 0,++ "deployed": false,++ "deployment": "Not deployed. Plan T18 steps 2-3 are implemented and tested in the repository (registry, check engine, http/feed/api carriers, conditional GET, extraction, conditions, observations, check runs, the API, the events); nothing runs on the host yet, and openvibe.watch has no DNS or vhost installed.",++ "features": [++ "watch registry with owner scoping (404 for another owner) validated against the released contracts",++ "binding carrier preference order: webhook/event → ETag/Last-Modified → feed → API → Run",++ "conditional GET: If-None-Match/If-Modified-Since, HEAD-first when only change is needed, 304 and unchanged-body short circuits",++ "SSRF-guarded fetcher (ports, private hosts, every redirect hop, byte cap, one deadline) and per-host spacing",++ "feed (RSS/Atom) and mapped-API carriers",++ "extraction: text, html, css subset, json/jsonpath, regex; ai refused as a later step",++ "conditions: changed/threshold with tolerance and percent, for_sec debounce over the observation trail",++ "observations with value hashes, capped snapshots and retention",++ "check runs with the full terminal-state set, exponential backoff and Retry-After",++ "POST /api/v1/watches/:id/check plus the observations and checks reads",++ "watch.* events through the outbox in the same transaction as the state change",++ "per-actor limits (openvibe-sdk/limits)"++ ],++ "updated": "2026-10-07"++}nnew file mode 100644@@@ -0,0 +1,93 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.watch — Nginx Configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.watch.conf — keep them in sync,++## and reload nginx after changing it. Install: README "Public host".++##++## Watch is an internal service: it holds what people and agents watch and when their conditions++## fired. The public host answers only:++## / a short page saying what the service is (HTML for browsers, text otherwise)++## /api/health, /api/ready, /release.json++## /robots.txt disallow everything++## and keeps the rest off the public edge:++## /api/v1/* service-to-service API (bearer token, one capability per route): loopback++## only. Services on this host call 127.0.0.1:4730 directly.++## /internal/hooks/* webhook deliveries (plan T18 step 5): not exposed yet; when the carrier++## lands it goes here with its own signature check and no /api/v1 reach.++## /metrics 404 here; Prometheus text for direct loopback callers of 127.0.0.1:4730++## anything else a JSON 404 that says why, so the name never falls through to another site++## ═══════════════════════════════════════════════════════════════++++# ── HTTP → HTTPS redirect ──────────────────────────────────++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.watch;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.watch$request_uri; }++}++++# ── HTTPS server ───────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.watch;++++ # The host has the openvibe.network wildcard certificate, not a per-name one.++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.watch.access.log;++ error_log /var/log/nginx/openvibe.watch.error.log;++++ client_max_body_size 256k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++ add_header X-Robots-Tag "noindex, nofollow" always;++++ # Client address only from $remote_addr (realip has already replaced it with the visitor's++ # address for Cloudflare's ranges); never append to or pass through a client-sent header.++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ location = /api/health { proxy_pass http://127.0.0.1:4730; }++ location = /api/ready { proxy_pass http://127.0.0.1:4730; }++ location = /release.json { proxy_pass http://127.0.0.1:4730; }++++ # Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics).++ location = /metrics { return 404; }++++ location = / {++ limit_except GET { deny all; }++ proxy_pass http://127.0.0.1:4730;++ }++++ location = /robots.txt {++ default_type text/plain;++ return 200 "User-agent: *\nDisallow: /\n";++ }++++ # ── Service API: loopback only (see header) ─────────────++ location ^~ /api/v1/ {++ allow 127.0.0.1;++ allow ::1;++ deny all;++ proxy_pass http://127.0.0.1:4730;++ }++++ # ── Anything else: an honest JSON 404, never another site ──++ location / {++ default_type application/problem+json;++ return 404 '{"type":"about:blank","title":"Not Found","status":404,"code":"watch.not_found","detail":"openvibe.watch is an internal service of the OpenVibe network with no public pages or API. Its API is for OpenVibe services and their signed-in people on the host. About: https://openvibe.watch/ and https://github.com/OpenVibers/OpenVibe.Watch"}';++ }++}nnew file mode 100644@@@ -0,0 +1,32 @@++[Unit]++Description=OpenVibe.Watch — persistent user-defined observations and conditions (pages, feeds, APIs)++# Network (openvibe-network) signs the tokens this service verifies, and Events carries watch.*++# events. Watch starts without either: the SDK's JWKS client keeps the last good keys and backs++# off (/api/ready degrades while no key has loaded), and the outbox waits for the relay.++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.watch++EnvironmentFile=/etc/openvibe/watch.env++Environment=NODE_ENV=production++Environment=PORT=4730++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.watch/server/index.js++Restart=always++RestartSec=5++# SIGTERM: the scheduler stops starting checks; checks in flight finish (each is bounded by the++# fetch timeout, and the graceful drain gives them 15 s).++KillSignal=SIGTERM++TimeoutStopSec=25++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# The code tree stays read-only; watches, checks and observations live in PostgreSQL (ADR-035).++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,97 @@++-- phase: expand++-- OpenVibe.Watch on PostgreSQL (ADR-035). Additive only; never edited after it runs.++--++-- The whole model on purpose (plan T18 step 2): the watch registry, where each watch's conditional++-- fetch state lives, what every check recorded as a run and as an observation, and the outbox that++-- carries watch.observation.recorded / watch.watch.triggered / watch.check.failed. Later steps of++-- T18 (actions, event and webhook carriers, Run) add code, not tables.++++CREATE TABLE watches (++ id text COLLATE "C" PRIMARY KEY, -- wch_<ULID>++ project_id text COLLATE "C",++ owner_sub text COLLATE "C" NOT NULL, -- usr_… / agt_… / svc:…++ name text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','disabled','failed')),++ source jsonb NOT NULL, -- watch.watch@1 source (validated in code against the contract)++ cadence jsonb, -- {every_sec,jitter_sec,timezone}; NULL for event/webhook++ extraction jsonb NOT NULL DEFAULT '{}',++ comparison jsonb,++ condition jsonb NOT NULL DEFAULT '{}',++ action jsonb NOT NULL DEFAULT '[]',++ budget jsonb NOT NULL DEFAULT '{}',++ retention jsonb NOT NULL DEFAULT '{}',++ labels jsonb NOT NULL DEFAULT '{}',++ -- runtime state++ next_due_at bigint NOT NULL DEFAULT 0,++ not_before bigint NOT NULL DEFAULT 0, -- Retry-After / rate-limit wait++ last_check_at bigint,++ last_success_at bigint,++ last_state text COLLATE "C",++ consecutive_failures bigint NOT NULL DEFAULT 0,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ updated_by text COLLATE "C",++ deleted_at bigint++);++CREATE INDEX idx_watches_due ON watches (next_due_at) WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL;++CREATE INDEX idx_watches_owner ON watches (owner_sub, created_at DESC);++++CREATE TABLE watch_endpoint_state (++ watch_id text COLLATE "C" NOT NULL,++ url text COLLATE "C" NOT NULL,++ etag text COLLATE "C",++ last_modified text COLLATE "C",++ last_status bigint,++ last_fetch_at bigint,++ last_body_hash text COLLATE "C",++ PRIMARY KEY (watch_id, url)++);++++CREATE TABLE check_runs (++ rid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id text COLLATE "C" NOT NULL UNIQUE, -- ckr_<ULID>++ watch_id text COLLATE "C" NOT NULL,++ trigger text COLLATE "C" NOT NULL CHECK (trigger IN ('schedule','manual','event','webhook')),++ carrier text COLLATE "C", -- event|webhook|http|feed|api|node|run++ started_at bigint NOT NULL,++ finished_at bigint NOT NULL,++ state text COLLATE "C" NOT NULL CHECK (state IN ('ok','not_modified','no_change','changed','condition_met','http_error','timeout','parse_error','rate_limited','budget_exceeded','skipped','disabled')),++ http_status bigint,++ error_code text COLLATE "C",++ detail text COLLATE "C",++ bytes bigint,++ raw_body_hash text COLLATE "C",++ cost_usd numeric(12,6) NOT NULL DEFAULT 0,++ observations bigint NOT NULL DEFAULT 0,++ triggers bigint NOT NULL DEFAULT 0++);++CREATE INDEX idx_check_runs_watch ON check_runs (watch_id, rid DESC);++++CREATE TABLE observations (++ rid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id text COLLATE "C" NOT NULL UNIQUE, -- wco_<ULID>++ watch_id text COLLATE "C" NOT NULL,++ check_run_id text COLLATE "C" NOT NULL,++ observed_at bigint NOT NULL,++ value jsonb NOT NULL,++ value_hash text COLLATE "C" NOT NULL,++ previous_hash text COLLATE "C",++ changed bigint NOT NULL DEFAULT 0,++ condition_met bigint NOT NULL DEFAULT 0,++ snapshot text COLLATE "C",++ retained_until bigint++);++CREATE INDEX idx_observations_watch ON observations (watch_id, rid DESC);++CREATE INDEX idx_observations_retain ON observations (retained_until);++++CREATE TABLE counters ( name text COLLATE "C" PRIMARY KEY, value bigint NOT NULL );++++-- server/events/outbox.js ensureSchema() (identical to OpenVibe.Sources)++CREATE TABLE event_outbox (++ seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text COLLATE "C" NOT NULL UNIQUE, event_type text COLLATE "C" NOT NULL,++ envelope text COLLATE "C" NOT NULL, created_at bigint NOT NULL,++ attempts bigint NOT NULL DEFAULT 0, next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint, rejected_at bigint, last_error text COLLATE "C"++);++CREATE INDEX idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);nnew file mode 100644@@@ -0,0 +1,1287 @@++{++ "name": "openvibe-watch",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-watch",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "fast-xml-parser": "^4.5.3",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "ajv": "^8.20.0",++ "ajv-formats": "^3.0.1"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/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/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/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/fast-xml-parser": {++ "version": "4.5.7",++ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz",++ "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/NaturalIntelligence"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "strnum": "^1.0.5"++ },++ "bin": {++ "fxparser": "src/cli/cli.js"++ }++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.107.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",++ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "2.11.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/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/strnum": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",++ "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/NaturalIntelligence"++ }++ ],++ "license": "MIT"++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,40 @@++{++ "name": "openvibe-watch",++ "version": "0.1.0",++ "private": true,++ "main": "server/index.js",++ "description": "OpenVibe.Watch: persistent user-defined observations and conditions over pages, feeds, APIs, events and devices.",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "WATCH_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe",++ "watch",++ "monitoring",++ "observations",++ "conditions"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Watch",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "fast-xml-parser": "^4.5.3",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "ajv": "^8.20.0",++ "ajv-formats": "^3.0.1",++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * Per-actor rate limits on /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * Every API route takes a token and one capability (auth.js); the limits count requests by the++ * principal that passed that guard (req.principal.sub: svc:watch, a person's usr_…, an app:app_…),++ * before the route does any work. Watch's own worker (the scheduler, the carriers, the outbox++ * relay) runs in this process and never goes through HTTP, so nothing here can slow a check.++ *++ * Reads (watch.watch.read, watch.observation.read) take WATCH_LIMITS_MINUTE / WATCH_LIMITS_HOUR,++ * 120 and 3000, for an app or module (app:…, mod:…). A first-party service (svc:…) is not counted++ * on reads: the public site and an agent's runtime read for many people, and one budget for the++ * whole service would refuse real people's work.++ *++ * Writes (watch.watch.manage) and manual checks (watch.check.run) are counted for every principal++ * with the budgets below: a person creating watches from a page, or a service nudging a check.++ *++ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After; the refusal is++ * logged once and counted in watch_rate_limited_total{limit,window}. Counters live in this process:++ * a restart forgets them. Never limited: /api/health, /api/ready, /release.json, /metrics and the++ * home page.++ */++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++++const FIRST_PARTY = /^svc:/;++++function actor(req) {++ const p = req.principal;++ if (p && typeof p.sub === 'string' && p.sub) return p.sub;++ return defaultActor(req);++}++++/** Counted on reads: anything but a first-party service (an app or module acting as itself). */++function countedRead(req) {++ const p = req.principal;++ return !(p && FIRST_PARTY.test(String(p.sub)));++}++++/** The writes, each with its numbers per principal (a minute, an hour). */++const BUDGETS = {++ // Creating, changing, pausing or deleting a watch is a deliberate act by a person or a service++ // acting for one: a form every few seconds at most.++ 'watch.write': { minute: 30, hour: 600 },++ // A manual check spends the watched site's bandwidth now (the per-host spacing still applies),++ // so it is rarer than a write and always bounded by the watch's own cadence.++ 'watch.check.run': { minute: 10, hour: 120 },++};++++/**++ * limits(name, own) middleware, plus limits.reads(name) (the defaults on a counted read) and++ * limits.budget(name) (one of BUDGETS).++ */++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'watch_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const limiter = createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor,++ now,++ onLimited(e) {++ // The actor is a principal (or an address), never a token.++ log.warn(`[limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name) => {++ const limit = limiter(name);++ return function actorReadLimit(req, res, next) { return countedRead(req) ? limit(req, res, next) : next(); };++ };++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));++ limiter.budget = (name) => {++ const m = budgets.get(name);++ if (!m) throw new Error(`limits: no budget named ${name}`);++ return m;++ };++ return limiter;++}++++module.exports = { createActorLimits, actor, countedRead, BUDGETS };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * Checks, observations and check runs (one capability per route).++ *++ * watch.check.run++ * POST /api/v1/watches/:id/check run one check now, outside the cadence++ * watch.observation.read++ * GET /api/v1/watches/:id/observations?before=&limit= newest first, cursor by rid++ * GET /api/v1/watches/:id/checks?before=&limit= the check runs, newest first++ *++ * A manual check of a watch that is paused, disabled or failed is refused with 409: it is recorded++ * as `disabled` in the check runs, and nothing is fetched. A source whose carrier is a later step++ * (event, webhook, node, run) answers 422 watch.carrier_unavailable — also without fetching.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { CAPS } = require('../auth');++++function checksRouter({ registry, check, observations, auth, limits }) {++ const router = express.Router();++ const read = [auth.requireCap(CAPS.observations), limits.reads('watch.read')];++ const runNow = [auth.requireCap(CAPS.run), limits.budget('watch.check.run')];++++ const notFound = (res, req) => http.sendProblem(res, 404, 'watch.not_found', { detail: 'no such watch', ctx: req.ov });++ const before = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);++ const pageLimit = (q, d = 50, max = 200) => Math.min(Math.max(parseInt(q, 10) || d, 1), max);++++ /** The watch must exist and belong to the acting subject before anything else happens. */++ const owned = async (req, res) => {++ const row = await registry.get(req.params.id, req.principal);++ if (!row) { notFound(res, req); return null; }++ return row;++ };++++ router.post('/api/v1/watches/:id/check', runNow, async (req, res) => {++ const row = await owned(req, res);++ if (!row) return;++ const out = await check.run(row.id, { trigger: 'manual' });++ if (!out) return notFound(res, req);++ if (out.busy) return http.sendProblem(res, 409, 'watch.busy', { detail: 'a check of this watch is already running', ctx: req.ov });++ if (out.disabled) return http.sendProblem(res, 409, 'watch.disabled', { detail: `the watch is ${row.status}: resume it first`, ctx: req.ov });++ if (out.unavailable) return http.sendProblem(res, 422, out.unavailable.code, { detail: out.unavailable.detail, ctx: req.ov });++ return res.json(check.runView(out.run));++ });++++ router.get('/api/v1/watches/:id/observations', read, async (req, res) => {++ const row = await owned(req, res);++ if (!row) return;++ const limit = pageLimit(req.query.limit);++ const rows = await observations.list(row.id, { before: before(req.query.before), limit });++ res.json({++ observations: rows.map(observations.view),++ next_before: rows.length === limit ? rows[rows.length - 1].rid : null,++ });++ });++++ router.get('/api/v1/watches/:id/checks', read, async (req, res) => {++ const row = await owned(req, res);++ if (!row) return;++ const limit = pageLimit(req.query.limit);++ const rows = await check.list(row.id, { before: before(req.query.before), limit });++ res.json({++ checks: rows.map(check.runView),++ next_before: rows.length === limit ? rows[rows.length - 1].rid : null,++ });++ });++++ return router;++}++++module.exports = { checksRouter };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * The watch registry API (one capability per route; every answer is scoped to the acting subject).++ *++ * watch.watch.read++ * GET /api/v1/watches?status=&project_id=&before=&limit= the acting subject's watches++ * GET /api/v1/watches/:id++ * watch.watch.manage++ * POST /api/v1/watches create (source, extraction, condition and action required)++ * PATCH /api/v1/watches/:id change fields; pause/resume through status++ * POST /api/v1/watches/:id/pause status = paused++ * POST /api/v1/watches/:id/resume status = active (and re-armed)++ * DELETE /api/v1/watches/:id soft delete (observations stay resolvable)++ *++ * Another owner's watch is 404, never 403: an id must not confirm that someone else's watch exists.++ * Errors are problem+json (http.sendProblem): watch.not_found, watch.invalid (422 from the released++ * watch.watch-request@1 validator), watch.exists (409, never used by this route shaping but kept++ * for the same shape as Sources), and the per-actor limit's 429.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { CAPS } = require('../auth');++const { RegistryError, STATUSES } = require('../registry');++++function watchesRouter({ registry, auth, limits }) {++ const router = express.Router();++ const read = [auth.requireCap(CAPS.read), limits.reads('watch.read')];++ const manage = [auth.requireCap(CAPS.manage), limits.budget('watch.write')];++++ const fail = (res, req, err) => {++ if (err instanceof RegistryError) {++ const status = err.code === 'watch.internal' ? 500 : (err.code === 'watch.exists' ? 409 : 422);++ return http.sendProblem(res, status, err.code, { detail: err.message, ctx: req.ov });++ }++ throw err;++ };++ const notFound = (res, req) => http.sendProblem(res, 404, 'watch.not_found', { detail: 'no such watch', ctx: req.ov });++++ router.get('/api/v1/watches', read, async (req, res) => {++ const status = req.query.status ? String(req.query.status) : null;++ if (status && !STATUSES.includes(status)) {++ return http.sendProblem(res, 400, 'watch.bad_query', { detail: `status must be one of ${STATUSES.join('|')}`, ctx: req.ov });++ }++ const before = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);++ const rows = await registry.all(req.principal, {++ status,++ project_id: req.query.project_id ? String(req.query.project_id) : null,++ before,++ limit,++ });++ // The answer is exactly watch.watch-result@1 ("{ watches }"), so the page cursor is the last++ // watch's own id: `GET /api/v1/watches?before=<that id>` is the next page (wch_ ids are ULIDs,++ // i.e. creation order).++ res.json({ watches: rows.map(registry.view) });++ });++++ router.get('/api/v1/watches/:id', read, async (req, res) => {++ const row = await registry.get(req.params.id, req.principal);++ if (!row) return notFound(res, req);++ res.json({ watch: registry.view(row) });++ });++++ router.post('/api/v1/watches', manage, async (req, res) => {++ try {++ const row = await registry.create(req.body, req.principal);++ res.status(201).json({ watch: registry.view(row) });++ } catch (err) { fail(res, req, err); }++ });++++ router.patch('/api/v1/watches/:id', manage, async (req, res) => {++ try {++ const row = await registry.patch(req.params.id, req.body, req.principal);++ if (!row) return notFound(res, req);++ res.json({ watch: registry.view(row) });++ } catch (err) { fail(res, req, err); }++ });++++ /** pause/resume are the two status changes a person makes by name. */++ const setStatus = (status) => async (req, res) => {++ try {++ const row = await registry.patch(req.params.id, { status }, req.principal);++ if (!row) return notFound(res, req);++ res.json({ id: row.id, status: row.status });++ } catch (err) { fail(res, req, err); }++ };++ router.post('/api/v1/watches/:id/pause', manage, setStatus('paused'));++ router.post('/api/v1/watches/:id/resume', manage, setStatus('active'));++++ router.delete('/api/v1/watches/:id', manage, async (req, res) => {++ try {++ const out = await registry.remove(req.params.id, req.principal);++ if (!out) return notFound(res, req);++ res.json(out);++ } catch (err) { fail(res, req, err); }++ });++++ return router;++}++++module.exports = { watchesRouter };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/** Express app: request context, the v1 API, health/readiness, metrics. */++const path = require('path');++const express = require('express');++const { http } = require('openvibe-contracts');++const { instrument } = require('openvibe-shared/metrics');++const { createRelease } = require('openvibe-shared/release');++const { createWatchReadiness, registerWatchGauges } = require('./observability');++const { watchesRouter } = require('./api/watches');++const { checksRouter } = require('./api/checks');++const { createActorLimits } = require('./api/actor-limits');++const pkg = require('../package.json');++++const TEXT_INDEX = [++ 'OpenVibe.Watch: persistent user-defined observations and conditions over pages, feeds and APIs.',++ 'A watch states what it observes (source), how often or what pushes to it (cadence / event pattern),',++ 'what value it takes (extraction), how that value is compared and when the condition fires, and what',++ 'it wakes. A watch never polls when an event or a webhook will do, and it never fabricates a value:',++ 'a check that failed is recorded as failed, and an absent value stays absent.',++ '',++ 'This is an internal service. Its API is for OpenVibe services and their signed-in people on the',++ 'production host (bearer tokens); openvibe.watch answers only this page, /api/health, /api/ready and',++ '/release.json.',++ '',++ 'GET /api/v1/watches, /api/v1/watches/:id (watch.watch.read)',++ 'POST/PATCH/DELETE /api/v1/watches[/:id], POST /api/v1/watches/:id/pause, /resume (watch.watch.manage)',++ 'GET /api/v1/watches/:id/observations, /api/v1/watches/:id/checks (watch.observation.read)',++ 'POST /api/v1/watches/:id/check (watch.check.run)',++ 'GET /api/health, /api/ready, /release.json',++ '',++ 'Source: https://github.com/OpenVibers/OpenVibe.Watch',++ '',++].join('\n');++++const HOME_HTML = `<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<meta name="robots" content="noindex, nofollow">++<meta name="color-scheme" content="light dark">++<title>OpenVibe.Watch</title>++<style>++:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --accent: #2456d6; }++@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --accent: #7aa2ff; } }++body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }++main { max-width: 640px; margin: 0 auto; padding: 32px 16px; }++h1 { font-size: 1.4rem; margin: 0 0 12px; }++p { margin: 0 0 12px; }++.muted { color: var(--muted); font-size: .9rem; }++a { color: var(--accent); }++</style>++</head>++<body>++<main>++<h1>OpenVibe.Watch</h1>++<p>This is an internal service of the OpenVibe network, not a website. It keeps the watches people and++agents define — an observation over a page, a feed or an API, with a condition on it — and tells them,++or their Actors and Runners, when it changes or fires.</p>++<p>A watch prefers the cheapest way to know: an event or a webhook when one is delivered, then the++site's own ETag/Last-Modified, then a feed, then an API, and only then an expensive browser check.++A failed check is recorded as a failure; a value the source did not state stays absent.</p>++<p class="muted">Health: <a href="/api/health">/api/health</a> · Readiness: <a href="/api/ready">/api/ready</a> ·++Source code: <a href="https://github.com/OpenVibers/OpenVibe.Watch">OpenVibers/OpenVibe.Watch</a></p>++</main>++</body>++</html>++`;++++function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null }) {++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', 'loopback');++ const release = createRelease({ service: 'watch', root: path.join(__dirname, '..') });++ // HTTP golden signals by route template, process metrics, release_info and the Watch gauges;++ // GET /metrics answers direct loopback callers only (Track O).++ const metrics = instrument(app, { service: 'watch', release: release.release });++ registerWatchGauges(metrics.registry, { db, watches: registry, check, observations, outbox, now });++ app.use(http.middleware());++ // One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.++ require('openvibe-shared/trace').install(app);++ app.use((req, res, next) => {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Cache-Control', 'no-store');++ next();++ });++ app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));++++ app.get('/api/health', (_req, res) => {++ res.json({ status: 'ok', service: 'openvibe-watch', version: pkg.version });++ });++++ // Readiness (openvibe-shared/ready): 503 only when the database fails; the Network key and the++ // check worker (running, queue keeping up) are optional and degrade it (see observability.js).++ const readiness = createWatchReadiness({ db, config, registry, check, observations, scheduler, outbox, relay, now, release: release.release });++ app.get('/api/ready', readiness.handler);++ // GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).++ release.mount(app, { registry: metrics.registry });++++ // Per-actor limits (api/actor-limits.js), counted by the principal once a route's capability guard++ // passed. limitsNow: the limiter's clock (tests; default the wall clock, not the check clock).++ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });++ app.use(watchesRouter({ registry, auth, limits }));++ app.use(checksRouter({ registry, check, observations, auth, limits }));++++ // The public host (openvibe.watch) shows only this page, health, readiness and /release.json:++ // Watch is internal. Browsers get a short honest HTML page, other clients the text route index.++ // Never cached, never indexed.++ app.get('/', (req, res) => {++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');++ res.setHeader('Vary', 'Accept');++ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {++ // The page runs no script of its own. Cloudflare Web Analytics: Cloudflare injects its beacon at the++ // edge and the privacy text says it may measure performance; script-src loads it, connect-src is++ // where it reports.++ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src https://static.cloudflareinsights.com; connect-src https://cloudflareinsights.com; base-uri 'none'; frame-ancestors 'none'");++ return res.type('html').send(HOME_HTML);++ }++ return res.type('text/plain').send(TEXT_INDEX);++ });++++ app.use((req, res) => http.sendProblem(res, 404, 'watch.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, 'watch.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });++ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'watch.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, 'watch.internal', { detail: 'internal error', ctx: req.ov });++ });++++ app.locals.metrics = metrics;++ return app;++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++/**++ * Authentication and capability checks. Watch serves two kinds of caller, and every API route is++ * guarded by exactly ONE capability (server/api/*.js):++ *++ * a service an RS256 client-credentials token from OpenVibe.Network (audience openvibe.watch,++ * sub svc:… / app:… / mod:…), verified offline against Network's JWKS++ * (openvibe-sdk/auth, one client per URL, kept fresh and served through an outage).++ * Its `cap` claim is judged by openvibe-contracts' own grant rule (an exact id or a++ * `family.*` grant; capability.unknown for an id the release does not know). The++ * watch.* capabilities are released, so the library decides every one of them.++ *++ * a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified++ * with openvibe-sdk/auth verifyUserToken for the same issuer and audience. Such a++ * token carries no capability list: watches are user-defined resources and the guard++ * that matters is ownership — every route scopes to the acting subject, and another++ * owner's watch answers 404 (registry.js). A person may therefore use all four watch++ * capabilities, on their own watches only.++ *++ * X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site++ * going through Network/Services, or an agent's runtime), names the subject a route acts for; the++ * watch it creates is owned by that person and its reads are scoped to them. An app:… or mod:…++ * principal is a third party and may never name one, exactly as in OpenVibe.Deals.++ */++const { serviceAuth, capabilities, http } = require('openvibe-contracts');++const { jwksClient, verifyUserToken } = require('openvibe-sdk/auth');++++const CAPS = Object.freeze({++ read: 'watch.watch.read',++ manage: 'watch.watch.manage',++ observations: 'watch.observation.read',++ run: 'watch.check.run',++});++++/** Service-side principals: their token carries `cap` and is judged by the capability rule. */++const SERVICE_SUB = /^(svc|app|mod):/;++/** A person (usr_…) or an agent (agt_…) subject: the token is the identity, ownership is the authority. */++const PERSON_SUB = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;++++function checkCapability(claims, capabilityId) {++ return capabilities.check(claims, capabilityId);++}++++/** Payload of an (unverified) token, used only to pick the verifier — never to decide anything. */++function tokenPayload(token) {++ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function createAuth({ config, log = console }) {++ const headerKid = (token) => { try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; } };++++ /** { ok, claims } or { ok: false, code, reason }. The SDK's error names the internal JWKS URL: logged, never answered. */++ async function verifyService(token) {++ const kid = headerKid(token);++ let keys;++ try { keys = await jwksClient(config.jwksUrl, { log }).keysForKid(kid); } catch (err) {++ log.error(`[auth] Network keys unavailable: ${(err && err.message) || err}`);++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };++ }++ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];++ let last = { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };++ for (const k of byKid.length ? byKid : keys) {++ last = serviceAuth.verifyServiceToken(token, { publicKey: k.key, issuer: config.issuer, audience: config.audience });++ if (last.ok || last.code !== 'token.bad_signature') return last;++ }++ return last;++ }++++ /** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */++ async function verifyUser(token) {++ try {++ const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience: config.audience });++ return { ok: true, claims };++ } catch (err) {++ const code = (err && err.code) || 'token.malformed';++ const status = Number(err && err.status) === 503 || code === 'token.no_key' ? 503 : 401;++ return { ok: false, code: status === 503 ? 'token.unavailable' : code, reason: status === 503 ? 'signing key not loaded yet' : ((err && err.message) || 'token rejected'), status };++ }++ }++++ /**++ * Express guard: one capability. Sets req.principal = { sub, kind, role, cap, jti, subject }.++ * `kind` is 'service' or 'person'; `subject` is the person a trusted service acts for (else null).++ */++ function requireCap(id) {++ return async function capGuard(req, res, next) {++ const ctx = req.ov;++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });++ const token = h.slice(7).trim();++ const payload = tokenPayload(token);++ if (!payload || typeof payload.sub !== 'string') return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });++++ if (SERVICE_SUB.test(payload.sub)) {++ // The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a++ // Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'++ // verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).++ let r;++ try { r = await verifyService(token); } catch (err) { return next(err); }++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });++ const claims = r.claims;++ const c = checkCapability(claims, id);++ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });++ req.principal = { sub: claims.sub, kind: 'service', role: null, cap: claims.cap, jti: claims.jti, subject: null };++ } else {++ // A person or an agent: usr_…/agt_… only. verifyUserToken refuses a service principal, a typed token++ // (typ/purpose) and every rule this service does not serve.++ const r = await verifyUser(token);++ if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });++ const claims = r.claims;++ req.principal = { sub: claims.sub, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };++ }++++ // X-OV-Subject: only a first-party service may name the person it acts for, and only a++ // usr_…/agt_… subject; the watch is then owned by and scoped to that subject.++ const named = req.get('x-ov-subject');++ if (named) {++ if (req.principal.kind !== 'service' || !/^svc:/.test(req.principal.sub)) {++ return http.sendProblem(res, 403, 'watch.subject_not_delegated', { detail: 'only a first-party service token may name the person it acts for (X-OV-Subject)', ctx });++ }++ if (!PERSON_SUB.test(String(named))) return http.sendProblem(res, 400, 'watch.subject_invalid', { detail: 'X-OV-Subject must be a usr_… or agt_… subject id', ctx });++ req.principal.subject = String(named);++ }++ return next();++ };++ }++++ return { requireCap, verifyService, verifyUser };++}++++/** The subject a route acts for: the person a trusted service names, else the caller itself. */++function ownerOf(principal) {++ return (principal && (principal.subject || principal.sub)) || null;++}++++module.exports = { CAPS, createAuth, checkCapability, ownerOf, SERVICE_SUB, PERSON_SUB };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * The api carrier: an official JSON (or XML) endpoint, mapped by the watch itself — no code per++ * provider (OpenVibe.Sources/server/adapters/api.js is the same idea).++ *++ * source.items_path dot path to the records ('' or absent: the body is the array)++ * source.fields { name: path } → each record becomes { name: value, … }++ *++ * With a mapping, extraction reads the mapped records: `value_path: 'latest.price'` (the document++ * is { items, latest, count }) or `extraction.fields` for several values at once. Without one, the++ * parsed body IS the document and extraction.json / extraction.jsonpath reads it directly — the++ * `format: 'json'` case of plan T18 step 3.++ *++ * Fetching is conditional (see carriers/http.js); a 304 or an unchanged body records no observation.++ */++const { parseXml, decodeBody, ParseError } = require('../util');++const { at } = require('../extract');++const { conditionalGet, onlyChange, ACCEPT } = require('./http');++const { mapFields } = require('./feed');++++const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;++++/** '' means "the body itself is the array"; anything else must be a dotted path. */++function checkItemsPath(p) {++ return p === undefined || p === '' || PATH_RE.test(String(p).replace(/\[(\d+)\]/g, '.$1').replace(/^\./, ''));++}++++function mapped(document, itemsPath) {++ if (itemsPath === undefined || itemsPath === '') return document;++ return at(document, itemsPath);++}++++function createApiCarrier({ fetcher, config }) {++ return {++ name: 'api',++ run: async ({ watch, state }) => {++ const source = watch.source;++ const out = await conditionalGet({++ source, state, fetcher, secrets: config.secrets, accept: ACCEPT.api,++ onlyChangeNeeded: onlyChange(watch),++ });++ if (out.state !== 'ok' || !out.body) return out;++ const format = source.format || 'json';++ let body;++ try {++ if (format === 'xml') body = parseXml(decodeBody(out.body, out.content_type));++ else {++ try { body = JSON.parse(decodeBody(out.body, out.content_type)); }++ catch { throw new ParseError('the body is not valid JSON'); }++ }++ } catch (err) {++ return { ...out, state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'carrier_error', detail: String(err.message).slice(0, 500), body: null };++ }++ const hasMapping = Boolean(source.items_path) || Boolean(source.fields && Object.keys(source.fields).length);++ if (!hasMapping) return { ...out, document: body };++ if (!checkItemsPath(source.items_path)) {++ return { ...out, state: 'parse_error', error_code: 'items_path', detail: `items_path "${source.items_path}" is malformed`, body: null };++ }++ const list = mapped(body, source.items_path);++ if (!Array.isArray(list)) {++ return { ...out, state: 'parse_error', error_code: 'items_path', detail: `items_path "${source.items_path || ''}" is not an array in the response`, body: null };++ }++ const items = mapFields(list.filter((r) => r && typeof r === 'object'), source.fields);++ return {++ ...out,++ document: { format, items, latest: items[0] || null, count: items.length },++ };++ },++ };++}++++module.exports = { createApiCarrier, checkItemsPath };nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++/**++ * The feed carrier: RSS 2.0, RSS 1.0 (RDF) and Atom 1.0, parsed with fast-xml-parser (the reader is++ * OpenVibe.Sources/server/adapters/feed.js, which is where feeds are read for the network).++ *++ * The feed's own order is kept, and `latest` is its first entry — a watch over "the latest release"++ * reads `latest.title`, `latest.link` or `latest.published_at` through extraction.value_path, or++ * maps several values at once with extraction.fields. An entry the feed gave no identity for is++ * skipped: it is never given a made-up identity.++ *++ * Fetching is conditional (see carriers/http.js): ETag/Last-Modified first, `not_modified` and++ * `no_change` cost no observation.++ */++const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');++const { at } = require('../extract');++const { conditionalGet, onlyChange, ACCEPT } = require('./http');++++function atomLink(entry, base) {++ const links = asArray(entry.link);++ const alt = links.find(l => typeof l === 'object' && (!l['@_rel'] || l['@_rel'] === 'alternate') && l['@_href'])++ || links.find(l => typeof l === 'object' && l['@_href'])++ || links.find(l => typeof l === 'string');++ if (!alt) return null;++ return canonicalUrl(typeof alt === 'string' ? alt : alt['@_href'], base);++}++++function namesOf(list) {++ return asArray(list).map((a) => {++ if (a == null) return null;++ if (typeof a === 'object') return toText(a.name ?? a['#text'] ?? a.__cdata, 200);++ return toText(a, 200);++ }).filter(Boolean).slice(0, 20);++}++++function termsOf(list) {++ return asArray(list).map((c) => {++ if (c == null) return null;++ if (typeof c === 'object') return toText(c['@_term'] ?? c['#text'] ?? c.__cdata, 100);++ return toText(c, 100);++ }).filter(Boolean).slice(0, 30);++}++++function rssItem(it, base, summaryMax) {++ const guidNode = Array.isArray(it.guid) ? it.guid[0] : it.guid;++ const guid = toId(guidNode);++ const link = canonicalUrl(pick(it, 'link'), base);++ const guidIsPermalink = !(guidNode && typeof guidNode === 'object' && guidNode['@_isPermaLink'] === 'false');++ const url = link || (guid && guidIsPermalink ? canonicalUrl(guid) : null);++ const identity = guid || url;++ if (!identity) return null;++ const enclosure = asArray(it.enclosure).find(e => e && typeof e === 'object' && e['@_url']);++ return {++ identity,++ kind: 'entry',++ title: toText(pick(it, 'title'), 500),++ link: url,++ summary: toText(pick(it, 'description', 'content:encoded'), summaryMax),++ authors: namesOf(it['dc:creator'] ?? it.author),++ published_at: toIsoDate(pick(it, 'pubDate', 'dc:date')),++ updated_at: toIsoDate(pick(it, 'atom:updated', 'dc:modified')),++ categories: termsOf(it.category ?? it['dc:subject']),++ enclosure: enclosure ? { url: canonicalUrl(enclosure['@_url'], base), type: toId(enclosure['@_type'], 100) } : null,++ };++}++++function atomEntry(e, base, summaryMax) {++ const id = toId(pick(e, 'id'));++ const url = atomLink(e, base);++ const identity = id || url;++ if (!identity) return null;++ return {++ identity,++ kind: 'entry',++ title: toText(pick(e, 'title'), 500),++ link: url,++ summary: toText(pick(e, 'summary', 'content'), summaryMax),++ authors: namesOf(e.author),++ published_at: toIsoDate(pick(e, 'published', 'issued')),++ updated_at: toIsoDate(pick(e, 'updated', 'modified')),++ categories: termsOf(e.category),++ enclosure: null,++ };++}++++/** parseFeed(text, baseUrl, { summaryMax, maxItems }) → { format, title, link, items, skipped }. */++function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {++ const doc = parseXml(text);++ let raw = [];++ let meta = {};++ let make;++ if (doc.rss && doc.rss.channel) {++ const ch = Array.isArray(doc.rss.channel) ? doc.rss.channel[0] : doc.rss.channel;++ raw = asArray(ch.item);++ meta = { format: 'rss2', title: toText(pick(ch, 'title'), 300), link: canonicalUrl(pick(ch, 'link'), url) };++ make = (it) => rssItem(it, url, summaryMax);++ } else if (doc['rdf:RDF']) {++ const rdf = doc['rdf:RDF'];++ raw = asArray(rdf.item);++ const ch = Array.isArray(rdf.channel) ? rdf.channel[0] : rdf.channel;++ meta = { format: 'rss1', title: ch ? toText(pick(ch, 'title'), 300) : null, link: null };++ make = (it) => rssItem({ ...it, guid: it['@_rdf:about'] ? { '#text': it['@_rdf:about'] } : undefined }, url, summaryMax);++ } else if (doc.feed) {++ const feed = doc.feed;++ raw = asArray(feed.entry);++ meta = { format: 'atom', title: toText(pick(feed, 'title'), 300), link: atomLink(feed, url) };++ make = (e) => atomEntry(e, url, summaryMax);++ } else {++ throw new ParseError('not an RSS or Atom document');++ }++ const items = [];++ let skipped = 0;++ const seen = new Set();++ for (const r of raw) {++ if (items.length >= maxItems) { skipped++; continue; }++ const it = r && typeof r === 'object' ? make(r) : null;++ if (!it || seen.has(it.identity)) { skipped++; continue; }++ seen.add(it.identity);++ items.push(it);++ }++ return { ...meta, items, skipped };++}++++function createFeedCarrier({ fetcher, config }) {++ return {++ name: 'feed',++ run: async ({ watch, state }) => {++ const out = await conditionalGet({++ source: watch.source, state, fetcher, secrets: config.secrets, accept: ACCEPT.feed,++ onlyChangeNeeded: onlyChange(watch),++ });++ if (out.state !== 'ok' || !out.body) return out;++ const text = decodeBody(out.body, out.content_type);++ let parsed;++ try {++ parsed = parseFeed(text, out.final_url || watch.source.url, { summaryMax: 2000 });++ } catch (err) {++ return { ...out, state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'carrier_error', detail: String(err.message).slice(0, 500), body: null };++ }++ const items = mapFields(parsed.items, watch.source.fields);++ return {++ ...out,++ document: { format: parsed.format, title: parsed.title, link: parsed.link, items, latest: items[0] || null, count: items.length },++ };++ },++ };++}++++/** source.fields: { name: path } over each mapped record; a path that matches nothing is null. */++function mapFields(items, fields) {++ const names = Object.keys(fields || {});++ if (!names.length) return items;++ return items.map((item) => {++ const out = {};++ for (const name of names) {++ const v = at(item, fields[name]);++ out[name] = v === undefined ? null : v;++ }++ return out;++ });++}++++module.exports = { createFeedCarrier, parseFeed, mapFields };nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * The http carrier: one URL, fetched conditionally.++ *++ * The binding preference order of plan T18 puts ETag/Last-Modified first among the pull rungs, so++ * every carrier here goes through conditionalGet():++ *++ * - source.conditional !== false and a stored validator → If-None-Match / If-Modified-Since;++ * a 304 is `not_modified` and costs nothing (no body, no observation).++ * - when the watch only needs to know WHETHER the page changed (condition.op 'changed'), a HEAD is++ * tried first while a validator is stored: the answer is the validators, not the body.++ * - a 200 whose body hashes to the hash of the last body is `no_change`: the value cannot have++ * changed, so no observation is recorded and the cadence is not disturbed.++ * - 429 and 503+Retry-After set not_before: Watch waits as long as the site asked.++ *++ * Every URL goes through the SSRF guard inside the fetcher (ports 80/443 unless configured, no++ * private hosts, every redirect hop re-checked, byte cap, one deadline). A source's credential is++ * read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,++ * logged, or sent to another origin than the one it was configured for.++ */++const { sha256, decodeBody } = require('../util');++++const ACCEPT = {++ http: 'text/html, application/xhtml+xml, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1',++ feed: 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.1',++ api: 'application/json, application/xml;q=0.9, */*;q=0.1',++};++++/** Retry-After (seconds or an HTTP date) → ms from now, or null. */++function retryAfterMs(value, now) {++ if (value == null) return null;++ const s = String(value).trim();++ if (/^\d{1,7}$/.test(s)) return Number(s) * 1000;++ const t = Date.parse(s);++ return Number.isFinite(t) ? Math.max(0, t - now) : null;++}++++/**++ * The headers a source asks for: its own (minus the ones the fetcher owns), its Accept, and its++ * credential when it names an environment variable that is set.++ */++function requestFor(source, secrets, accept) {++ const headers = { ...(source.headers || {}) };++ const sensitive = [];++ let url = String(source.url);++ if (accept) headers.Accept = accept;++ const auth = source.auth;++ if (auth && auth.mode && auth.mode !== 'none') {++ const secret = secrets ? secrets[auth.env] : null;++ if (!secret) return { missing: auth.env };++ if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }++ else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }++ else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }++ }++ return { url, headers, sensitive };++}++++/** True when the watch only needs to know whether the value changed (a HEAD may answer it). */++function onlyChange(watch) {++ const mode = (watch.comparison && watch.comparison.mode) || 'changed';++ const op = (watch.condition && watch.condition.op) || 'changed';++ return op === 'changed' && mode !== 'none';++}++++/**++ * One conditional fetch of a source, shared by the http, feed and api carriers.++ * → { state, http_status, bytes, raw_body_hash, etag, last_modified, body, content_type, detail, error_code, notBefore, final_url }++ */++async function conditionalGet({ source, state = {}, fetcher, secrets, accept, now = () => Date.now(), onlyChangeNeeded = false }) {++ const req = requestFor(source, secrets, accept);++ if (req.missing) {++ return {++ state: 'disabled', error_code: 'credential_missing',++ detail: `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,++ etag: null, last_modified: null, body: null, content_type: null, notBefore: null,++ };++ }++ const conditional = source.conditional !== false;++ const etag = conditional ? (state.etag || null) : null;++ const lastModified = conditional ? (state.last_modified || null) : null;++ const base = {++ http_status: null, bytes: null, raw_body_hash: null, etag, last_modified: lastModified,++ body: null, content_type: null, detail: null, error_code: null, notBefore: null,++ };++ const fetchOpts = { headers: req.headers, etag, lastModified, sensitiveHeaders: req.sensitive };++ const method = String(source.method || 'GET').toUpperCase() === 'HEAD' ? 'HEAD' : 'GET';++++ const sameValidators = (h) => Boolean(++ (etag && h.etag === etag) || (lastModified && h['last-modified'] === lastModified),++ );++++ // HEAD first when only change is needed and there is something to compare with: a page that has++ // not changed costs two headers instead of a body.++ if (onlyChangeNeeded && conditional && (etag || lastModified) && method === 'GET') {++ let head = null;++ try { head = await fetcher.fetchUrl(req.url, { ...fetchOpts, method: 'HEAD' }); } catch { head = null; }++ if (head) {++ if (head.status === 304) return { ...base, state: 'not_modified', http_status: 304 };++ if ((head.status === 200 || head.status === 204) && sameValidators(head.headers)) {++ return { ...base, state: 'not_modified', http_status: head.status };++ }++ }++ }++++ let res;++ try {++ res = await fetcher.fetchUrl(req.url, { ...fetchOpts, method });++ } catch (err) {++ const code = err.code || 'network';++ return {++ ...base,++ state: code === 'timeout' ? 'timeout' : 'http_error',++ error_code: code,++ detail: String(err.message).slice(0, 500),++ };++ }++ const out = {++ ...base,++ http_status: res.status,++ bytes: res.bytes != null ? res.bytes : null,++ etag: res.headers.etag || etag,++ last_modified: res.headers['last-modified'] || lastModified,++ content_type: res.headers['content-type'] || null,++ final_url: res.finalUrl,++ };++ if (res.status === 304) return { ...out, state: 'not_modified' };++ if (res.status === 429) {++ const wait = Math.min(retryAfterMs(res.headers['retry-after'], now()) ?? 10 * 60 * 1000, 24 * 3600 * 1000);++ return { ...out, state: 'rate_limited', error_code: 'upstream_429', detail: `the site asked us to wait ${Math.round(wait / 1000)} s`, notBefore: now() + wait };++ }++ if (res.status === 503) {++ const wait = retryAfterMs(res.headers['retry-after'], now());++ return { ...out, state: 'http_error', error_code: 'http_503', detail: 'HTTP 503', notBefore: wait == null ? null : now() + Math.min(wait, 24 * 3600 * 1000) };++ }++ if (res.status !== 200 && res.status !== 203) {++ return { ...out, state: 'http_error', error_code: `http_${res.status}`, detail: `HTTP ${res.status}` };++ }++ if (method === 'HEAD') {++ return { ...out, state: 'ok', document: { status: res.status, etag: out.etag, last_modified: out.last_modified, content_type: out.content_type, content_length: Number(res.headers['content-length']) || null } };++ }++ const hash = sha256(res.body);++ out.raw_body_hash = hash;++ out.bytes = res.body.length;++ if (state.last_body_hash && state.last_body_hash === hash) {++ return { ...out, state: 'no_change', detail: `the body hashed ${hash.slice(0, 16)}… again` };++ }++ return { ...out, state: 'ok', body: res.body };++}++++function createHttpCarrier({ fetcher, config }) {++ return {++ name: 'http',++ run: async ({ watch, state }) => conditionalGet({++ source: watch.source, state, fetcher, secrets: config.secrets, accept: ACCEPT.http,++ onlyChangeNeeded: onlyChange(watch),++ }),++ };++}++++module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, ACCEPT, decodeBody };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * Carrier choice. The plan's preference order is binding:++ *++ * webhook/event → ETag/Last-Modified → feed → API → and only then browser polling (through Run)++ *++ * The rungs are cheap to expensive, so a watch takes the first one its source makes possible: a++ * pushed event or webhook costs nothing, a conditional GET costs two headers, a feed and an API are++ * ordinary conditional GETs, and a Run check is an expensive browser/code execution. `source.kind`++ * fixes the carrier (migration 0001); PREFERENCE records the order the service is built around.++ *++ * This release carries the pull rungs only:++ *++ * http feed api implemented here (step 3)++ * event webhook step 5++ * run step 6++ * node step 7++ *++ * A kind whose carrier is not built yet is accepted by the registry (a watch may be defined before++ * its transport exists) but is NEVER fetched: choose() answers `watch.carrier_unavailable`, the++ * check is recorded as `skipped`, and no request leaves this process. That is what keeps a++ * half-built rung from silently turning into polling.++ */++const { createHttpCarrier } = require('./http');++const { createFeedCarrier } = require('./feed');++const { createApiCarrier } = require('./api');++++/** The documented order. A source kind fixes its carrier; this is the order a watch would prefer. */++const PREFERENCE = ['event', 'webhook', 'http', 'feed', 'api'];++++/** The step of plan T18 that carries each kind this release does not. */++const LATER = {++ event: { step: 5, note: 'an event source is delivered by OpenVibe.Events, never polled' },++ webhook: { step: 5, note: 'a webhook source is delivered to /internal/hooks/, never polled' },++ run: { step: 6, note: 'browser polling goes through OpenVibe.Run only after every cheaper rung was unavailable' },++ node: { step: 7, note: 'a node probe observes a local/LAN thing through OpenVibe.Node' },++};++++function unavailable(kind) {++ const later = LATER[kind] || { step: null, note: 'no carrier for this source kind' };++ return {++ code: 'watch.carrier_unavailable',++ carrier: kind,++ detail: later.step++ ? `a ${kind} source is not checked yet: the ${kind} carrier arrives in plan T18 step ${later.step} (${later.note}). Nothing was fetched.`++ : `no carrier handles a ${kind} source. Nothing was fetched.`,++ };++}++++function createCarriers({ fetcher, config, log = console }) {++ const carriers = {++ http: createHttpCarrier({ fetcher, config, log }),++ feed: createFeedCarrier({ fetcher, config, log }),++ api: createApiCarrier({ fetcher, config, log }),++ };++++ /**++ * choose(watch) → { name, run } | { unavailable: { code, carrier, detail } }.++ * Never fetches, never throws.++ */++ function choose(watch) {++ const kind = watch && watch.source ? watch.source.kind : null;++ const carrier = carriers[kind];++ if (!carrier) return { unavailable: unavailable(kind) };++ return { name: carrier.name, run: carrier.run };++ }++++ return { choose, carriers, PREFERENCE, LATER };++}++++module.exports = { createCarriers, PREFERENCE, LATER, unavailable };nnew file mode 100644@@@ -0,0 +1,388 @@++'use strict';++/**++ * The check engine: one execution of one watch (plan T18 step 3).++ *++ * run(id, { trigger }) —++ * 1 one check of a watch at a time (a second call answers { busy: true })++ * 2 load the watch; a watch that is not active is recorded `disabled` and never fetched++ * 3 choose the carrier (carriers/index.js). event, webhook, node and run sources are accepted++ * by the registry but have no carrier yet: the check is recorded `skipped` with++ * watch.carrier_unavailable and NOTHING is fetched (steps 5-7)++ * 4 carrier.run() → the body, its validators and the terminal state; watch_endpoint_state is++ * updated with what the site said (ETag, Last-Modified, status, body hash)++ * 5 extract → value; record an observation (value, hashes, changed, condition, retention)++ * 6 evaluate the condition; a condition that MET writes check_runs(triggers = 1, state++ * 'condition_met') and emits watch.watch.triggered. Actions are step 4 of the plan and are++ * NOT dispatched here: a met condition records the trigger and emits the event, nothing else++ * 7 arm next_due_at (+ jitter, or exponential backoff after a failure, capped) and not_before++ * (Retry-After)++ *++ * The run row, the observation and every event commit in ONE transaction: an event exists exactly++ * when its effect does. Nothing polls a source whose condition is 'changed' twice for the same++ * body: a 304 is `not_modified` and an identical body hash is `no_change`, and neither records an++ * observation.++ */++const { ids: contractIds, validate } = require('openvibe-contracts');++const { nextDue } = require('./registry');++const { ExtractError } = require('./extract');++++/** States that count as a failure, back off, and emit watch.check.failed. */++const FAILURE_STATES = new Set(['http_error', 'timeout', 'parse_error', 'rate_limited', 'budget_exceeded', 'skipped', 'disabled']);++++/** The owner a watch event can name: observation.recorded takes usr_…/agt_…, triggered usr_… only. */++const PERSON_OWNER = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_OWNER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, now = () => Date.now(), log = console, relay = null, ids = contractIds }) {++ const inflight = new Set();++++ const st = {++ insertRun: db.prepare(`INSERT INTO check_runs (id, watch_id, trigger, carrier, started_at, finished_at, state, http_status,++ error_code, detail, bytes, raw_body_hash, cost_usd, observations, triggers)++ VALUES (@id, @watch_id, @trigger, @carrier, @started_at, @finished_at, @state, @http_status,++ @error_code, @detail, @bytes, @raw_body_hash, @cost_usd, @observations, @triggers)`),++ endpoint: db.prepare('SELECT * FROM watch_endpoint_state WHERE watch_id = ? AND url = ?'),++ touchEndpoint: db.prepare(`INSERT INTO watch_endpoint_state (watch_id, url, last_status, last_fetch_at) VALUES (@w, @u, @status, @at)++ ON CONFLICT(watch_id, url) DO UPDATE SET last_status = excluded.last_status, last_fetch_at = excluded.last_fetch_at`),++ saveEndpoint: db.prepare(`INSERT INTO watch_endpoint_state (watch_id, url, etag, last_modified, last_status, last_fetch_at, last_body_hash)++ VALUES (@w, @u, @etag, @lm, @status, @at, @hash)++ ON CONFLICT(watch_id, url) DO UPDATE SET etag = excluded.etag, last_modified = excluded.last_modified,++ last_status = excluded.last_status, last_fetch_at = excluded.last_fetch_at, last_body_hash = excluded.last_body_hash`),++ finish: db.prepare(`UPDATE watches SET last_check_at = @at, last_success_at = COALESCE(@success_at, last_success_at),++ last_state = @state, consecutive_failures = @failures, next_due_at = @next_due, not_before = @not_before WHERE id = @id`),++ runs: db.prepare('SELECT * FROM check_runs WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT ?'),++ run: db.prepare('SELECT * FROM check_runs WHERE id = ?'),++ // The last check that fired the condition: a repeat needs a value that is new or a new run++ // of holding, so a satisfied watch does not fire once per check while nothing moves.++ lastTrigger: db.prepare('SELECT id, started_at FROM check_runs WHERE watch_id = ? AND triggers > 0 ORDER BY rid DESC LIMIT 1'),++ };++++ const boolOf = (v) => Boolean(Number(v));++++ /** check-run.v1.json, validated with the released schema. */++ function runView(row) {++ const v = {++ id: row.id,++ watch_id: row.watch_id,++ trigger: row.trigger,++ carrier: row.carrier == null ? null : row.carrier,++ started_at: new Date(row.started_at).toISOString(),++ finished_at: new Date(row.finished_at).toISOString(),++ state: row.state,++ http_status: row.http_status == null ? null : Number(row.http_status),++ observations: Number(row.observations),++ triggers: Number(row.triggers),++ cost_usd: Number(row.cost_usd),++ detail: row.detail == null ? null : row.detail,++ };++ const r = validate('watch.check-run@1', v);++ if (!r.valid) throw new Error(`check-run view does not match watch.check-run@1: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ return v;++ }++++ function baseRun(watch, trigger, carrier, startedAt) {++ return {++ id: `ckr_${ids.ulid(startedAt)}`, watch_id: watch.id, trigger, carrier,++ started_at: startedAt, finished_at: startedAt, state: null, http_status: null, error_code: null,++ detail: null, bytes: null, raw_body_hash: null, cost_usd: 0, observations: 0, triggers: 0,++ };++ }++++ // ── Events ───────────────────────────────────────────────────────────────────────────────++++ /**++ * The three events OpenVibe.Watch publishes (released payload contracts). They are emitted only++ * when the watch's owner is a subject the payload can name: observation.recorded takes usr_… or++ * agt_…, and a trigger names a usr_… recipient. A watch owned by a service still records every++ * observation and trigger in its own tables; it just has no notification recipient, and the++ * service logs that rather than emitting a payload the contract refuses.++ */++ async function eventsFor({ watch, owner, observation, value, met, firedOn = null, firedAt = null, previousValue, failures, run }) {++ const events = [];++ if (observation) {++ if (PERSON_OWNER.test(owner)) {++ events.push({++ event_type: 'watch.observation.recorded',++ subject: { type: 'watch', id: watch.id },++ visibility: 'internal', priority: 'low',++ payload: {++ watch_id: watch.id, owner,++ observed_at: new Date(observation.observed_at).toISOString(),++ value,++ value_hash: observation.value_hash,++ changed: boolOf(observation.changed),++ condition_met: boolOf(observation.condition_met),++ },++ });++ } else {++ log.warn(`[check] ${watch.id}: observation.recorded not emitted: watch.observation.recorded@1 names usr_/agt_ owners, this watch belongs to ${owner}`);++ }++ }++ if (met && firedOn) {++ if (USER_OWNER.test(owner)) {++ events.push({++ event_type: 'watch.watch.triggered',++ subject: { type: 'watch', id: watch.id },++ visibility: 'internal', priority: 'important',++ payload: {++ watch_id: watch.id, recipient: owner, project_id: null, name: watch.name,++ condition: watch.condition,++ fired_at: new Date(firedAt == null ? firedOn.observed_at : firedAt).toISOString(),++ observation: {++ id: firedOn.id,++ observed_at: new Date(firedOn.observed_at).toISOString(),++ value,++ previous_value: previousValue === undefined ? null : previousValue,++ },++ },++ });++ } else {++ log.warn(`[check] ${watch.id}: watch.triggered not emitted: watch.watch.triggered@1 names a usr_ recipient, this watch belongs to ${owner}`);++ }++ }++ if (FAILURE_STATES.has(run.state)) {++ events.push({++ event_type: 'watch.check.failed',++ subject: { type: 'watch', id: watch.id },++ visibility: 'internal',++ payload: {++ watch_id: watch.id, state: run.state, carrier: run.carrier,++ http_status: run.http_status == null ? null : Number(run.http_status),++ error_code: run.error_code, consecutive_failures: Math.max(1, failures),++ },++ });++ }++ return events;++ }++++ /**++ * One terminal path, in ONE transaction: the observation (when there is one), the check run,++ * the events and the watch's re-armed runtime state all commit together, or none of them do.++ * `failures` is the consecutive failure count (0 when the check succeeded).++ */++ async function settle({ watch, run, failures, successAt = null, notBefore = 0, events = [], observation = null, endpoint = null }) {++ const armed = nextDue(watch, run.finished_at, { failures, maxBackoffMs: config.worker.maxBackoffMs });++ await db.tx(async () => {++ if (observation) await observations.insert(observation);++ await st.insertRun.run(run);++ if (endpoint) await st.saveEndpoint.run(endpoint);++ await st.finish.run({++ id: watch.id, at: run.finished_at, success_at: successAt, state: run.state,++ failures, next_due: armed, not_before: notBefore,++ });++ for (const e of events || []) await outbox.enqueue(e);++ });++ if (relay) relay.flush().catch(() => { });++ return { run, observation, met: run.triggers > 0, failures };++ }++++ // ── One check ────────────────────────────────────────────────────────────────────────────++++ async function runOnce(row, trigger) {++ const watch = registry.fromRow(row);++ const startedAt = now();++ const carrier = watch.source && watch.source.kind ? watch.source.kind : null;++ const failuresBefore = Number(row.consecutive_failures) || 0;++++ const skip = async (state, error_code, detail) => {++ const run = {++ ...baseRun(watch, trigger, carrier, startedAt),++ state, error_code, detail, finished_at: now(),++ };++ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });++ return await settle({ watch, run, failures: failuresBefore + 1, events });++ };++++ // A watch that is not active is never fetched, and says so.++ if (row.status !== 'active') {++ return { disabled: true, ...(await skip('disabled', `watch_${row.status}`, `the watch is ${row.status}`)) };++ }++++ // An extraction this release cannot run (an AI reading is an action, step 10): never fetch.++ if (watch.extraction && watch.extraction.kind === 'ai') {++ const out = await skip('skipped', 'watch.extraction_unavailable', 'ai extraction is an AI action, not a check (plan T18 step 10). Nothing was fetched.');++ return { unavailable: { code: 'watch.extraction_unavailable' }, ...out };++ }++++ const choice = carriers.choose(watch);++ if (choice.unavailable) {++ const out = await skip('skipped', choice.unavailable.code, choice.unavailable.detail);++ return { unavailable: choice.unavailable, ...out };++ }++++ const url = watch.source.url || null;++ const state = url ? (await st.endpoint.get(watch.id, url)) || {} : {};++ let out;++ try {++ out = await choice.run({ watch, state, now });++ } catch (err) {++ log.error(`[check] ${watch.id}: carrier ${carrier} crashed: ${err.stack || err}`);++ out = { state: 'http_error', error_code: 'internal', detail: 'the carrier failed unexpectedly' };++ }++ const finishedAt = now();++++ // What the site said about this URL. Status and fetch time are always recorded; the++ // validators and the body hash are stored only after a body that was read successfully++ // (OpenVibe.Sources' rule), so a body that failed to parse is fetched and parsed again on++ // the next check instead of being skipped as unchanged forever.++ const endpointFor = (hash) => (url && out.http_status != null ? {++ w: watch.id, u: url, etag: out.etag ?? null, lm: out.last_modified ?? null,++ status: out.http_status, at: finishedAt, hash,++ } : null);++ const endpoint = endpointFor(out.raw_body_hash ?? state.last_body_hash ?? null);++ if (url && out.http_status != null) await st.touchEndpoint.run({ w: watch.id, u: url, status: out.http_status, at: finishedAt });++++ // A failed fetch: the run is the record, and nothing was stated.++ if (FAILURE_STATES.has(out.state)) {++ const run = {++ ...baseRun(watch, trigger, carrier, startedAt),++ state: out.state, http_status: out.http_status ?? null, error_code: out.error_code ?? null,++ detail: out.detail ? String(out.detail).slice(0, 500) : null,++ bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,++ };++ const failures = failuresBefore + 1;++ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });++ const result = await settle({ watch, run, failures, events, endpoint, notBefore: out.notBefore || 0 });++ return { ...result, rateLimitedUntil: out.notBefore || null };++ }++++ // The value: extracted from the body, or the one we already hold. `not_modified` (304) and++ // `no_change` (an identical body) state nothing new, so no observation is recorded — but the++ // condition is still evaluated against the held value, because a condition that must hold++ // for for_sec becomes met while the page has not changed.++ const stated = out.state === 'ok';++ let value = null;++ let snapshot = null;++ if (stated) {++ try {++ const extracted = extract(watch.extraction, {++ body: out.body === undefined ? null : out.body,++ contentType: out.content_type || null,++ document: out.document,++ snapshotMax: config.observations.snapshotMax,++ });++ value = extracted.value;++ snapshot = extracted.snapshot;++ } catch (err) {++ const unsupported = err instanceof ExtractError && err.code === 'unsupported';++ const run = {++ ...baseRun(watch, trigger, carrier, startedAt),++ state: unsupported ? 'skipped' : 'parse_error',++ error_code: unsupported ? 'watch.extraction_unavailable' : 'unreadable',++ detail: String(err.message).slice(0, 500),++ http_status: out.http_status ?? null, bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null,++ finished_at: finishedAt,++ };++ const failures = failuresBefore + 1;++ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });++ return await settle({ watch, run, failures, events });++ }++ } else {++ const held = await observations.latest(watch.id);++ if (!held) {++ const run = {++ ...baseRun(watch, trigger, carrier, startedAt),++ state: out.state, http_status: out.http_status ?? null, error_code: out.error_code ?? null,++ detail: out.detail ? String(out.detail).slice(0, 500) : null,++ bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,++ };++ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });++ return await settle({ watch, run, failures: 0, successAt: finishedAt, events, endpoint });++ }++ value = held.value;++ }++++ const latest = await observations.latest(watch.id);++ const previous = latest ? {++ value: latest.value, // jsonb is parsed by the db layer++ condition_met: boolOf(latest.condition_met),++ observed_at: latest.observed_at,++ } : null;++ const heldSince = await observations.heldSince(watch.id);++ const verdict = condition.evaluate({ comparison: watch.comparison, condition: watch.condition }, {++ value, previous, heldSince, now: finishedAt,++ changed: stated ? undefined : false, // a body that did not change states no new value++ });++++ // A condition fires when it becomes satisfied and stays satisfied: it fires again only for a++ // value that is new (changed) or for a new run of holding (the trail began after the last++ // firing) — never once per check while nothing moves.++ const lastFired = await st.lastTrigger.get(watch.id);++ const repeat = verdict.met && !verdict.changed && lastFired != null && verdict.since != null && lastFired.started_at >= verdict.since;++ const met = verdict.met && !repeat;++++ const retentionDays = watch.retention && Number.isInteger(watch.retention.observations_days)++ ? watch.retention.observations_days++ : config.retention.defaultDays;++ const keepSnapshots = !(watch.retention && watch.retention.keep_snapshots === false);++++ const run = {++ ...baseRun(watch, trigger, carrier, startedAt),++ state: met ? 'condition_met' : (stated ? (verdict.changed ? 'changed' : 'ok') : out.state),++ http_status: out.http_status ?? null, bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null,++ observations: stated ? 1 : 0, triggers: met ? 1 : 0, finished_at: finishedAt,++ };++++ // The observation row is built before the transaction (it needs the previous one's hash),++ // then inserted with the run, the endpoint state and the events in one commit. A check that++ // stated nothing inserts no observation; its trigger (if any) points at the last one.++ let observation = null;++ let firedOn = latest;++ let previousValue = previous ? previous.value : null;++ // WATCH_OBSERVATIONS_MAX_PER_CHECK bounds what one check may write (a mapped feed or API can++ // carry several watched records; a pull check today carries one value).++ if (stated && run.observations < Math.max(1, config.observations.maxPerCheck)) {++ observation = await observations.build({++ watch_id: watch.id, check_run_id: run.id, value, snapshot,++ changed: verdict.changed, condition_met: verdict.holds,++ retentionDays, keepSnapshots, observed_at: finishedAt,++ });++ firedOn = observation;++ } else if (met && latest) {++ const beforeLatest = await observations.before(latest);++ previousValue = beforeLatest ? beforeLatest.value : null;++ }++++ const events = await eventsFor({++ watch, owner: row.owner_sub, observation, value, met, run,++ firedOn, firedAt: finishedAt, previousValue, failures: 0,++ });++ const result = await settle({ watch, run, failures: 0, successAt: finishedAt, events, observation, endpoint });++ return {++ ...result, met, value, verdict: { ...verdict, met, repeat },++ observation: observation ? observations.view(observation) : null,++ };++ }++++ /**++ * run(id, { trigger, event }) →++ * null no such watch++ * { busy: true } a check of this watch is already in flight++ * { disabled: true } the watch is paused/disabled/failed; recorded, not fetched++ * { unavailable: { code, detail } } the carrier (or extraction) is a later step; nothing fetched++ * { run, observation, met, value } the check ran++ */++ async function run(id, { trigger = 'schedule' } = {}) {++ const key = String(id);++ // Claimed before the first await, so two callers racing on one watch cannot both proceed.++ if (inflight.has(key)) return { busy: true };++ inflight.add(key);++ try {++ const row = await registry.get(key);++ if (!row) return null;++ return await runOnce(row, trigger);++ } finally {++ inflight.delete(key);++ }++ }++++ /** Newest first; `before` is the rid of the last row of the previous page. */++ async function list(watch_id, { before = null, limit = 50 } = {}) {++ const b = before != null && Number.isSafeInteger(Number(before)) ? Number(before) : Number.MAX_SAFE_INTEGER;++ return await st.runs.all(watch_id, b, limit);++ }++++ return { run, list, runView, inflight: () => [...inflight], FAILURE_STATES };++}++++module.exports = { createCheck, FAILURE_STATES };nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Comparison and conditions (watch.watch@1 $defs/comparison, $defs/condition).++ *++ * changed the value differs from the previous observation. No previous observation means it++ * changed (there is nothing it is equal to). `tolerance` (absolute) and `percent`++ * apply to numbers: a price that moved less than the tolerance has not changed.++ * holds the predicate over the value: gt/gte/lt/lte (numbers, else strings), eq/ne, contains++ * (a substring, an array member, or a key of an object), matches (a regular expression++ * over the text), exists/absent.++ * met holds AND the predicate has held continuously for `for_sec` seconds. The "since" is++ * measured from the observations themselves (observations.heldSince), never from a++ * timer in this process: a restart does not lose a pending debounce.++ *++ * Numeric comparison refuses to guess: a value that is not a number (or a plain decimal string) is++ * never "greater than" anything, and an unknown value is never low (the same rule Deals' watches++ * use). `matches` compiles the watch's own pattern with a length cap and tests a bounded string.++ */++const { canonicalJson } = require('./util');++++const MAX_PATTERN = 200;++// Bounded input: the watch's own pattern only ever runs over this much text (see extract.js: a++// value is capped at the same size), so a pathological pattern cannot scan a whole body.++const MAX_TESTED = 65536;++++/** The numeric value of a number, or of a string that is exactly a decimal number; else null. */++function asNumber(v) {++ if (typeof v === 'number') return Number.isFinite(v) ? v : null;++ if (typeof v !== 'string') return null;++ const s = v.trim();++ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;++ const n = Number(s);++ return Number.isFinite(n) ? n : null;++}++++const same = (a, b) => canonicalJson(a) === canonicalJson(b);++++function isAbsent(v) {++ if (v === null || v === undefined) return true;++ if (typeof v === 'string') return v.trim() === '';++ if (Array.isArray(v)) return v.length === 0;++ if (typeof v === 'object') return Object.keys(v).length === 0;++ return false;++}++++/** changed: does `value` differ from the previous observation's value? */++function hasChanged({ comparison }, { value, previous }) {++ const mode = (comparison && comparison.mode) || 'changed';++ if (mode === 'none') return false;++ if (!previous) return true;++ const before = previous.value;++ const a = asNumber(value);++ const b = asNumber(before);++ const tolerance = comparison && comparison.tolerance != null ? Number(comparison.tolerance) : null;++ const percent = comparison && comparison.percent != null ? Number(comparison.percent) : null;++ if (a != null && b != null && tolerance != null && Number.isFinite(tolerance)) return Math.abs(a - b) > tolerance;++ if (a != null && b != null && percent != null && Number.isFinite(percent)) return Math.abs(a - b) > Math.abs(b) * (percent / 100);++ return !same(value, before);++}++++/** The predicate alone: does the condition hold for this value? */++function holds({ comparison, condition }, { value, previous, changed }) {++ const op = (condition && condition.op) || 'changed';++ const target = condition ? condition.value : null;++ switch (op) {++ case 'changed': return changed;++ case 'gt': case 'gte': case 'lt': case 'lte': {++ const a = asNumber(value);++ const b = target === undefined ? (previous ? asNumber(previous.value) : null) : asNumber(target);++ if (a != null && b != null) {++ if (op === 'gt') return a > b;++ if (op === 'gte') return a >= b;++ if (op === 'lt') return a < b;++ return a <= b;++ }++ if (typeof value === 'string' && typeof target === 'string') {++ if (op === 'gt') return value > target;++ if (op === 'gte') return value >= target;++ if (op === 'lt') return value < target;++ return value <= target;++ }++ return false;++ }++ case 'eq': {++ if (target === undefined || target === null) return isAbsent(value);++ const a = asNumber(value);++ const b = asNumber(target);++ return a != null && b != null ? a === b : same(value, target);++ }++ case 'ne': return !holds({ comparison, condition: { ...condition, op: 'eq' } }, { value, previous, changed });++ case 'contains': {++ if (typeof value === 'string') return typeof target === 'string' && value.includes(target);++ if (Array.isArray(value)) return value.some((v) => same(v, target));++ if (value && typeof value === 'object') return typeof target === 'string' && Object.prototype.hasOwnProperty.call(value, target);++ return false;++ }++ case 'matches': {++ if (typeof value !== 'string' || typeof target !== 'string') return false;++ if (!target || target.length > MAX_PATTERN) return false;++ let re;++ try { re = new RegExp(target); } catch { return false; }++ return re.test(value.slice(0, MAX_TESTED));++ }++ case 'exists': return !isAbsent(value);++ case 'absent': return isAbsent(value);++ default: return false;++ }++}++++/**++ * evaluate({ comparison, condition }, { value, previous, heldSince, now })++ * → { changed, holds, met, since }++ *++ * `previous` is the previous observation ({ value, condition_met, observed_at }) or null;++ * `heldSince` (ms) is when the predicate began holding continuously, from the observation trail —++ * pass it from observations.heldSince(). When the predicate holds and no start is known, it starts++ * now, so a for_sec condition never fires on the first sighting.++ */++function evaluate({ comparison = null, condition = {} } = {}, { value, previous = null, heldSince = null, now = Date.now(), changed: given } = {}) {++ // The caller may know better: a check that got a 304 or an unchanged body knows nothing changed,++ // whatever comparing the values would say.++ const changed = given === undefined ? hasChanged({ comparison }, { value, previous }) : Boolean(given);++ const hold = holds({ comparison, condition }, { value, previous, changed });++ const forSec = condition && Number.isInteger(condition.for_sec) ? Math.max(0, condition.for_sec) : 0;++ const since = hold ? (heldSince != null ? heldSince : now) : null;++ const met = hold && (forSec === 0 || (since != null && now - since >= forSec * 1000));++ return { changed, holds: hold, met, since };++}++++module.exports = { evaluate, hasChanged, holds, asNumber, isAbsent };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * OpenVibe.Watch configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/watch.env in production); see .env.example for the documented list.++ *++ * load(env) is pure so tests can build a config without touching process.env. `env` is also kept++ * as config.secrets: a watch source's credential is read from it by environment-variable NAME at++ * fetch time and never stored, logged or returned.++ *++ * The carrier preference order is binding (plan T18): webhook/event → ETag/Last-Modified → feed →++ * API → and only then browser polling through OpenVibe.Run. `run` below is where that last rung++ * lives; nothing in this release executes it (steps 5-7).++ */++const pkg = require('../package.json');++++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(/\/$/, '');++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4730);++ const networkUrl = strip(env.OV_NETWORK_URL || 'https://openvibe.network');++ const networkInternalUrl = strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');++ // The JWKS the SDK's client (openvibe-sdk/auth jwksClient) fetches, caches and keeps fresh; one URL,++ // since the SDK keeps one client per URL. Defaults to Network's internal JWKS.++ const jwksUrl = strip(env.OV_NETWORK_JWKS_URL || `${networkInternalUrl}/api/.well-known/jwks`);++ return {++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ serviceId: 'watch',++ baseUrl: strip(env.BASE_URL || (isProduction ? 'https://openvibe.watch' : `http://localhost:${port}`)),++++ networkUrl,++ networkInternalUrl,++ jwksUrl,++ issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),++ audience: 'openvibe.watch',++ // Per-actor limits (server/api/actor-limits.js): the API reads one app or module may make per++ // minute and per hour. Writes and manual checks set their own numbers there.++ limits: {++ minute: Math.max(1, int(env.WATCH_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.WATCH_LIMITS_HOUR, 3000)),++ },++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'watch',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ },++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++++ events: {++ url: strip(env.EVENTS_URL || ''),++ relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ },++++ // The check worker: every WATCH_TICK_MS, start checks for active watches whose turn has come.++ worker: {++ enabled: env.WATCH_WORKER !== 'off',++ tickMs: int(env.WATCH_TICK_MS, 5000),++ maxConcurrent: int(env.WATCH_MAX_CONCURRENT, 8),++ // Longest wait between checks of a failing watch (exponential backoff cap).++ maxBackoffMs: int(env.WATCH_MAX_BACKOFF_MS, 6 * 3600 * 1000),++ },++ fetch: {++ userAgent: env.WATCH_USER_AGENT || `OpenVibeWatch/${pkg.version} (+https://openvibe.watch; [email protected])`,++ timeoutMs: int(env.WATCH_FETCH_TIMEOUT_MS, 20000),++ maxBytes: int(env.WATCH_MAX_BYTES, 5 * 1024 * 1024),++ maxRedirects: int(env.WATCH_MAX_REDIRECTS, 5),++ // Minimum gap between any two requests to one host, whatever the watches say.++ hostMinIntervalMs: int(env.WATCH_HOST_MIN_INTERVAL_MS, 1000),++ allowedPorts: list(env.WATCH_ALLOWED_PORTS, ['80', '443']).map(Number),++ // Exact hostnames/IPs that may resolve to private or loopback addresses (tests and++ // deliberate on-host watches only). Empty in production.++ allowPrivateHosts: list(env.WATCH_ALLOW_PRIVATE_HOSTS, []),++ },++ observations: {++ // How many observations one check may record (a mapped feed or API can yield several++ // values; the cap keeps one pathological check from writing thousands of rows).++ maxPerCheck: int(env.WATCH_OBSERVATIONS_MAX_PER_CHECK, 100),++ // A snapshot is a capped copy of the body the value came from (observation.v1.json).++ snapshotMax: 8 * 1024,++ },++ retention: {++ // observations_days when the watch states none; a watch may override it (watch.watch@1).++ defaultDays: int(env.WATCH_RETENTION_DEFAULT_DAYS, 90),++ },++++ // OpenVibe.Run (plan T14): the expensive last rung — browser polling — and never used before++ // steps 5-7 exist. WATCH_RUN_URL is Run's loopback port.++ run: {++ url: strip(env.WATCH_RUN_URL || 'http://127.0.0.1:4920'),++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'watch',++ clientSecret: env.WATCH_RUN_OAUTH_CLIENT_SECRET || '',++ },++ },++++ secrets: env,++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++/**++ * The Watch database: PostgreSQL (ADR-035), owned by this service only; the schema is migrations/NNNN_*.sql, applied++ * at boot.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++/**++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.++ */++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh watch)');++ log.warn(`[watch] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ const db = createDb({ pglite: DEV_PGLITE, service: 'watch', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'watch-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'watch', registry, log });++}++++/** Next value of a named monotonic counter (call inside a transaction). */++async function nextSeq(db, name) {++ return await db.prepare('INSERT INTO counters (name, value) VALUES (?, 1) ON CONFLICT (name) DO UPDATE SET value = counters.value + 1 RETURNING counters.value').pluck().get(name);++}++++module.exports = { openDb, nextSeq, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,163 @@++'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 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 */++ async 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('; ')}`);++ await 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: async () => (await q.pending.get()).n,++ rejected: async () => (await q.rejectedCount.get()).n,++ /** Every envelope in order (tests and operators). */++ all: async () => (await q.list.all()).map(r => JSON.parse(r.envelope)),++ prune: async (olderThanMs = 7 * 24 * 3600 * 1000) => (await 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 };++ }++++ async function mark(rows, fn) { await db.tx(async () => { for (const r of rows) await fn(r); }); }++++ async function publishRows(rows) {++ let r;++ try {++ r = await post(rows);++ } catch (err) {++ await mark(rows, async row => await 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) {++ await mark(rows, async row => await 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)) {++ await mark(rows, async row => await q.rejected.run(now(), msg, row.seq));++ log.warn(`[outbox] event refused by Events: ${msg}`);++ return { sent: 0, failed: 0, rejected: rows.length };++ }++ await mark(rows, async row => await 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 = await 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 = { createOutbox, createRelay };nnew file mode 100644@@@ -0,0 +1,291 @@++'use strict';++/**++ * Extraction: what value a watch takes from the body a carrier fetched (watch.watch@1 $defs/extraction).++ *++ * text the whole body as text, trimmed++ * html the body's text (markup stripped), or the text of the first element a `selector` matches++ * json the parsed body (a `value_path` picks a part of it); an api or feed carrier hands in++ * its own document — the mapped { items, latest, … } — so a path reads the watched++ * record rather than a provider's shape++ * jsonpath the same, named for the dotted path it takes++ * regex the first match of `selector` (its first group when it has one, else the whole match)++ * css the text of the first element a `selector` matches, in the small selector subset below++ * ai not here: an AI reading is an action (step 10), and this returns `unsupported`++ *++ * A value the source did not state is null, never guessed: a path that matches nothing and a++ * regular expression that does not match both extract null, which `absent` then answers truthfully.++ * `fields` (name → path) extracts an object of several values at once, each null when it is missing.++ */++const { toText, decodeBody } = require('./util');++++/** Longest text value one extraction stores (a 5 MB page as `text` is a mistake, not a value). */++const VALUE_MAX = 64 * 1024;++++class ExtractError extends Error {++ constructor(code, detail) { super(detail); this.code = code; }++}++++// ── The small selector subset ────────────────────────────────────────────────────────────────++const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);++const RAW = new Set(['script', 'style', 'noscript']);++const MAX_DEPTH = 200;++++function parseAttrs(text) {++ const attrs = {};++ const re = /([:@\w.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;++ let m;++ while ((m = re.exec(text))) {++ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';++ }++ return attrs;++}++++/** A forgiving HTML reader: elements, attributes and text, enough to select from. Never throws. */++function parseHtml(html) {++ const root = { type: 'element', tag: '#root', attrs: {}, children: [], parent: null };++ const stack = [root];++ const push = (node) => {++ const top = stack[stack.length - 1];++ const last = top.children[top.children.length - 1];++ if (node.type === 'text') {++ if (last && last.type === 'text') { last.value += node.value; return; }++ }++ top.children.push(node);++ };++ const text = (s) => { if (s) push({ type: 'text', value: s }); };++ let i = 0;++ while (i < html.length) {++ const lt = html.indexOf('<', i);++ if (lt < 0) { text(html.slice(i)); break; }++ if (lt > i) text(html.slice(i, lt));++ if (html.startsWith('<!--', lt)) { const end = html.indexOf('-->', lt + 4); i = end < 0 ? html.length : end + 3; continue; }++ if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) { const end = html.indexOf('>', lt); i = end < 0 ? html.length : end + 1; continue; }++ if (html[lt + 1] === '/') {++ const end = html.indexOf('>', lt);++ const tag = html.slice(lt + 2, end < 0 ? html.length : end).trim().toLowerCase();++ for (let k = stack.length - 1; k > 0; k--) if (stack[k].tag === tag) { stack.length = k; break; }++ i = end < 0 ? html.length : end + 1;++ continue;++ }++ // The tag ends at the first '>' outside a quoted attribute value.++ let end = lt + 1;++ let quote = null;++ for (; end < html.length; end++) {++ const ch = html[end];++ if (quote) { if (ch === quote) quote = null; continue; }++ if (ch === '"' || ch === "'") { quote = ch; continue; }++ if (ch === '>') break;++ }++ if (end >= html.length) { text(html.slice(lt)); break; }++ const raw = html.slice(lt + 1, end);++ const selfClosing = raw.endsWith('/');++ const m = /^([a-zA-Z][\w:-]*)/.exec(selfClosing ? raw.slice(0, -1) : raw);++ if (!m) { text(html.slice(lt, end + 1)); i = end + 1; continue; }++ const tag = m[1].toLowerCase();++ const el = { type: 'element', tag, attrs: parseAttrs(raw.slice(m[1].length)), children: [], parent: stack[stack.length - 1] };++ push(el);++ i = end + 1;++ if (VOID.has(tag) || selfClosing) continue;++ if (RAW.has(tag)) {++ const close = html.toLowerCase().indexOf(`</${tag}`, i);++ const stop = close < 0 ? html.length : close;++ if (stop > i) el.children.push({ type: 'text', value: html.slice(i, stop) });++ if (close < 0) { i = html.length; continue; }++ const gt = html.indexOf('>', close);++ i = gt < 0 ? html.length : gt + 1;++ continue;++ }++ if (stack.length < MAX_DEPTH) stack.push(el);++ }++ return root;++}++++const COMPOUND_RE = /^([a-zA-Z][\w-]*|\*)?((?:[.#][\w-]+|\[[^\]]{0,120}\])*)$/;++const PART_RE = /([.#])([\w-]+)|\[([^\]]*)\]/g;++++/** 'div.card > a[href] .price' → [{ comb:' ', compound }, …]; null when it is not in the subset. */++function parseSelector(selector) {++ const text = String(selector || '').trim();++ if (!text) return null;++ const parts = [];++ const tokens = text.split(/(\s*>\s*|\s+)/).filter((s) => s !== '');++ let comb = ' ';++ for (const token of tokens) {++ if (/^\s*>\s*$/.test(token)) { comb = '>'; continue; }++ if (/^\s+$/.test(token)) { comb = ' '; continue; }++ const m = COMPOUND_RE.exec(token);++ if (!m) return null;++ const compound = { tag: m[1] && m[1] !== '*' ? m[1].toLowerCase() : null, id: null, classes: [], attrs: [] };++ let p;++ PART_RE.lastIndex = 0;++ while ((p = PART_RE.exec(m[2]))) {++ if (p[1] === '#') compound.id = p[2];++ else if (p[1] === '.') compound.classes.push(p[2]);++ else {++ const a = /^([:@\w.-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?$/.exec(p[3].trim());++ if (!a) return null;++ compound.attrs.push({ name: a[1].toLowerCase(), value: a[2] ?? a[3] ?? a[4] ?? null });++ }++ }++ parts.push({ comb, compound });++ comb = ' ';++ }++ return parts.length ? parts : null;++}++++function matchesCompound(el, c) {++ if (c.tag && el.tag !== c.tag) return false;++ if (c.id && el.attrs.id !== c.id) return false;++ if (c.classes.length && !c.classes.every((x) => String(el.attrs.class || '').split(/\s+/).includes(x))) return false;++ for (const a of c.attrs) {++ if (el.attrs[a.name] === undefined) return false;++ if (a.value != null && el.attrs[a.name] !== a.value) return false;++ }++ return true;++}++++function matchesFrom(el, parts, i) {++ if (!matchesCompound(el, parts[i].compound)) return false;++ if (i === 0) return true;++ if (parts[i].comb === '>') return el.parent != null && matchesFrom(el.parent, parts, i - 1);++ for (let p = el.parent; p; p = p.parent) if (matchesFrom(p, parts, i - 1)) return true;++ return false;++}++++/** Elements matching a parsed selector, in document order. */++function selectAll(root, parts) {++ const out = [];++ const walk = (el) => {++ for (const child of el.children) {++ if (child.type !== 'element') continue;++ if (matchesFrom(child, parts, parts.length - 1)) out.push(child);++ walk(child);++ }++ };++ walk(root);++ return out;++}++++/** The text of an element, markup stripped, in document order (script and style dropped). */++function innerText(el, max = VALUE_MAX) {++ let s = '';++ const push = (t) => { if (s.length < max) s += t; };++ const walk = (node) => {++ if (s.length >= max) return;++ for (const child of node.children) {++ if (child.type === 'text') push(child.value);++ else if (!RAW.has(child.tag)) { walk(child); push(' '); }++ }++ };++ walk(el);++ return (toText(s, max) || '').slice(0, max);++}++++// ── Paths (json / jsonpath / fields) ─────────────────────────────────────────────────────────++const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;++++/** Dot path with array indexes: 'items.0.fields.price' or 'items[0].price'. Missing → undefined. */++function at(obj, path) {++ if (path == null || path === '') return obj;++ const clean = String(path).replace(/\[(\d+)\]/g, '.$1').replace(/^\./, '');++ if (!PATH_RE.test(clean)) return undefined;++ let cur = obj;++ for (const seg of clean.split('.')) {++ if (cur == null || typeof cur !== 'object') return undefined;++ cur = cur[seg];++ }++ return cur;++}++++function parseJson(text) {++ try {++ return JSON.parse(text);++ } catch {++ throw new ExtractError('parse_error', 'the body is not valid JSON');++ }++}++++/** '/<re>/flags' or a bare pattern → a RegExp; oversized or invalid patterns throw. */++function compileRegex(pattern) {++ const s = String(pattern || '');++ if (!s || s.length > 200) throw new ExtractError('unsupported', 'regex extraction needs a pattern of at most 200 characters');++ const m = /^\/(.*)\/([gimsuy]*)$/.exec(s);++ try {++ return m ? new RegExp(m[1], m[2].replace('g', '')) : new RegExp(s);++ } catch {++ throw new ExtractError('parse_error', 'selector is not a valid regular expression');++ }++}++++function capped(text, max) {++ const s = String(text == null ? '' : text);++ return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;++}++++/**++ * extract(extraction, { body, contentType, document, snapshotMax })++ * → { value, snapshot }++ *++ * `body` is the fetched body (Buffer or text); `document` is what a carrier already parsed (a++ * feed's or api's mapped records) and is used by json/jsonpath when present. `snapshot` is the++ * capped copy of the body the value came from (the caller decides whether to keep it).++ */++function extract(extraction = {}, { body = null, contentType = null, document = undefined, snapshotMax = 8 * 1024 } = {}) {++ const kind = extraction.kind || 'text';++ const text = body == null ? null : (Buffer.isBuffer(body) ? decodeBody(body, contentType) : String(body));++ const snapshot = text == null ? null : capped(text, snapshotMax);++ const doc = () => (document !== undefined ? document : parseJson(text == null ? '' : text));++++ switch (kind) {++ case 'ai':++ throw new ExtractError('unsupported', 'ai extraction is an AI action, not a check (plan T18 step 10): this watch cannot be checked yet');++ case 'text':++ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');++ return { value: capped(text.trim(), VALUE_MAX), snapshot };++ case 'html': {++ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');++ if (!extraction.selector) return { value: toText(text, VALUE_MAX), snapshot };++ const parts = parseSelector(extraction.selector);++ if (!parts) throw new ExtractError('unsupported', `selector "${extraction.selector}" is not in the supported subset`);++ const first = selectAll(parseHtml(text), parts)[0];++ return { value: first ? innerText(first) || null : null, snapshot };++ }++ case 'css': {++ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');++ if (!extraction.selector) throw new ExtractError('unsupported', 'css extraction needs a selector');++ const parts = parseSelector(extraction.selector);++ if (!parts) throw new ExtractError('unsupported', `selector "${extraction.selector}" is not in the supported subset`);++ const first = selectAll(parseHtml(text), parts)[0];++ return { value: first ? innerText(first) || null : null, snapshot };++ }++ case 'json':++ case 'jsonpath': {++ const d = doc();++ const value = extraction.fields ? fieldsOf(d, extraction.fields) : (extraction.value_path ? at(d, extraction.value_path) : d);++ return { value: value === undefined ? null : value, snapshot };++ }++ case 'regex': {++ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');++ const re = compileRegex(extraction.selector);++ // Bounded input: a watch's own pattern is compiled as-is, so it only ever runs over a++ // window of VALUE_MAX characters (a value beyond that is not a value this service keeps).++ const m = re.exec(text.slice(0, VALUE_MAX));++ if (!m) return { value: null, snapshot };++ return { value: m.length > 1 ? (m[1] === undefined ? null : m[1]) : m[0], snapshot };++ }++ default:++ throw new ExtractError('unsupported', `extraction kind "${kind}" is not supported`);++ }++}++++/** extraction.fields: { name: path } → an object of the values the document states (null when absent). */++function fieldsOf(document, fields) {++ const out = {};++ for (const [name, path] of Object.entries(fields || {})) {++ const v = at(document, path);++ out[name] = v === undefined ? null : v;++ }++ return out;++}++++module.exports = { extract, ExtractError, at, parseSelector, parseHtml, selectAll, VALUE_MAX };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * OpenVibe.Watch entry point.++ *++ * node server/index.js (systemd: openvibe-watch.service)++ *++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable++ * clock/fetch/log/DNS lookup, and returns handles to every part. Nothing starts at module load:++ * the database, the JWKS client, the scheduler and the outbox relay are all started here, and every++ * one of them is stopped by close().++ */++const { load } = require('./config');++const { openDb } = require('./db');++const { createRegistry } = require('./registry');++const { createObservations } = require('./observations');++const { createGuard } = require('./net/guard');++const { createFetcher } = require('./net/fetcher');++const { createSpacer } = require('./spacer');++const { createCarriers } = require('./carriers');++const { extract } = require('./extract');++const condition = require('./condition'); // { evaluate, hasChanged, holds, … }++const { createCheck } = require('./check');++const { createScheduler } = require('./scheduler');++const { createOutbox, createRelay } = require('./events/outbox');++const { jwksClient } = require('openvibe-sdk/auth');++const { gracefulStop } = require('openvibe-sdk/service');++const { createAuth } = require('./auth');++const { createApp } = require('./app');++++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null } = {}) {++ config = config || load();++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.++ const db = givenDb || await openDb(config, { log });++ const outbox = createOutbox(db, { source: config.serviceId, 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 registry = createRegistry({ db, now, outbox });++ const observations = createObservations({ db, now });++ // The only way out to the internet: SSRF guard (ports, private hosts, every redirect hop), byte++ // cap, one deadline, conditional GET; per-host spacing in front of every request.++ const guard = createGuard({ allowPrivateHosts: config.fetch.allowPrivateHosts, allowedPorts: config.fetch.allowedPorts, ...(lookupImpl ? { lookupImpl } : {}) });++ const fetcher = createFetcher({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.fetch.timeoutMs, maxBytes: config.fetch.maxBytes, maxRedirects: config.fetch.maxRedirects });++ const spacer = createSpacer({ hostMinIntervalMs: config.fetch.hostMinIntervalMs });++ // Every carrier goes out through the spacer, so two watches over one host never hammer it.++ const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log });++ const check = createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, now, log, relay });++ const scheduler = createScheduler({ db, check, config, now, log });++ const keys = jwksClient(config.jwksUrl, { fetch: fetchImpl, log });++ const auth = createAuth({ config, log });++ const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow });++ // One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background++ // on an unref'd timer, keeping the last good keys through a Network outage.++ keys.start();++ const keyLoaded = keys.keys().catch(() => null);++ relay.start();++ if (config.worker.enabled) scheduler.start();++ const pruneTimer = setInterval(async () => {++ try { await observations.prune(); } catch (err) { log.error(`[observations] prune: ${err.message}`); }++ }, 3600 * 1000);++ pruneTimer.unref?.();++ const outboxPruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);++ outboxPruneTimer.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(`[watch] listening on http://${config.host}:${server.address().port}`);++ }++++ async function close() {++ clearInterval(pruneTimer);++ clearInterval(outboxPruneTimer);++ keys.stop();++ await scheduler.stop();++ await relay.stop();++ if (server) {++ server.closeAllConnections?.();++ await new Promise(resolve => server.close(() => resolve()));++ }++ if (!givenDb) await db.close();++ }++++ return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, app, server, close };++}++++/**++ * The fetcher every carrier uses: one request per host at a time, spaced like any other client to++ * that host — including every redirect hop, which the fetcher hands us before it follows it. A URL++ * the spacer cannot read is not a URL the guard would pass either; the real fetcher refuses it and++ * the carrier records the refusal.++ */++function spacedFetcher(fetcher, spacer) {++ return {++ fetchUrl: async (url, opts = {}) => {++ await spacer.space(new URL(url).host);++ const inner = opts.beforeHop;++ return await fetcher.fetchUrl(url, {++ ...opts,++ beforeHop: async (next) => {++ await spacer.space(next.host);++ return inner ? await inner(next) : null;++ },++ });++ },++ };++}++++if (require.main === module) {++ require('dotenv').config();++ start().then((handles) => {++ // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's handles family, Watch 25 s): requests in++ // flight get 15 s, then handles.close() (the prune timers and JWKS refresher stopped, the scheduler,++ // relay and server stopped, the database closed; a rejection exits 1); past 25 s the process exits 1.++ gracefulStop({ name: 'Watch', server: handles.server, handles, drainMs: 15000, deadlineMs: 25000 });++ }).catch((err) => {++ console.error(`[watch] failed to start: ${err.stack || err}`);++ process.exit(1);++ });++}++++module.exports = { start };nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++/**++ * The only way Watch reaches the internet: GET (or HEAD) with an address guard (server/net/guard.js),++ * manual redirects re-checked hop by hop, conditional headers, one overall deadline covering++ * connect + headers + body, a byte cap applied to the decoded body, and no cookies.++ *++ * fetchUrl(url, { method, headers, etag, lastModified, beforeHop(url) }) →++ * { kind: 'response', status, headers, body (Buffer|null), finalUrl, bytes }++ * or throws FetchError { code: timeout | too_large | address_refused | port_refused | bad_url |++ * too_many_redirects | network | hop_refused, detail }++ *++ * Copied from OpenVibe.Sources/server/net/fetcher.js; Watch adds `method: 'HEAD'`, which a++ * conditional watch uses to ask "has this changed?" without pulling the body (the dossier's++ * "HEAD first when only change is needed").++ */++const http = require('http');++const https = require('https');++const zlib = require('zlib');++const { GuardError } = require('./guard');++++class FetchError extends Error {++ constructor(code, detail) { super(detail); this.code = code; }++}++++function createFetcher({ guard, userAgent, timeoutMs = 20000, maxBytes = 5 * 1024 * 1024, maxRedirects = 5 }) {++ function once(url, method, headers, deadline, capBytes) {++ return new Promise((resolve, reject) => {++ const mod = url.protocol === 'https:' ? https : http;++ const remaining = deadline - Date.now();++ if (remaining <= 0) return reject(new FetchError('timeout', 'deadline passed'));++ let settled = false;++ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v); } };++ const req = mod.request(url, {++ method,++ headers,++ lookup: guard.lookup,++ agent: false,++ timeout: remaining,++ }, (res) => {++ const status = res.statusCode;++ if (status >= 300 && status < 400 && res.headers.location) {++ res.resume();++ return done(resolve, { redirect: res.headers.location, status, headers: res.headers });++ }++ // A HEAD answer carries no body by definition; nothing to cap or collect.++ if (method === 'HEAD') {++ res.resume();++ return done(resolve, { status, headers: res.headers, body: null, bytes: 0 });++ }++ const declared = Number(res.headers['content-length']);++ if (Number.isFinite(declared) && declared > capBytes && status === 200) {++ req.destroy();++ return done(reject, new FetchError('too_large', `content-length ${declared} exceeds ${capBytes} bytes`));++ }++ let stream = res;++ const enc = String(res.headers['content-encoding'] || '').trim().toLowerCase();++ if (enc === 'gzip' || enc === 'x-gzip') stream = res.pipe(zlib.createGunzip());++ else if (enc === 'deflate') stream = res.pipe(zlib.createInflate());++ else if (enc === 'br') stream = res.pipe(zlib.createBrotliDecompress());++ const chunks = [];++ let bytes = 0;++ stream.on('data', (c) => {++ bytes += c.length;++ if (bytes > capBytes) {++ req.destroy();++ done(reject, new FetchError('too_large', `body exceeds ${capBytes} bytes`));++ return;++ }++ chunks.push(c);++ });++ stream.on('end', () => done(resolve, { status, headers: res.headers, body: Buffer.concat(chunks), bytes }));++ stream.on('error', (err) => done(reject, new FetchError('network', `body: ${err.message}`)));++ res.on('aborted', () => done(reject, new FetchError('network', 'response aborted')));++ });++ const timer = setTimeout(() => {++ req.destroy();++ done(reject, new FetchError('timeout', `no complete response within ${timeoutMs} ms`));++ }, remaining);++ req.on('timeout', () => {++ req.destroy();++ done(reject, new FetchError('timeout', `no complete response within ${timeoutMs} ms`));++ });++ req.on('error', (err) => {++ if (err instanceof GuardError) return done(reject, new FetchError(err.code, err.message));++ done(reject, new FetchError('network', `${err.code || 'error'}: ${err.message}`));++ });++ req.end();++ });++ }++++ async function fetchUrl(input, { method = 'GET', headers = {}, etag = null, lastModified = null, beforeHop = null, capBytes = maxBytes, sensitiveHeaders = [] } = {}) {++ const verb = String(method).toUpperCase() === 'HEAD' ? 'HEAD' : 'GET';++ const deadline = Date.now() + timeoutMs;++ let url;++ try { url = guard.checkUrl(input); } catch (err) { throw new FetchError(err.code || 'bad_url', err.message); }++ const h = {++ 'User-Agent': userAgent,++ Accept: headers.Accept || '*/*',++ 'Accept-Encoding': 'gzip, deflate, br',++ ...headers,++ };++ if (etag) h['If-None-Match'] = etag;++ if (lastModified) h['If-Modified-Since'] = lastModified;++ for (let hop = 0; ; hop++) {++ const r = await once(url, verb, h, deadline, capBytes);++ if (!r.redirect) return { kind: 'response', status: r.status, headers: r.headers, body: r.body, bytes: r.bytes, finalUrl: url.toString() };++ if (hop >= maxRedirects) throw new FetchError('too_many_redirects', `more than ${maxRedirects} redirects`);++ let next;++ try { next = guard.checkUrl(new URL(r.redirect, url).toString()); } catch (err) { throw new FetchError(err.code || 'bad_url', `redirect: ${err.message}`); }++ if (beforeHop) {++ const verdict = await beforeHop(next);++ if (verdict && verdict.refuse) throw new FetchError('hop_refused', verdict.refuse);++ }++ // Conditional headers belong to the original resource only; credentials never leave++ // the origin they were configured for.++ delete h['If-None-Match'];++ delete h['If-Modified-Since'];++ if (next.origin !== url.origin) for (const name of sensitiveHeaders) delete h[name];++ url = next;++ }++ }++++ return { fetchUrl };++}++++module.exports = { createFetcher, FetchError };nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * Outbound address policy (roadmap anti-goal 13: no fetcher may reach internal addresses).++ *++ * A URL is fetchable only when it is http(s), on an allowed port, and every address its host++ * resolves to is public unicast. The check runs at connect time through the socket's `lookup`++ * (so a DNS answer cannot change between check and connect) and up front for IP literals (which++ * never go through lookup). SOURCES_ALLOW_PRIVATE_HOSTS lists exact hostnames/IPs exempted —++ * for tests and deliberate on-host sources only.++ */++const dns = require('dns');++const net = require('net');++const sharedEgress = require('openvibe-shared/egress');++++const blocked = new net.BlockList();++for (const [addr, prefix] of [++ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],++ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],++ ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],++]) blocked.addSubnet(addr, prefix, 'ipv4');++for (const [addr, prefix] of [++ ['::', 128], ['::1', 128], ['100::', 64], ['2001:db8::', 32], ['2001::', 32], ['fc00::', 7],++ ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],++]) blocked.addSubnet(addr, prefix, 'ipv6');++++class GuardError extends Error {++ constructor(code, detail) { super(detail); this.code = code; }++}++++/** IPv4 embedded in IPv4-mapped (::ffff:a.b.c.d), NAT64 (64:ff9b::/96) or 6to4 (2002::/16). */++function embeddedV4(ip) {++ const lower = ip.toLowerCase();++ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);++ if (mapped) return mapped[1];++ const full = expandV6(lower);++ if (!full) return null;++ const hex = (i) => parseInt(full[i], 16);++ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;++ if (full.slice(0, 5).every(h => h === '0000') && full[5] === 'ffff') return v4(hex(6), hex(7));++ if (full[0] === '0064' && full[1] === 'ff9b' && full.slice(2, 6).every(h => h === '0000')) return v4(hex(6), hex(7));++ if (full[0] === '2002') return v4(hex(1), hex(2));++ return null;++}++++function expandV6(ip) {++ if (!net.isIPv6(ip)) return null;++ let s = ip;++ const v4tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);++ if (v4tail) {++ const p = v4tail[1].split('.').map(Number);++ s = s.slice(0, -v4tail[1].length) + ((p[0] << 8) | p[1]).toString(16) + ':' + ((p[2] << 8) | p[3]).toString(16);++ }++ const [head, tail] = s.split('::');++ const h = head ? head.split(':') : [];++ const t = tail !== undefined ? (tail ? tail.split(':') : []) : [];++ const fill = tail !== undefined ? Array(8 - h.length - t.length).fill('0') : [];++ const parts = [...h, ...fill, ...t];++ if (parts.length !== 8) return null;++ return parts.map(x => x.padStart(4, '0'));++}++++/**++ * Is this IP address public unicast? It must pass this file's list AND the platform's rule++ * (openvibe-shared/egress, which Live, Events, Chat and Tools use): the list here had drifted and++ * let IPv4-compatible IPv6 (::127.0.0.1, ::7f00:1) and the local-use NAT64 prefix (64:ff9b:1::/48)++ * through. test/security-ssrf.test.js holds the two rules equal.++ */++function isPublicAddress(ip) {++ const family = net.isIP(ip);++ let ok = false;++ if (family === 4) ok = !blocked.check(ip, 'ipv4');++ else if (family === 6) {++ const v4 = embeddedV4(ip);++ ok = v4 ? !blocked.check(v4, 'ipv4') : !blocked.check(ip, 'ipv6');++ }++ return ok && sharedEgress.isPublicAddress(ip);++}++++function createGuard({ allowPrivateHosts = [], allowedPorts = [80, 443], lookupImpl = dns.lookup } = {}) {++ const exempt = new Set(allowPrivateHosts.map(h => h.toLowerCase().replace(/^\[|\]$/g, '')));++++ /** Synchronous URL checks: scheme, credentials, port, IP literal. Throws GuardError. */++ function checkUrl(input) {++ let url;++ try { url = new URL(input); } catch { throw new GuardError('bad_url', 'not a URL'); }++ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new GuardError('bad_url', `scheme ${url.protocol} is not fetched`);++ if (url.username || url.password) throw new GuardError('bad_url', 'credentials in URLs are not allowed');++ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');++ const port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80);++ if (!exempt.has(host) && !allowedPorts.includes(port)) throw new GuardError('port_refused', `port ${port} is not allowed`);++ if (net.isIP(host) && !exempt.has(host) && !isPublicAddress(host)) throw new GuardError('address_refused', `${host} is not a public address`);++ return url;++ }++++ /** dns.lookup replacement for http(s).request: refuses non-public answers at connect time. */++ function lookup(hostname, options, callback) {++ if (typeof options === 'function') { callback = options; options = {}; }++ const opts = typeof options === 'number' ? { family: options } : { ...(options || {}) };++ const host = String(hostname).toLowerCase();++ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {++ if (err) return callback(err);++ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];++ if (!list.length) return callback(Object.assign(new Error(`no address for ${hostname}`), { code: 'ENOTFOUND' }));++ if (!exempt.has(host)) {++ const bad = list.find(a => !isPublicAddress(a.address));++ if (bad) {++ const e = new GuardError('address_refused', `${hostname} resolves to a non-public address`);++ e.errno = 'EADDRREFUSED';++ return callback(e);++ }++ }++ if (opts.all) return callback(null, list);++ return callback(null, list[0].address, list[0].family);++ });++ }++++ return { checkUrl, lookup, isPublicAddress };++}++++module.exports = { createGuard, isPublicAddress, GuardError, embeddedV4 };nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * Track O: truthful readiness for GET /api/ready and the Watch gauges on GET /metrics++ * (openvibe-shared/ready and openvibe-shared/metrics).++ *++ * db required a real query on the watch registry: without it nothing is served or checked++ * network_jwks optional the Network signing keys have loaded into the SDK's JWKS client. Without them++ * no token can be verified (the API answers 503), but checks keep running, so++ * it degrades rather than fails++ * checker optional the check worker is on and running, and its queue keeps up: no active watch++ * has waited longer than OVERDUE_MS. Without it watches stop being checked++ *++ * Gauges: watches by health status, the check queue (due, in flight), the time of the last finished++ * check and of the last successful one, observations by state, and the outbox backlog.++ */++const { createReadiness } = require('openvibe-shared/ready');++const { jwksStatus } = require('openvibe-sdk/auth');++++const OVERDUE_MS = 15 * 60 * 1000;++const STATUSES = ['healthy', 'failing', 'never_checked', 'waiting', 'paused', 'disabled', 'failed'];++const OBSERVATION_STATES = ['unchanged', 'changed', 'condition_met'];++++function readers(db, { startedAt }) {++ // A watch waits from when it became due, when it was last changed (a new or re-enabled watch is++ // due at once, from next_due_at 0) or when this process started, whichever is latest.++ const due = db.prepare(`SELECT COUNT(*) AS n, MIN(GREATEST(next_due_at, not_before, updated_at)) AS oldest FROM watches++ WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL AND next_due_at <= @now AND not_before <= @now`);++ const lastCheck = db.prepare('SELECT finished_at FROM check_runs ORDER BY rid DESC LIMIT 1');++ const lastSuccess = db.prepare('SELECT MAX(last_success_at) AS t FROM watches');++ const observations = db.prepare(`SELECT COUNT(*) AS n,++ SUM(CASE WHEN condition_met = 1 THEN 1 ELSE 0 END) AS met,++ SUM(CASE WHEN condition_met = 0 AND changed = 1 THEN 1 ELSE 0 END) AS changed FROM observations`);++ return {++ /** Active watches whose turn has come, and how long the oldest has waited. */++ async queue(t) {++ const r = await due.get({ now: t });++ return { due: r.n, oldest_wait_ms: r.n ? Math.max(0, t - Math.max(r.oldest, startedAt)) : 0 };++ },++ lastCheckAt: async () => { const r = await lastCheck.get(); return r ? r.finished_at : null; },++ lastSuccessAt: async () => (await lastSuccess.get()).t,++ observations: async () => {++ const r = await observations.get();++ const n = Number(r.n) || 0;++ const met = Number(r.met) || 0;++ const changed = Number(r.changed) || 0;++ return { unchanged: n - met - changed, changed, condition_met: met };++ },++ };++}++++function createWatchReadiness({ db, config, registry, check, observations, scheduler, outbox, relay, now, release = null }) {++ const read = readers(db, { startedAt: now() });++ const iso = (v) => (v == null ? null : new Date(v).toISOString());++ return createReadiness({++ service: 'watch',++ release,++ checks: [++ // A real round trip that names the store (postgresql / pglite), and the schema present.++ { name: 'db', required: true, check: async () => { const r = await db.ready(); if (!r.ok) return r.error; await db.prepare('SELECT COUNT(*) AS n FROM watches').get(); return { ok: true, detail: r.detail }; } },++ { name: 'network_jwks', required: false, check: () => {++ // The SDK's client for the JWKS this service verifies against (server/index.js started it).++ // Public: counts and times only — never the internal JWKS URL and never the fetch error++ // behind it (both are logged by the SDK client, not answered).++ const st = jwksStatus().find((s) => s.url === config.jwksUrl) || null;++ if (!st) return 'the Network JWKS client has not started: no token can be verified';++ const detail = { keys: st.keys || 0, stale: Boolean(st.stale), failures: st.failures || 0, fetchedAt: iso(st.fetchedAt) };++ if (!st.ready) return { ok: false, error: 'Network signing key not loaded yet: no token can be verified', detail };++ return { ok: true, detail };++ } },++ {++ name: 'checker', required: false,++ check: async () => {++ const q = await read.queue(now());++ const detail = {++ enabled: config.worker.enabled, running: scheduler.running(), in_flight: check.inflight().length,++ max_concurrent: config.worker.maxConcurrent, due: q.due, oldest_wait_ms: q.oldest_wait_ms,++ last_check_at: iso(await read.lastCheckAt()),++ };++ if (!config.worker.enabled) return { ok: false, error: 'the check worker is off (WATCH_WORKER=off): nothing is checked', detail };++ if (!scheduler.running()) return { ok: false, error: 'the check worker is not running', detail };++ if (q.oldest_wait_ms > OVERDUE_MS) return { ok: false, error: `the check queue is behind: ${q.due} watch(es) due, the oldest for ${Math.round(q.oldest_wait_ms / 60000)} min`, detail };++ return { ok: true, detail };++ },++ },++ ],++ details: async (body) => {++ const dbOk = body.checks.db.status === 'ok';++ let watches = null;++ if (dbOk) {++ watches = {};++ for (const r of await registry.all(null)) {++ const s = registry.health(r).status;++ watches[s] = (watches[s] || 0) + 1;++ }++ }++ return {++ watches,++ checks_in_flight: check.inflight().length,++ observations: dbOk ? await read.observations() : null,++ outbox: dbOk ? { pending: await outbox.pending(), rejected: await outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,++ };++ },++ });++}++++/** Watch gauges on the openvibe-shared/metrics registry. */++function registerWatchGauges(registry, { db, watches, check, outbox, now }) {++ const read = readers(db, { startedAt: now() });++ const seconds = (ms) => (ms == null ? null : ms / 1000);++ registry.gauge({++ name: 'watch_watches', help: 'Watches by health status', labelNames: ['status'],++ collect: async () => {++ const n = Object.fromEntries(STATUSES.map((s) => [s, 0]));++ for (const r of await watches.all(null)) { const s = watches.health(r).status; n[s] = (n[s] || 0) + 1; }++ return Object.entries(n).map(([status, value]) => ({ labels: { status }, value }));++ },++ });++ registry.gauge({++ name: 'watch_observations', help: 'Observations held, by state', labelNames: ['state'],++ collect: async () => Object.entries(await read.observations()).map(([state, value]) => ({ labels: { state }, value })),++ });++ registry.gauge({ name: 'watch_checks_due', help: 'Active watches whose next check is due now (the check queue)', collect: async () => (await read.queue(now())).due });++ registry.gauge({ name: 'watch_checks_oldest_wait_seconds', help: 'How long the longest-waiting due watch has waited', collect: async () => (await read.queue(now())).oldest_wait_ms / 1000 });++ registry.gauge({ name: 'watch_checks_in_flight', help: 'Checks in progress', collect: () => check.inflight().length });++ // Left out until there is a check: a timestamp of 0 would read as "1970", not "never".++ registry.gauge({ name: 'watch_last_check_timestamp_seconds', help: 'When the last check finished (any outcome), Unix seconds', collect: async () => seconds(await read.lastCheckAt()) });++ registry.gauge({ name: 'watch_last_success_timestamp_seconds', help: 'When a watch was last checked successfully, Unix seconds', collect: async () => seconds(await read.lastSuccessAt()) });++ registry.gauge({ name: 'watch_outbox_pending', help: 'Events waiting in the outbox', collect: async () => await outbox.pending() });++}++++++module.exports = { createWatchReadiness, registerWatchGauges, OVERDUE_MS, OBSERVATION_STATES };nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * Observations: what a check recorded for a watch — the extracted value exactly as the source++ * stated it, when it was seen, which check run saw it, whether it changed and whether the watch's++ * condition held. Rows are immutable (watch.observation@1) and carry a capped snapshot of the body++ * the value came from until `retained_until`, which the watch's retention sets.++ *++ * value_hash is SHA-256 over the canonical JSON of the value, so equal values hash equal whatever++ * order their keys came in; previous_hash is the value_hash of the observation before it (null for++ * the first), which is what makes "changed" answerable without reading the earlier row back.++ */++const { validate, ids: contractIds } = require('openvibe-contracts');++const { hashValue } = require('./util');++++const DAY_MS = 24 * 3600 * 1000;++++function createObservations({ db, now = () => Date.now(), ids = contractIds }) {++ const st = {++ insert: db.prepare(`INSERT INTO observations (id, watch_id, check_run_id, observed_at, value, value_hash, previous_hash,++ changed, condition_met, snapshot, retained_until)++ VALUES (@id, @watch_id, @check_run_id, @observed_at, @value, @value_hash, @previous_hash, @changed, @condition_met, @snapshot, @retained_until)`),++ latest: db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid DESC LIMIT 1'),++ before: db.prepare('SELECT * FROM observations WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT 1'),++ page: db.prepare('SELECT * FROM observations WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT ?'),++ count: db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?'),++ trail: db.prepare('SELECT observed_at, condition_met FROM observations WHERE watch_id = ? ORDER BY rid DESC LIMIT 200'),++ prune: db.prepare('DELETE FROM observations WHERE retained_until IS NOT NULL AND retained_until <= ?'),++ };++++ const bool = (v) => Boolean(Number(v));++++ /** observation.v1.json, validated with the released schema. */++ function view(row) {++ const v = {++ id: row.id,++ watch_id: row.watch_id,++ check_run_id: row.check_run_id,++ observed_at: new Date(row.observed_at).toISOString(),++ // jsonb comes back parsed from both adapters (openvibe-sdk/db): the value as stored.++ value: row.value,++ value_hash: row.value_hash,++ previous_hash: row.previous_hash == null ? null : row.previous_hash,++ changed: bool(row.changed),++ condition_met: bool(row.condition_met),++ snapshot: row.snapshot == null ? null : row.snapshot,++ };++ const r = validate('watch.observation@1', v);++ if (!r.valid) throw new Error(`observation view does not match watch.observation@1: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ return v;++ }++++ /**++ * Build the row an observation will be (reads the previous one for previous_hash; writes++ * nothing). The caller inserts it inside the transaction that also writes the check run and the++ * events, so all three commit together.++ */++ async function build({ watch_id, check_run_id, value, snapshot = null, changed = false, condition_met = false, retentionDays = null, keepSnapshots = true, observed_at = null }) {++ const at = observed_at == null ? now() : observed_at;++ const previous = await st.latest.get(watch_id);++ const v = value === undefined ? null : value;++ return {++ id: `wco_${ids.ulid(at)}`,++ watch_id,++ check_run_id,++ observed_at: at,++ value: JSON.stringify(v),++ value_hash: hashValue(v),++ previous_hash: previous ? previous.value_hash : null,++ changed: changed ? 1 : 0,++ condition_met: condition_met ? 1 : 0,++ snapshot: keepSnapshots && snapshot != null ? snapshot : null,++ retained_until: retentionDays == null ? null : at + retentionDays * DAY_MS,++ };++ }++++ /** Insert a built row (inside the caller's transaction). */++ async function insert(row) {++ await st.insert.run(row);++ return row;++ }++++ /** build + insert, for a caller that has nothing else to commit with it. */++ async function record(input) {++ return await insert(await build(input));++ }++++ async function latest(watch_id) {++ return await st.latest.get(watch_id);++ }++++ /** The observation immediately before `row` (the previous_value of a trigger fired on it). */++ async function before(row) {++ return await st.before.get(row.watch_id, row.rid);++ }++++ /** Newest first; `before` is the rid of the last row of the previous page. */++ async function list(watch_id, { before = null, limit = 50 } = {}) {++ const b = Number.isSafeInteger(Number(before)) && before != null ? Number(before) : Number.MAX_SAFE_INTEGER;++ return await st.page.all(watch_id, b, limit);++ }++++ async function count(watch_id) {++ return (await st.count.get(watch_id)).n;++ }++++ /**++ * When the current run of observations whose condition held began (ms), or null when the last++ * observation's condition did not hold (or there is none). This is what `for_sec` measures: a++ * condition that has held continuously for that long is met, not a condition seen once.++ */++ async function heldSince(watch_id) {++ const rows = await st.trail.all(watch_id);++ let since = null;++ for (const r of rows) {++ if (!bool(r.condition_met)) break;++ since = r.observed_at;++ }++ return since;++ }++++ /** Drop observations past their retention. Rows a check run still refers to are pruned too. */++ async function prune(at = now()) {++ return (await st.prune.run(at)).changes;++ }++++ return { build, insert, record, latest, before, list, count, heldSince, prune, view };++}++++module.exports = { createObservations, DAY_MS };nnew file mode 100644@@@ -0,0 +1,354 @@++'use strict';++/**++ * The watch registry: validation, CRUD and the view.++ *++ * A Watch is a user-defined persistent observation (plan T18): where it watches (source), how often++ * or what pushes to it (cadence / event pattern), what value it takes from the answer (extraction),++ * how that value is compared with the last observation (comparison), the condition that fires++ * (condition), the actions it wakes (action, executed from step 4), its budget and its retention.++ *++ * Validation is openvibe-contracts' own validator: every request body is checked against++ * watch.watch-request@1 and every answer against watch.watch@1 (never a hand copy of the schema).++ * The rules a schema cannot carry live here: a create needs source, extraction, condition and++ * action; a source that polls (http, feed, api, node, run) needs a cadence with every_sec, and an++ * event or webhook source never polls (cadence null).++ *++ * Ownership is the authority: every read and write is scoped to the acting subject (principal.sub,++ * or the person a first-party service names with X-OV-Subject), and another owner's watch is not++ * found — 404, never 403 (an id must not confirm that someone else's watch exists).++ */++const { validate, ids: contractIds } = require('openvibe-contracts');++++/** Source kinds that reach out on a cadence; event and webhook sources are pushed to instead. */++const POLLING = new Set(['http', 'feed', 'api', 'node', 'run']);++const PUSHED = new Set(['event', 'webhook']);++const RUN_STATES = ['ok', 'not_modified', 'no_change', 'changed', 'condition_met', 'http_error', 'timeout', 'parse_error', 'rate_limited', 'budget_exceeded', 'skipped', 'disabled'];++const STATUSES = ['active', 'paused', 'disabled', 'failed'];++/** Headers the fetcher decides; a watch may never override them (SSRF and conditional-GET safety). */++const FORBIDDEN_HEADERS = new Set(['host', 'cookie', 'connection', 'content-length', 'transfer-encoding', 'user-agent', 'accept-encoding', 'if-none-match', 'if-modified-since']);++++class RegistryError extends Error {++ constructor(detail, code = 'watch.invalid') { super(detail); this.code = code; }++}++++const parse = (v) => (v == null ? null : (typeof v === 'string' ? JSON.parse(v) : v));++++/** A name a watch can always carry, taken from the source it watches (watch.watch@1 requires one). */++function defaultName(source) {++ if (!source || typeof source !== 'object') return 'watch';++ if (source.url) {++ try {++ const u = new URL(source.url);++ return `${u.host}${u.pathname}`.slice(0, 120);++ } catch { return String(source.url).slice(0, 120); }++ }++ if (source.pattern) return source.pattern.slice(0, 120);++ if (source.endpoint) return `webhook ${source.endpoint}`.slice(0, 120);++ if (source.node_id) return `node ${source.node_id}`.slice(0, 120);++ if (source.runtime) return `run ${source.runtime}`.slice(0, 120);++ return source.kind ? `${source.kind} watch` : 'watch';++}++++/**++ * Validate a watch-request body (openvibe-contracts' validator). `create` adds the rules of a new++ * watch. Returns the body unchanged; throws RegistryError (watch.invalid) with a readable detail.++ */++function validateRequest(body, { create = false } = {}) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RegistryError('body must be a watch object');++ const v = validate('watch.watch-request@1', body);++ if (!v.valid) throw new RegistryError(`body does not match watch.watch-request@1: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ if (create) {++ for (const field of ['source', 'extraction', 'condition', 'action']) {++ if (body[field] === undefined || body[field] === null) throw new RegistryError(`a new watch requires ${field}`);++ }++ }++ const cadence = body.cadence === undefined ? undefined : body.cadence;++ if (body.source && body.source.headers) {++ // A watch may add headers to its own request, but not the ones that decide who is asking or++ // what was asked: the fetcher owns those (OpenVibe.Sources refuses the same set).++ for (const name of Object.keys(body.source.headers)) {++ if (FORBIDDEN_HEADERS.has(name.toLowerCase())) throw new RegistryError(`source.headers.${name} is set by the fetcher and cannot be overridden`);++ }++ }++ if (body.source) {++ const kind = body.source.kind;++ if (POLLING.has(kind)) {++ if (cadence === null || cadence === undefined) throw new RegistryError(`a ${kind} source needs a cadence (every_sec)`);++ if (!Number.isInteger(cadence.every_sec)) throw new RegistryError('cadence.every_sec is required for a source that polls');++ }++ if (PUSHED.has(kind) && cadence) throw new RegistryError(`a ${kind} source is pushed to and never polls: cadence must be null`);++ // A HEAD source states no body, so only a document/record extraction can read one: refuse++ // the misconfiguration here instead of recording `parse_error` on every check.++ if (body.source.method === 'HEAD' && !['json', 'jsonpath'].includes((body.extraction || {}).kind)) {++ throw new RegistryError('a HEAD source states no body: its extraction must be json or jsonpath (status, etag, last_modified, content_type, content_length)');++ }++ }++ return body;++}++++/**++ * When an active, polling watch is next due: every_sec from `at` plus its jitter, or — after++ * failures — an exponential backoff (every_sec · 2^(failures-1), capped at the worker's maximum,++ * the same shape OpenVibe.Sources uses for a failing source). 0 for a source that never polls.++ */++function nextDue(rec, at, { failures = 0, maxBackoffMs = Infinity } = {}) {++ if (!rec || !rec.cadence || !rec.cadence.every_sec) return 0;++ const every = rec.cadence.every_sec * 1000;++ if (failures > 0) return at + Math.min(every * 2 ** Math.min(failures - 1, 10), Math.max(maxBackoffMs, every));++ const jitter = Math.max(0, Number(rec.cadence.jitter_sec) || 0);++ return at + every + (jitter ? Math.floor(Math.random() * jitter * 1000) : 0);++}++++/** Validate an answer (openvibe-contracts' validator). A view this service produces must always fit. */++function validateView(view) {++ const v = validate('watch.watch@1', view);++ if (!v.valid) throw new RegistryError(`watch view does not match watch.watch@1: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`, 'watch.internal');++ return view;++}++++function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox = null }) {++ const st = {++ get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),++ insert: db.prepare(`INSERT INTO watches (id, project_id, owner_sub, name, status, source, cadence, extraction, comparison, condition,++ action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)++ VALUES (@id, @project_id, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,++ @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),++ };++++ /** Every column a patch may write, and nothing else. */++ const COLUMNS = {++ name: (v) => v,++ labels: (v) => JSON.stringify(v || {}),++ status: (v) => v,++ source: (v) => JSON.stringify(v),++ cadence: (v) => (v == null ? null : JSON.stringify(v)),++ extraction: (v) => JSON.stringify(v),++ comparison: (v) => (v == null ? null : JSON.stringify(v)),++ condition: (v) => JSON.stringify(v),++ action: (v) => JSON.stringify(v || []),++ budget: (v) => JSON.stringify(v || {}),++ retention: (v) => JSON.stringify(v || {}),++ project_id: (v) => v,++ };++++ /** The stored record, request-shaped (what a PATCH merges into). */++ function fromRow(row) {++ if (!row) return null;++ return {++ id: row.id,++ project_id: row.project_id,++ owner: row.owner_sub,++ name: row.name,++ labels: parse(row.labels) || {},++ status: row.status,++ source: parse(row.source),++ cadence: parse(row.cadence),++ extraction: parse(row.extraction) || {},++ comparison: parse(row.comparison),++ condition: parse(row.condition) || {},++ action: parse(row.action) || [],++ budget: parse(row.budget) || {},++ retention: parse(row.retention) || {},++ };++ }++++ /** What the operator sees about a watch's runtime, never invented: counts and times only. */++ function health(row) {++ const iso = (v) => (v == null ? null : new Date(v).toISOString());++ const polls = row.cadence != null;++ let status;++ if (row.status === 'paused') status = 'paused';++ else if (row.status === 'disabled') status = 'disabled';++ else if (row.status === 'failed') status = 'failed';++ else if (!polls) status = 'waiting';++ else if (row.last_check_at == null) status = 'never_checked';++ else if (row.consecutive_failures > 0) status = 'failing';++ else status = 'healthy';++ return {++ status,++ last_check_at: iso(row.last_check_at),++ last_success_at: iso(row.last_success_at),++ last_state: row.last_state,++ consecutive_failures: row.consecutive_failures,++ next_due_at: row.status === 'active' && polls ? iso(Math.max(row.next_due_at, row.not_before)) : null,++ not_before: iso(row.not_before || null),++ };++ }++++ /** row → watch.watch@1, validated with the released schema. */++ function view(row) {++ const rec = fromRow(row);++ return validateView({++ id: rec.id,++ project_id: rec.project_id,++ owner: rec.owner,++ name: rec.name,++ labels: rec.labels,++ status: rec.status,++ source: rec.source,++ cadence: rec.cadence,++ extraction: rec.extraction,++ comparison: rec.comparison,++ condition: rec.condition,++ action: rec.action,++ budget: rec.budget,++ retention: rec.retention,++ health: health(row),++ created_at: new Date(row.created_at).toISOString(),++ updated_at: new Date(row.updated_at).toISOString(),++ });++ }++++ const ownerOf = (principal) => (principal && (principal.subject || principal.sub)) || null;++++ /** The next time an active, polling watch is due: every_sec from now, plus its jitter. */++ function arm(rec, at) {++ return nextDue(rec, at, {});++ }++++ /** The row when it exists AND belongs to the acting subject; null otherwise (routes answer 404). */++ async function get(id, principal) {++ const row = await st.get.get(String(id || ''));++ if (!row) return null;++ const owner = ownerOf(principal);++ if (owner && row.owner_sub !== owner) return null;++ return row;++ }++++ async function create(body, principal) {++ const req = validateRequest(body, { create: true });++ const owner = ownerOf(principal);++ if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');++ const t = now();++ const rec = {++ name: req.name || defaultName(req.source),++ status: req.status || 'active',++ source: req.source,++ cadence: req.cadence == null ? null : req.cadence,++ extraction: req.extraction,++ comparison: req.comparison == null ? null : req.comparison,++ condition: req.condition,++ action: req.action,++ budget: req.budget || {},++ retention: req.retention || {},++ labels: req.labels || {},++ };++ const row = {++ id: `wch_${ids.ulid(t)}`,++ project_id: null,++ owner_sub: owner,++ name: rec.name,++ status: rec.status,++ source: JSON.stringify(rec.source),++ cadence: rec.cadence ? JSON.stringify(rec.cadence) : null,++ extraction: JSON.stringify(rec.extraction),++ comparison: rec.comparison ? JSON.stringify(rec.comparison) : null,++ condition: JSON.stringify(rec.condition),++ action: JSON.stringify(rec.action),++ budget: JSON.stringify(rec.budget),++ retention: JSON.stringify(rec.retention),++ labels: JSON.stringify(rec.labels),++ // A new active polling watch takes its first reading on the next scheduler tick (as Sources arms a new++ // source), not one cadence later; resume/patch re-arm by cadence below.++ next_due_at: rec.status === 'active' ? t : 0,++ created_at: t,++ updated_at: t,++ updated_by: owner,++ };++ // The row and its event commit together: an event exists exactly when its effect does.++ await db.tx(async () => {++ await st.insert.run(row);++ if (outbox) await outbox.enqueue(watchEvent('watch.watch.created', row, owner, { name: rec.name, status: rec.status }));++ });++ return await st.get.get(row.id);++ }++++ /** Only the fields the patch names are written; the merged watch is validated first. */++ async function patch(id, changes, principal) {++ const row = await get(id, principal);++ if (!row) return null;++ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new RegistryError('body must be a watch object');++ const current = fromRow(row);++ const merged = { ...current, ...changes };++ delete merged.id; // an id never changes++ delete merged.owner; // ownership never changes (a watch is the subject's)++ // project_id is not part of watch.watch-request@1: the row's own null is dropped, and a++ // caller that sends one fails the schema check below rather than being silently ignored.++ if (changes.project_id === undefined) delete merged.project_id;++ validateRequest(merged);++ const sets = [];++ const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };++ for (const [k, v] of Object.entries(changes)) {++ if (!COLUMNS[k]) continue;++ sets.push(`${k} = @${k}`);++ args[k] = COLUMNS[k](v);++ }++ if (!sets.length) return await st.get.get(row.id);++ // A changed watch gets a fresh cadence: re-arm from now, so editing never fires late work.++ const rec = { ...current, ...changes };++ args.next_due_at = rec.status === 'active' ? arm(rec, args.updated_at) : 0;++ sets.push('next_due_at = @next_due_at');++ sets.push('updated_at = @updated_at', 'updated_by = @updated_by');++ const type = changes.status === 'paused' ? 'watch.watch.paused' : 'watch.watch.updated';++ const touched = Object.keys(changes).filter((k) => k !== 'labels');++ await db.tx(async () => {++ await db.prepare(`UPDATE watches SET ${sets.join(', ')} WHERE id = @id AND deleted_at IS NULL`).run(args);++ if (outbox && touched.length) {++ await outbox.enqueue(watchEvent(type, row, row.owner_sub, { name: rec.name, status: rec.status, fields: touched.sort() }));++ }++ });++ return await st.get.get(row.id);++ }++++ /** Soft delete: the rows an observation refers to stay resolvable. */++ async function remove(id, principal) {++ const row = await get(id, principal);++ if (!row) return null;++ const t = now();++ await db.tx(async () => {++ await db.prepare('UPDATE watches SET deleted_at = ?, updated_at = ?, next_due_at = 0 WHERE id = ?').run(t, t, row.id);++ if (outbox) await outbox.enqueue(watchEvent('watch.watch.removed', row, row.owner_sub, { name: row.name, status: row.status }));++ });++ return { id: row.id, deleted: true };++ }++++ /**++ * Watches, newest first, scoped to the acting subject. The keyset is `id`: a wch_ ULID is++ * creation order, so `id < before` is exactly "older than the last row of the previous page",++ * with no ties.++ *++ * A null principal means NO scoping and is the service's own view (the gauges and the readiness++ * detail): every API route passes req.principal, which the capability guard always sets.++ */++ async function all(principal, { status = null, project_id = null, before = null, limit = 50 } = {}) {++ const owner = principal == null ? null : ownerOf(principal);++ const rows = await db.prepare(`SELECT * FROM watches WHERE deleted_at IS NULL++ AND (@owner::text IS NULL OR owner_sub = @owner)++ AND (@status::text IS NULL OR status = @status)++ AND (@project_id::text IS NULL OR project_id = @project_id)++ AND (@before::text IS NULL OR id < @before)++ ORDER BY id DESC LIMIT @limit`).all({ owner, status, project_id, before, limit });++ return rows;++ }++++ /** Active watches whose turn has come, oldest due first (the scheduler's queue). */++ async function due(limit) {++ return await db.prepare(`SELECT id FROM watches WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL++ AND next_due_at <= @now AND not_before <= @now ORDER BY next_due_at, id LIMIT @limit`).all({ now: now(), limit });++ }++++ /** The envelope every watch-lifecycle event shares: the watch's own subject, internal. */++ function watchEvent(event_type, row, owner, payload) {++ return {++ event_type,++ subject: { type: 'watch', id: row.id },++ payload: { watch_id: row.id, owner, ...payload },++ visibility: 'internal',++ priority: 'low',++ };++ }++++ return { validate: validateRequest, validateView, view, health, fromRow, create, get, all, patch, remove, due };++}++++module.exports = { createRegistry, validateRequest, validateView, RegistryError, defaultName, nextDue, POLLING, PUSHED, RUN_STATES, STATUSES };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * In-process scheduler: every WATCH_TICK_MS, start checks for active watches whose next_due_at and++ * not_before have passed, oldest due first, up to WATCH_MAX_CONCURRENT checks at a time. The due++ * query is the one the migration's partial index serves, and check.run() itself refuses a second++ * check of a watch already in flight (so a manual check and a scheduled one never overlap).++ *++ * Copied from OpenVibe.Sources/server/scheduler.js with Watch's due query (watches, not sources;++ * cadence IS NOT NULL, since a source pushed to by an event or webhook never polls).++ */++function createScheduler({ db, check, config, now = () => Date.now(), log = console }) {++ const due = db.prepare(`SELECT id FROM watches WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL++ AND next_due_at <= @now AND not_before <= @now ORDER BY next_due_at, id LIMIT @limit`);++ let timer = null;++ const running = new Set();++++ async function tick() {++ const free = config.worker.maxConcurrent - running.size;++ if (free <= 0) return [];++ const ids = (await due.all({ now: now(), limit: free + running.size })).map(r => r.id).filter(id => !running.has(id)).slice(0, free);++ for (const id of ids) {++ running.add(id);++ check.run(id, { trigger: 'schedule' })++ .catch(err => log.error(`[scheduler] ${id}: ${err.stack || err}`))++ .finally(() => running.delete(id));++ }++ return ids;++ }++++ function start() {++ if (timer) return;++ timer = setInterval(async () => { try { await tick(); } catch (err) { log.error(`[scheduler] tick: ${err.message}`); } }, config.worker.tickMs);++ timer.unref?.();++ }++++ async function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ // let checks in flight finish (each is bounded by the fetch timeout)++ const deadline = Date.now() + 15000;++ while (running.size && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));++ }++++ return { start, stop, tick, running: () => Boolean(timer), active: () => [...running] };++}++++module.exports = { createScheduler };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * Per-host request spacing shared by robots.txt fetches and ingestion: before any request to a++ * host, wait until max(SOURCES_HOST_MIN_INTERVAL_MS, the source's min interval, the host's robots++ * Crawl-delay) has passed since the previous request to it. Requests to one host are serialized.++ */++const sleep = (ms) => new Promise(r => setTimeout(r, ms));++++function createSpacer({ hostMinIntervalMs = 1000, now = () => Date.now() } = {}) {++ const hosts = new Map(); // host → { last, crawlDelayMs, chain }++++ function state(host) {++ let h = hosts.get(host);++ if (!h) { h = { last: 0, crawlDelayMs: 0, chain: Promise.resolve() }; hosts.set(host, h); }++ return h;++ }++++ function space(host, minMs = 0) {++ const h = state(host);++ const turn = h.chain.then(async () => {++ const gap = Math.max(hostMinIntervalMs, minMs || 0, h.crawlDelayMs);++ const wait = h.last + gap - now();++ if (wait > 0) await sleep(wait);++ h.last = now();++ });++ h.chain = turn.catch(() => {});++ return turn;++ }++++ function setCrawlDelay(host, sec) {++ if (sec == null) return;++ state(host).crawlDelayMs = Math.min(sec * 1000, 3600 * 1000);++ }++++ return { space, setCrawlDelay };++}++++module.exports = { createSpacer };nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++/**++ * Helpers the carriers and the extractor share, modelled on OpenVibe.Sources/server/adapters/util.js:++ * a fetched body becomes a value and nothing else. A field the source did not state stays null —++ * never guessed, defaulted or invented.++ */++const crypto = require('crypto');++const { XMLParser } = require('fast-xml-parser');++++class ParseError extends Error {++ constructor(detail) { super(detail); this.code = 'parse_error'; }++}++++/** Body hash: SHA-256 hex, over the bytes as received. */++function sha256(buf) {++ return crypto.createHash('sha256').update(buf).digest('hex');++}++++/** Stable JSON of a value: object keys sorted, so equal values have one hash. */++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 === undefined ? null : value);++}++++/** SHA-256 hex of the canonical value (observation.value_hash). */++function hashValue(value) {++ return sha256(Buffer.from(canonicalJson(value), 'utf8'));++}++++/** Decode a body: charset from Content-Type, else the XML declaration / meta tag, else UTF-8. */++function decodeBody(buf, contentType) {++ let charset = /charset=["']?([\w-]+)/i.exec(String(contentType || ''))?.[1];++ if (!charset) {++ const head = buf.subarray(0, 1024).toString('latin1');++ charset = /<\?xml[^>]*encoding=["']([\w-]+)["']/i.exec(head)?.[1] || /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1];++ }++ let label = String(charset || 'utf-8').toLowerCase();++ if (label === 'iso-8859-1' || label === 'latin1') label = 'windows-1252';++ try {++ return new TextDecoder(label, { fatal: false }).decode(buf).replace(/^/, '');++ } catch {++ return new TextDecoder('utf-8').decode(buf).replace(/^/, '');++ }++}++++const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', hellip: '…', mdash: '—', ndash: '–', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };++++function decodeEntities(s) {++ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {++ if (e[0] === '#') {++ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);++ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : '';++ }++ return ENTITIES[e.toLowerCase()] ?? m;++ });++}++++/** HTML or text → plain text, whitespace collapsed, capped. Scripts and styles are dropped whole. */++function toText(value, max = 1000) {++ if (value == null) return null;++ // Bounded input: only a short value survives anyway, and the regexes below stay linear.++ let s = String(typeof value === 'object' ? (value['#text'] ?? value.__cdata ?? '') : value).slice(0, 100000);++ s = decodeEntities(String(s))++ .replace(/<(script|style|noscript)[\s\S]*?<\/\1>/gi, ' ')++ .replace(/<br\s*\/?>|<\/p>|<\/li>|<\/h\d>/gi, ' ')++ .replace(/<[^>]*>/g, ' ');++ s = decodeEntities(s).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').replace(/\s+/g, ' ').trim();++ if (!s) return null;++ return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;++}++++/** RFC 822 / RFC 3339 / ISO dates → ISO string; anything unparseable → null (never "now"). */++function toIsoDate(value) {++ if (value == null) return null;++ const s = decodeEntities(String(typeof value === 'object' ? value['#text'] ?? '' : value)).trim();++ if (!s || s.length > 64) return null;++ // Refuse bare numbers: ambiguous epoch units are guesses.++ if (/^\d+$/.test(s)) return null;++ const t = Date.parse(s);++ if (!Number.isFinite(t)) return null;++ const year = new Date(t).getUTCFullYear();++ if (year < 1990 || year > 2200) return null;++ return new Date(t).toISOString();++}++++const TRACKING = /^(utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid|_hsenc|_hsmi|mkt_tok)$/i;++++/** http(s) URL → canonical form (lowercase host, no fragment, no tracking params, no default port); else null. */++function canonicalUrl(value, base) {++ if (value == null) return null;++ const raw = decodeEntities(String(value)).trim();++ if (!raw || raw.length > 2048) return null;++ let url;++ try { url = base ? new URL(raw, base) : new URL(raw); } catch { return null; }++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;++ if (url.username || url.password) return null;++ url.hash = '';++ url.hostname = url.hostname.toLowerCase();++ if ((url.protocol === 'https:' && url.port === '443') || (url.protocol === 'http:' && url.port === '80')) url.port = '';++ for (const k of [...url.searchParams.keys()]) if (TRACKING.test(k)) url.searchParams.delete(k);++ return url.toString();++}++++/** A plain identifier string (guid, id…) or null. */++function toId(value, max = 512) {++ if (value == null) return null;++ const s = decodeEntities(String(typeof value === 'object' ? value['#text'] ?? '' : value)).trim();++ return s && s.length <= max ? s : null;++}++++function asArray(v) {++ if (v == null) return [];++ return Array.isArray(v) ? v : [v];++}++++/**++ * Parse XML safely: a DOCTYPE with entity declarations is refused (entity expansion is how XML++ * bombs work) and the parser expands no entities at all — the helpers above decode the predefined++ * and numeric ones per value.++ */++function parseXml(text) {++ if (/<!DOCTYPE[^>]*\[/i.test(text) || /<!ENTITY/i.test(text)) throw new ParseError('XML with entity declarations is refused');++ const parser = new XMLParser({++ ignoreAttributes: false,++ attributeNamePrefix: '@_',++ removeNSPrefix: false,++ processEntities: false,++ trimValues: true,++ parseTagValue: false,++ parseAttributeValue: false,++ cdataPropName: '__cdata',++ });++ try {++ return parser.parse(text, true);++ } catch (err) {++ throw new ParseError(`not well-formed XML: ${String(err.message).split('\n')[0].slice(0, 200)}`);++ }++}++++/** First non-empty text value of the given keys on an XML node. */++function pick(node, ...keys) {++ for (const k of keys) {++ const v = node && node[k];++ if (v == null) continue;++ const first = Array.isArray(v) ? v[0] : v;++ if (first == null) continue;++ if (typeof first === 'object') {++ const t = first['#text'] ?? first.__cdata;++ if (t != null && String(t).trim()) return t;++ if (first['@_href']) return first;++ continue;++ }++ if (String(first).trim()) return first;++ }++ return null;++}++++module.exports = {++ ParseError, sha256, canonicalJson, hashValue, decodeBody, decodeEntities, toText, toIsoDate, canonicalUrl,++ toId, asArray, parseXml, pick,++};nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++/**++ * The API: one capability per route (service and person tokens), owner scoping (404, never 403),++ * the released validator behind 422, the check/observations/checks reads, and per-actor limits.++ */++const assert = require('assert');++const { validate } = require('openvibe-contracts');++const { boot, request, serviceToken, userToken, site, suite, watchDef, newUser } = require('./helpers');++++const t = suite('api');++let svc;++let web;++let price = '19.99';++++const READER = serviceToken('news', ['watch.watch.read']);++const OBSERVER = serviceToken('news', ['watch.observation.read']);++const RUNNER = serviceToken('news', ['watch.check.run']);++const MANAGER = serviceToken('site', ['watch.watch.manage']);++const ALL = serviceToken('site', ['watch.*']);++const me = newUser();++const other = newUser();++const ME = userToken(me);++const OTHER = userToken(other);++++const api = (method, p, opts = {}) => request(svc.base, method, p, opts);++const mine = (overrides = {}) => watchDef({++ source: { kind: 'http', url: `${web.origin}/price`, format: null },++ name: 'my watch',++ ...overrides,++});++++t('boot', async () => {++ web = await site({ '/price': () => ({ body: price }) });++ svc = await boot();++});++++t('every route needs a token, and the one capability it serves', async () => {++ assert.strictEqual((await api('GET', '/api/v1/watches')).status, 401);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: 'not-a-token' })).status, 401);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.watch.manage']) })).status, 403);++ assert.strictEqual((await api('POST', '/api/v1/watches', { token: READER, body: mine() })).status, 403);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.watch.read'], { aud: 'openvibe.search' }) })).status, 401, 'another audience');++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.*']) })).status, 200, 'a family grant covers it');++ // a person's bearer token is enough for their own watches++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME })).status, 200);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: userToken(me, { aud: 'openvibe.deals' }) })).status, 401);++ // a watch id that does not exist is 404 on every route++ const ghost = 'wch_01J0000000000000000000000';++ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}`, { token: ME })).status, 404);++ assert.strictEqual((await api('DELETE', `/api/v1/watches/${ghost}`, { token: ME })).status, 404);++ assert.strictEqual((await api('POST', `/api/v1/watches/${ghost}/check`, { token: ME })).status, 404);++ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/observations`, { token: OBSERVER })).status, 404);++ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);++});++++t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {++ const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });++ assert.strictEqual(created.status, 201, created.text);++ const watch = created.body.watch;++ assert.match(watch.id, /^wch_/);++ assert.strictEqual(watch.owner, me);++ assert.deepStrictEqual(watch.labels, { room: 'kitchen' });++ assert.ok(validate('watch.watch@1', watch).valid);++ assert.ok(validate('watch.watch-result@1', created.body).valid);++ const one = await api('GET', `/api/v1/watches/${watch.id}`, { token: ME });++ assert.strictEqual(one.status, 200);++ assert.deepStrictEqual(one.body.watch, watch);++ const list = await api('GET', '/api/v1/watches', { token: ME });++ assert.strictEqual(list.status, 200);++ assert.deepStrictEqual(list.body.watches.map(w => w.id), [watch.id]);++ assert.ok(validate('watch.watch-result@1', list.body).valid, 'exactly { watches }: the cursor is the last id');++ // a service token with the read capability reads the same (it is the same subject)++ const service = await api('GET', `/api/v1/watches/${watch.id}`, { token: serviceToken('site', ['watch.watch.read']) });++ assert.strictEqual(service.status, 404, 'svc:site is not the person');++});++++t('an invalid body is 422 with the validator\'s detail, and nothing is written', async () => {++ const before = (await svc.db.prepare('SELECT COUNT(*) AS n FROM watches').get()).n;++ const bad = [++ [mine({ source: undefined }), /requires source/],++ [mine({ cadence: null }), /needs a cadence/],++ [mine({ condition: undefined }), /requires condition/],++ [mine({ action: [] }), /watch.watch-request@1/],++ [mine({ source: { kind: 'http', url: 'http://example.org/x', headers: { cookie: 'a=b' } } }), /cookie/],++ [mine({ nope: true }), /watch.watch-request@1/],++ ];++ for (const [body, re] of bad) {++ const res = await api('POST', '/api/v1/watches', { token: ME, body });++ assert.strictEqual(res.status, 422, JSON.stringify(body));++ assert.strictEqual(res.body.code, 'watch.invalid');++ assert.match(res.body.detail, re);++ }++ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM watches').get()).n, before);++ // malformed JSON is 400, and a body that is not an object is 422++ const raw = await fetch(`${svc.base}/api/v1/watches`, { method: 'POST', headers: { Authorization: `Bearer ${ME}`, 'Content-Type': 'application/json' }, body: '{' });++ assert.strictEqual(raw.status, 400);++ assert.strictEqual((await api('POST', '/api/v1/watches', { token: ME, body: [1, 2] })).status, 422);++});++++t('patch, pause, resume, delete — and another owner gets 404 for every one of them', async () => {++ const { body: { watch } } = await api('POST', '/api/v1/watches', { token: ME, body: mine() });++ const patched = await api('PATCH', `/api/v1/watches/${watch.id}`, { token: ME, body: { name: 'renamed', cadence: { every_sec: 120 } } });++ assert.strictEqual(patched.status, 200);++ assert.strictEqual(patched.body.watch.name, 'renamed');++ assert.strictEqual(patched.body.watch.cadence.every_sec, 120);++ assert.ok(validate('watch.watch-result@1', patched.body).valid);++++ const paused = await api('POST', `/api/v1/watches/${watch.id}/pause`, { token: ME });++ assert.strictEqual(paused.status, 200);++ assert.deepStrictEqual(paused.body, { id: watch.id, status: 'paused' });++ assert.ok(validate('watch.watch-result@1', paused.body).valid);++ const resumed = await api('POST', `/api/v1/watches/${watch.id}/resume`, { token: ME });++ assert.deepStrictEqual(resumed.body, { id: watch.id, status: 'active' });++++ // another owner: 404, never 403, and the watch is untouched++ for (const [method, path, body] of [['GET', '', undefined], ['PATCH', '', { name: 'stolen' }], ['POST', '/pause', undefined], ['DELETE', '', undefined]]) {++ const res = await api(method, `/api/v1/watches/${watch.id}${path}`, { token: OTHER, body });++ assert.strictEqual(res.status, 404, `${method} ${path}`);++ assert.strictEqual(res.body.code, 'watch.not_found');++ }++ assert.deepStrictEqual((await api('GET', '/api/v1/watches', { token: OTHER })).body.watches, []);++ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}`, { token: ME })).body.watch.name, 'renamed');++++ const gone = await api('DELETE', `/api/v1/watches/${watch.id}`, { token: ME });++ assert.strictEqual(gone.status, 200);++ assert.deepStrictEqual(gone.body, { id: watch.id, deleted: true });++ assert.ok(validate('watch.watch-result@1', gone.body).valid);++ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}`, { token: ME })).status, 404);++});++++t('X-OV-Subject: a first-party service acts for a person; nobody else may name one', async () => {++ const created = await api('POST', '/api/v1/watches', { token: MANAGER, body: mine(), headers: { 'X-OV-Subject': me } });++ assert.strictEqual(created.status, 201);++ assert.strictEqual(created.body.watch.owner, me);++ // the person sees the service's watch, the service itself does not own it++ assert.ok((await api('GET', '/api/v1/watches', { token: ME })).body.watches.some(w => w.id === created.body.watch.id));++ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: serviceToken('site', ['watch.watch.read']) })).status, 404);++ // a person may not delegate, and a subject must be a subject++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME, headers: { 'X-OV-Subject': other } })).status, 403);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ALL, headers: { 'X-OV-Subject': 'nobody' } })).status, 400);++ // a service that does not own the watch and names nobody gets 404, not a peek++ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: serviceToken('site', ['watch.watch.read']) })).status, 404);++});++++t('a manual check runs one check and answers the released check-run; observations and checks read back', async () => {++ const { body: { watch } } = await api('POST', '/api/v1/watches', { token: ME, body: mine({ condition: { op: 'lt', value: 10 } }) });++ const run = await api('POST', `/api/v1/watches/${watch.id}/check`, { token: ME });++ assert.strictEqual(run.status, 200, run.text);++ assert.ok(validate('watch.check-run@1', run.body).valid, JSON.stringify(run.body));++ assert.strictEqual(run.body.trigger, 'manual');++ assert.strictEqual(run.body.carrier, 'http');++ assert.strictEqual(run.body.state, 'changed');++ assert.strictEqual(run.body.observations, 1);++ assert.strictEqual(run.body.triggers, 0);++++ const observations = await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: ME });++ assert.strictEqual(observations.status, 200, observations.text);++ assert.strictEqual(observations.body.observations.length, 1);++ assert.ok(validate('watch.observation@1', observations.body.observations[0]).valid);++ assert.strictEqual(observations.body.observations[0].value, '19.99');++ const checks = await api('GET', `/api/v1/watches/${watch.id}/checks`, { token: ME });++ assert.deepStrictEqual(checks.body.checks.map(c => c.id), [run.body.id]);++ assert.ok(validate('watch.check-run@1', checks.body.checks[0]).valid);++ // a first-party service reads and runs them for the person it names (X-OV-Subject)++ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: OBSERVER, headers: { 'X-OV-Subject': me } })).status, 200);++ assert.strictEqual((await api('POST', `/api/v1/watches/${watch.id}/check`, { token: RUNNER, headers: { 'X-OV-Subject': me } })).status, 200);++ // without the delegation the same service is a stranger: 404, never 403++ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: OBSERVER })).status, 404);++ assert.strictEqual((await api('POST', `/api/v1/watches/${watch.id}/check`, { token: RUNNER })).status, 404);++ // and the wrong capability is 403 however it is scoped++ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: READER, headers: { 'X-OV-Subject': me } })).status, 403);++ // a watch whose carrier is a later step answers 422 and never fetches++ const later = await api('POST', '/api/v1/watches', {++ token: ME,++ body: mine({ source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: null, extraction: { kind: 'json' } }),++ });++ const refused = await api('POST', `/api/v1/watches/${later.body.watch.id}/check`, { token: ME });++ assert.strictEqual(refused.status, 422);++ assert.strictEqual(refused.body.code, 'watch.carrier_unavailable');++ assert.match(refused.body.detail, /step 5/);++ // a paused watch cannot be checked by hand either++ await api('POST', `/api/v1/watches/${watch.id}/pause`, { token: ME });++ const paused = await api('POST', `/api/v1/watches/${watch.id}/check`, { token: ME });++ assert.strictEqual(paused.status, 409);++ assert.strictEqual(paused.body.code, 'watch.disabled');++});++++t('list filters and the page cursor', async () => {++ const who = { sub: newUser() };++ const TOKEN = userToken(who.sub);++ const ids = [];++ for (let i = 0; i < 3; i++) ids.push((await api('POST', '/api/v1/watches', { token: TOKEN, body: mine({ name: `w${i}` }) })).body.watch.id);++ const page = await api('GET', '/api/v1/watches?limit=2', { token: TOKEN });++ assert.deepStrictEqual(page.body.watches.map(w => w.id), [ids[2], ids[1]]);++ const next = await api('GET', `/api/v1/watches?limit=2&before=${page.body.watches[1].id}`, { token: TOKEN });++ assert.deepStrictEqual(next.body.watches.map(w => w.id), [ids[0]]);++ await api('POST', `/api/v1/watches/${ids[0]}/pause`, { token: TOKEN });++ assert.deepStrictEqual((await api('GET', '/api/v1/watches?status=paused', { token: TOKEN })).body.watches.map(w => w.id), [ids[0]]);++ assert.strictEqual((await api('GET', '/api/v1/watches?status=nonsense', { token: TOKEN })).status, 400);++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ALL })).status, 200, 'watch.* covers read');++});++++t('per-actor limits: the writes and the manual checks each have their own budget', async () => {++ const who = newUser();++ const TOKEN = userToken(who);++ let refused = null;++ for (let i = 0; i < 40 && !refused; i++) {++ const res = await api('POST', '/api/v1/watches', { token: TOKEN, body: mine({ name: `limit ${i}` }) });++ if (res.status === 429) refused = res;++ else assert.strictEqual(res.status, 201);++ }++ assert.ok(refused, 'the write budget refused the 31st create');++ assert.strictEqual(refused.body.code, 'rate_limited');++ assert.ok(Number(refused.headers.get('retry-after')) > 0);++ // another actor is unaffected++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME })).status, 200);++});++++t('the API answers 404 for anything else, and health/readiness are honest', async () => {++ assert.strictEqual((await api('GET', '/api/v1/nope', { token: ME })).status, 404);++ assert.strictEqual((await api('GET', '/api/health')).status, 200);++ const ready = await api('GET', '/api/ready');++ assert.strictEqual(ready.status, 200);++ assert.strictEqual(ready.body.checks.db.status, 'ok');++ assert.strictEqual(ready.body.checks.checker.detail.enabled, false, 'the tests run with the worker off');++ const home = await fetch(`${svc.base}/`, { headers: { Accept: 'text/plain' } });++ assert.match(await home.text(), /\/api\/v1\/watches/);++ const html = await fetch(`${svc.base}/`, { headers: { Accept: 'text/html' } });++ assert.match(await html.text(), /OpenVibe\.Watch/);++});++++t('done', async () => { await web.close(); await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,221 @@++'use strict';++/**++ * The carriers and the SSRF guard, against stub HTTP servers (nothing here touches the internet):++ * conditional GET with ETag, the 304 path, the unchanged-body path, the HEAD-first path, feeds and++ * APIs, credentials read by environment-variable name, and the refusals — a redirect to a private++ * address, a private host, a port the guard does not allow.++ */++const assert = require('assert');++const { boot, request, site, serviceToken, suite, rss } = require('./helpers');++++const t = suite('carriers');++const OWNER = { sub: 'svc:carrier-test' };++let svc;++let web;++++const watch = async (overrides = {}) => (await svc.registry.create({++ name: 'carrier watch',++ source: { kind: 'http', url: `${web.origin}/page`, format: null },++ cadence: { every_sec: 60 },++ extraction: { kind: 'text' },++ condition: { op: 'eq', value: 'never-matches' },++ action: [{ kind: 'notification' }],++ ...overrides,++}, OWNER)).id;++++const check = async (id, trigger = 'manual') => await svc.check.run(id, { trigger });++const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);++++t('boot with a stub site', async () => {++ let version = 1;++ web = await site({++ '/page': (req) => {++ if (req.headers['if-none-match'] === `"v${version}"`) return { status: 304, headers: { etag: `"v${version}"` } };++ return { headers: { etag: `"v${version}"`, 'content-type': 'text/plain' }, body: `value ${version}` };++ },++ '/bump': () => { version += 1; return { body: 'ok' }; },++ '/stable': () => ({ headers: { 'content-type': 'text/plain' }, body: 'unchanging' }),++ '/redirect-private': () => ({ status: 302, headers: { location: 'http://127.0.0.2/elsewhere' } }),++ '/redirect-loop': () => ({ status: 302, headers: { location: '/redirect-loop' } }),++ '/feed.xml': () => ({ headers: { 'content-type': 'application/rss+xml' }, body: rss([++ { guid: 'g2', title: 'Widget 2', link: 'https://example.org/2', description: 'the newer one' },++ { guid: 'g1', title: 'Widget 1', link: 'https://example.org/1' },++ ]) }),++ '/api.json': () => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ products: [{ sku: 'A', price: 19.5 }, { sku: 'B', price: 4 }] }) }),++ '/secret.json': (req) => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ saw: req.url }) }),++ });++ svc = await boot();++});++++t('a conditional GET stores the validators, and the next check gets 304 → not_modified', async () => {++ const id = await watch();++ const first = await check(id);++ // the first observation is a change from nothing: there was no value before it++ assert.strictEqual(first.run.state, 'changed', first.run.detail || '');++ const state = await svc.db.prepare('SELECT * FROM watch_endpoint_state WHERE watch_id = ?').get(id);++ assert.strictEqual(state.etag, '"v1"');++ assert.strictEqual(state.last_status, 200);++ assert.strictEqual((await runs(id)).length, 1);++++ const second = await check(id);++ assert.strictEqual(second.run.state, 'not_modified');++ const got = web.hits('/page');++ assert.strictEqual(got[got.length - 1].headers['if-none-match'], '"v1"', 'the second request asked conditionally');++ assert.strictEqual((await runs(id)).length, 2, 'a 304 is still a recorded check run');++ const observations = await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ?').all(id);++ assert.strictEqual(observations.length, 1, 'a 304 records no observation: nothing was stated');++});++++t('a 200 with an unchanged body is no_change, and a changed body is changed', async () => {++ const id = await watch({ source: { kind: 'http', url: `${web.origin}/stable`, format: null } });++ const first = await check(id);++ assert.deepStrictEqual(first.value, 'unchanging');++ const second = await check(id);++ assert.strictEqual(second.run.state, 'no_change');++ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?').get(id)).n, 1);++++ // a body that changes, with a condition that does not fire: the run says `changed`++ const bump = await watch();++ await check(bump);++ await request(web.origin, 'GET', '/bump');++ const after = await check(bump);++ assert.strictEqual(after.run.state, 'changed');++ assert.strictEqual(after.run.triggers, 0);++ const obs = await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid').all(bump);++ assert.deepStrictEqual(obs.map((o) => [o.changed, o.condition_met, o.previous_hash != null]), [[1, 0, false], [1, 0, true]]);++});++++t('HEAD first when only change is needed: a 304 answers without a body', async () => {++ const id = await watch({ condition: { op: 'changed' } });++ await check(id); // stores the validators++ const before = web.hits('/page').length;++ const out = await check(id);++ assert.strictEqual(out.run.state, 'not_modified');++ const seen = web.hits('/page').slice(before);++ assert.deepStrictEqual(seen.map((r) => r.method), ['HEAD'], 'a conditional HEAD, no GET');++});++++t('a redirect to a private address is refused at the hop, the first request only', async () => {++ const id = await watch({ source: { kind: 'http', url: `${web.origin}/redirect-private`, format: null } });++ const before = web.hits('/redirect-private').length;++ const out = await check(id);++ assert.strictEqual(out.run.state, 'http_error');++ assert.strictEqual(out.run.error_code, 'address_refused');++ assert.match(out.run.detail, /redirect|not a public address/);++ const elsewhere = web.requests.filter(r => r.path === '/elsewhere');++ assert.strictEqual(elsewhere.length, 0, 'the redirect target was never fetched');++ assert.strictEqual(web.hits('/redirect-private').length - before, 1);++});++++t('a redirect loop stops at WATCH_MAX_REDIRECTS', async () => {++ const id = await watch({ source: { kind: 'http', url: `${web.origin}/redirect-loop`, format: null } });++ const out = await check(id);++ assert.strictEqual(out.run.state, 'http_error');++ assert.strictEqual(out.run.error_code, 'too_many_redirects');++});++++t('a private host is refused before any request (nothing is even connected to)', async () => {++ const aSite = await site({ '/page': () => ({ body: 'should never be fetched' }) });++ // the stub's port is allowed, so only the ADDRESS rule can refuse this URL++ const alone = await boot({ allowPrivate: false, env: { WATCH_ALLOWED_PORTS: `80,443,${new URL(aSite.origin).port}` } });++ try {++ const w = await alone.registry.create({++ name: 'loopback watch',++ source: { kind: 'http', url: `${aSite.origin}/page`, format: null },++ cadence: { every_sec: 60 }, extraction: { kind: 'text' }, condition: { op: 'eq', value: 'x' },++ action: [{ kind: 'notification' }],++ }, OWNER);++ const out = await alone.check.run(w.id, { trigger: 'manual' });++ assert.strictEqual(out.run.state, 'http_error');++ assert.strictEqual(out.run.error_code, 'address_refused');++ assert.strictEqual(aSite.requests.length, 0, 'the guard refused before the socket');++ await aSite.close();++ } finally { await alone.stop(); }++});++++t('the guard refuses ports outside WATCH_ALLOWED_PORTS and URL credentials', async () => {++ const { createGuard } = require('../server/net/guard');++ const guard = createGuard({ allowedPorts: [80, 443], allowPrivateHosts: [] });++ assert.throws(() => guard.checkUrl('http://example.org:8080/x'), (e) => e.code === 'port_refused');++ assert.throws(() => guard.checkUrl('http://user:[email protected]/x'), (e) => e.code === 'bad_url');++ assert.throws(() => guard.checkUrl('file:///etc/passwd'), (e) => e.code === 'bad_url');++ assert.throws(() => guard.checkUrl('http://10.0.0.5/x'), (e) => e.code === 'address_refused');++ assert.throws(() => guard.checkUrl('http://[::1]/x'), (e) => e.code === 'address_refused');++ assert.strictEqual(guard.checkUrl('https://example.org/x').hostname, 'example.org');++});++++t('a credential is read from the environment by name, at fetch time, and never stored', async () => {++ const id = await watch({++ source: { kind: 'api', url: `${web.origin}/secret.json`, format: 'json', auth: { mode: 'query', env: 'WATCH_CRED_TEST', param: 'k' } },++ extraction: { kind: 'json', value_path: 'saw' },++ });++ // not set: the check is recorded disabled and nothing is fetched++ const before = web.hits('/secret.json').length;++ const missing = await check(id);++ assert.strictEqual(missing.run.state, 'disabled');++ assert.strictEqual(missing.run.error_code, 'credential_missing');++ assert.strictEqual(web.hits('/secret.json').length, before, 'no request was made');++ // the stored watch and its view carry the NAME, never a value++ const view = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: serviceToken('carrier-test', ['watch.watch.read']) });++ assert.strictEqual(view.status, 200);++ assert.deepStrictEqual(view.body.watch.source.auth, { mode: 'query', env: 'WATCH_CRED_TEST', param: 'k' });++ assert.ok(!view.text.includes('s3cret-value'));++ // set on the config's environment: the next fetch carries it, and it is not written anywhere++ svc.config.secrets.WATCH_CRED_TEST = 's3cret-value';++ const out = await check(id);++ assert.strictEqual(out.run.state, 'changed');++ const hit = web.hits('/secret.json').pop();++ assert.strictEqual(hit.search, '?k=s3cret-value');++ const stored = await svc.db.prepare('SELECT source FROM watches WHERE id = ?').get(id);++ assert.ok(!JSON.stringify(stored).includes('s3cret-value'), 'the value is never stored');++ delete svc.config.secrets.WATCH_CRED_TEST;++});++++t('the feed carrier parses RSS and the extraction reads the latest entry', async () => {++ const id = await watch({++ source: { kind: 'feed', url: `${web.origin}/feed.xml`, format: 'rss' },++ extraction: { kind: 'json', value_path: 'latest.title' },++ condition: { op: 'eq', value: 'nothing' },++ });++ const out = await check(id);++ assert.strictEqual(out.run.state, 'changed', out.run.detail || '');++ assert.strictEqual(out.value, 'Widget 2');++ const items = await watch({++ source: { kind: 'feed', url: `${web.origin}/feed.xml`, format: null, fields: { name: 'title', url: 'link' } },++ extraction: { kind: 'json', fields: { first: 'items.0.name', count: 'count' } },++ });++ const all = await check(items);++ assert.deepStrictEqual(all.value, { first: 'Widget 2', count: 2 });++ const broken = await watch({ source: { kind: 'feed', url: `${web.origin}/stable`, format: null } });++ const bad = await check(broken);++ assert.strictEqual(bad.run.state, 'parse_error');++ assert.strictEqual(bad.run.error_code, 'unreadable');++});++++t('the api carrier maps items_path + fields, and reads the body directly without a mapping', async () => {++ const mapped = await watch({++ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json', items_path: 'products', fields: { sku: 'sku', price: 'price' } },++ extraction: { kind: 'json', value_path: 'latest.price' },++ });++ const out = await check(mapped);++ assert.strictEqual(out.run.state, 'changed', out.run.detail || '');++ assert.strictEqual(out.value, 19.5);++++ const direct = await watch({++ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json' },++ extraction: { kind: 'jsonpath', value_path: 'products.1.sku' },++ });++ assert.strictEqual((await check(direct)).value, 'B');++++ const wrongPath = await watch({++ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json', items_path: 'nope' },++ extraction: { kind: 'json' },++ });++ const bad = await check(wrongPath);++ assert.strictEqual(bad.run.state, 'parse_error');++ assert.strictEqual(bad.run.error_code, 'items_path');++});++++t('done', async () => { await web.close(); await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,310 @@++'use strict';++/**++ * The check engine: a run writes its check_runs row and its observation and emits the released++ * watch.* events; a condition that fires emits watch.watch.triggered; every failure is a recorded++ * state and an event; a source whose carrier is a later step is never fetched; retention and the++ * for_sec debounce behave. Every event payload is validated against the released contract.++ */++const assert = require('assert');++const { validate } = require('openvibe-contracts');++const { boot, site, suite, newUser, newAgent, rss } = require('./helpers');++++const t = suite('check');++let svc;++let web;++let price = '19.99';++++const person = { sub: newUser() };++const agent = { sub: newAgent() };++const service = { sub: 'svc:check-test' };++++const def = (overrides = {}) => ({++ name: 'price watch',++ source: { kind: 'http', url: `${web.origin}/price`, format: null },++ cadence: { every_sec: 60 },++ extraction: { kind: 'text' },++ condition: { op: 'lt', value: 10 },++ action: [{ kind: 'notification' }],++ ...overrides,++});++++const create = async (principal, overrides = {}) => await svc.registry.create(def(overrides), principal);++const check = async (id, trigger = 'schedule') => await svc.check.run(id, { trigger });++const row = async (id) => await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(id);++const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);++const observations = async (id) => await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid').all(id);++const payloads = async (type) => (await svc.outbox.all()).filter(e => e.event_type === type).map(e => e.payload);++const envelopes = async (type) => (await svc.outbox.all()).filter(e => e.event_type === type);++++const valid = (ref, value) => {++ const r = validate(ref, value);++ assert.ok(r.valid, `${ref}: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')} — ${JSON.stringify(value)}`);++};++++t('boot', async () => {++ web = await site({++ '/price': () => ({ headers: { 'content-type': 'text/plain' }, body: price }),++ '/set': (req, _res, ctx) => { price = ctx.url.searchParams.get('v') || price; return { body: 'ok' }; },++ '/rss': () => ({ headers: { 'content-type': 'application/rss+xml' }, body: rss([{ guid: 'g1', title: 'Widget', link: 'https://example.org/1' }]) }),++ '/slow': () => ({ delayMs: 4000, body: 'late' }),++ '/busy': () => ({ status: 429, headers: { 'retry-after': '120' }, body: 'slow down' }),++ '/broken': () => ({ headers: { 'content-type': 'application/json' }, body: 'not json at all' }),++ });++ svc = await boot();++});++++t('a check writes its run and its observation and emits watch.observation.recorded', async () => {++ price = '19.99';++ const w = await create(person);++ const out = await check(w.id);++ assert.strictEqual(out.run.state, 'changed');++ const r = (await runs(w.id))[0];++ assert.strictEqual(r.trigger, 'schedule');++ assert.strictEqual(r.carrier, 'http');++ assert.strictEqual(r.state, 'changed');++ assert.strictEqual(Number(r.observations), 1);++ assert.strictEqual(Number(r.triggers), 0);++ assert.strictEqual(Number(r.http_status), 200);++ assert.strictEqual(r.raw_body_hash.length, 64);++ const o = (await observations(w.id))[0];++ assert.strictEqual(o.value, '19.99');++ assert.strictEqual(o.check_run_id, r.id);++ assert.strictEqual(o.previous_hash, null);++ assert.strictEqual(Number(o.changed), 1);++ assert.strictEqual(Number(o.condition_met), 0, 'lt 10 does not hold at 19.99');++ assert.strictEqual(o.snapshot, '19.99');++ assert.strictEqual(o.value_hash.length, 64);++ assert.ok(o.retained_until > o.observed_at, 'retention is set from the watch (default 90 days)');++ assert.strictEqual(Math.round((o.retained_until - o.observed_at) / 86400000), 90);++++ const events = await envelopes('watch.observation.recorded');++ assert.strictEqual(events.length, 1);++ valid('watch.observation.recorded@1', events[0].payload);++ assert.strictEqual(events[0].payload.watch_id, w.id);++ assert.deepStrictEqual([events[0].payload.changed, events[0].payload.condition_met], [true, false]);++ assert.strictEqual(events[0].source, 'watch');++ assert.deepStrictEqual(events[0].subject, { type: 'watch', id: w.id });++ assert.strictEqual(events[0].visibility, 'internal');++ assert.strictEqual(events[0].priority, 'low');++});++++t('a condition that fires emits watch.watch.triggered with the observation that fired it', async () => {++ price = '19.99';++ const w = await create(person, { condition: { op: 'lt', value: 10 } });++ await check(w.id); // 19.99: no fire++ assert.strictEqual((await runs(w.id))[0].state, 'changed');++ await fetch(`${web.origin}/set?v=7.50`); // the price drops to 7.50++ const out = await check(w.id);++ assert.strictEqual(out.run.state, 'condition_met');++ assert.strictEqual(out.run.triggers, 1);++ assert.strictEqual(out.met, true);++ assert.strictEqual(out.value, '7.50');++++ const [trig] = await envelopes('watch.watch.triggered');++ assert.ok(trig, 'the trigger was emitted');++ valid('watch.watch.triggered@1', trig.payload);++ assert.deepStrictEqual(trig.payload.recipient, person.sub);++ assert.strictEqual(trig.payload.watch_id, w.id);++ assert.strictEqual(trig.payload.observation.value, '7.50');++ assert.strictEqual(trig.payload.observation.previous_value, '19.99');++ assert.match(trig.payload.observation.id, /^wco_/);++ assert.deepStrictEqual(trig.payload.condition, { op: 'lt', value: 10 });++ assert.strictEqual(trig.priority, 'important');++++ // the observation that fired it carries both changed and the predicate holding++ const last = (await observations(w.id)).pop();++ assert.deepStrictEqual([Number(last.changed), Number(last.condition_met)], [1, 1]);++ valid('watch.observation@1', svc.observations.view(last));++ valid('watch.check-run@1', svc.check.runView((await runs(w.id)).pop()));++});++++t('a failing check is a recorded state and a watch.check.failed event, and it backs off', async () => {++ const w = await create(person, { source: { kind: 'http', url: `${web.origin}/broken`, format: null }, extraction: { kind: 'json' } });++ const first = await check(w.id);++ assert.strictEqual(first.run.state, 'parse_error');++ assert.strictEqual(first.run.error_code, 'unreadable');++ const [fail] = await envelopes('watch.check.failed');++ valid('watch.check.failed@1', fail.payload);++ assert.deepStrictEqual([fail.payload.state, fail.payload.consecutive_failures], ['parse_error', 1]);++ let r = await row(w.id);++ assert.strictEqual(r.last_state, 'parse_error');++ assert.strictEqual(Number(r.consecutive_failures), 1);++ assert.strictEqual(r.next_due_at - r.last_check_at, 60000, 'first failure: every_sec · 2^0');++++ const second = await check(w.id);++ assert.strictEqual(second.failures, 2);++ r = await row(w.id);++ assert.strictEqual(Number(r.consecutive_failures), 2);++ assert.strictEqual(r.next_due_at - r.last_check_at, 120000, 'second failure: every_sec · 2^1');++ assert.strictEqual((await payloads('watch.check.failed')).length, 2);++++ // a success clears the backoff and the counter (19.99 is above the watch's lt 10, so it is a++ // change, not a fire)++ await fetch(`${web.origin}/set?v=19.99`);++ await svc.db.prepare('UPDATE watches SET source = ? WHERE id = ?').run(JSON.stringify({ kind: 'http', url: `${web.origin}/price`, format: null }), w.id);++ await check(w.id);++ r = await row(w.id);++ assert.deepStrictEqual([Number(r.consecutive_failures), r.last_state], [0, 'changed']);++ assert.ok(r.next_due_at - r.last_check_at >= 60000 && r.next_due_at - r.last_check_at <= 60100, 'back to the cadence');++});++++t('a timeout is its own state, and a 429 waits as long as the site asked', async () => {++ const slow = await create(person, { source: { kind: 'http', url: `${web.origin}/slow`, format: null } });++ const out = await check(slow.id);++ assert.strictEqual(out.run.state, 'timeout');++ assert.strictEqual(out.run.error_code, 'timeout');++++ const busy = await create(person, { source: { kind: 'http', url: `${web.origin}/busy`, format: null } });++ const limited = await check(busy.id);++ assert.strictEqual(limited.run.state, 'rate_limited');++ assert.strictEqual(limited.run.error_code, 'upstream_429');++ const r = await row(busy.id);++ assert.ok(r.not_before - r.last_check_at >= 119000, 'Retry-After: 120 s became not_before');++});++++t('an event, webhook, node or run source is accepted but never fetched (steps 5-7)', async () => {++ const cases = [++ ['event', { kind: 'event', pattern: 'release.published' }, { op: 'exists' }],++ ['webhook', { kind: 'webhook', endpoint: '/internal/hooks/abc' }, { op: 'exists' }],++ ['node', { kind: 'node', node_id: 'nod_01J0000000000000000000000', probe: { path: '/tmp/x' } }, { op: 'exists' }],++ ['run', { kind: 'run', runtime: 'browser', url: `${web.origin}/price`, steps: [{ goto: 'https://example.org' }] }, { op: 'exists' }],++ ];++ const before = web.requests.length;++ for (const [kind, source, condition] of cases) {++ // an event or a webhook is pushed to and never polls; node and run sources poll through++ // OpenVibe.Node and OpenVibe.Run, so they carry a cadence — and still nothing is fetched++ const cadence = (kind === 'event' || kind === 'webhook') ? null : { every_sec: 60 };++ const w = await create(person, { source, cadence, extraction: { kind: 'text' }, condition });++ assert.strictEqual((await row(w.id)).status, 'active');++ const out = await check(w.id, 'manual');++ assert.strictEqual(out.run.state, 'skipped', `${kind}: ${out.run.detail}`);++ assert.strictEqual(out.run.error_code, 'watch.carrier_unavailable');++ assert.strictEqual(out.run.carrier, kind);++ assert.match(out.run.detail, /step [567]/);++ assert.strictEqual(out.unavailable.code, 'watch.carrier_unavailable');++ assert.strictEqual((await observations(w.id)).length, 0);++ const [fail] = (await payloads('watch.check.failed')).slice(-1);++ valid('watch.check.failed@1', fail);++ assert.strictEqual(fail.carrier, kind);++ }++ assert.strictEqual(web.requests.length, before, 'no carrier ever fetched');++ // a run source that does poll is armed, and the scheduler may ask for it — the carrier still refuses++ const due = await svc.registry.due(10);++ assert.ok(!due.some(d => d.id === undefined));++});++++t('an ai extraction is not checked either: no fetch, a skipped run, and it explains why', async () => {++ const w = await create(person, { extraction: { kind: 'ai', ai: { prompt: 'read the price' } } });++ const before = web.requests.length;++ const out = await check(w.id, 'manual');++ assert.strictEqual(out.run.state, 'skipped');++ assert.strictEqual(out.run.error_code, 'watch.extraction_unavailable');++ assert.match(out.run.detail, /AI action/);++ assert.strictEqual(web.requests.length, before, 'the page was never fetched for a reading this release cannot do');++});++++t('a watch that is not active is recorded disabled and never fetched', async () => {++ const w = await create(person);++ await svc.registry.patch(w.id, { status: 'paused' }, person);++ const before = web.requests.length;++ const out = await check(w.id, 'manual');++ assert.strictEqual(out.disabled, true);++ assert.strictEqual(out.run.state, 'disabled');++ assert.strictEqual(out.run.error_code, 'watch_paused');++ assert.strictEqual(web.requests.length, before);++ const [fail] = (await payloads('watch.check.failed')).slice(-1);++ valid('watch.check.failed@1', fail);++ assert.strictEqual(fail.state, 'disabled');++});++++t('a watch is checked once at a time: a second run answers busy', async () => {++ const w = await create(person);++ const first = svc.check.run(w.id, { trigger: 'manual' });++ const second = await svc.check.run(w.id, { trigger: 'manual' });++ assert.deepStrictEqual(second, { busy: true });++ await first;++ assert.strictEqual((await runs(w.id)).length, 1, 'the busy call recorded nothing');++});++++t('for_sec is measured from the observations, not from a timer in this process', async () => {++ price = '7.50';++ const w = await create(person, { source: { kind: 'http', url: `${web.origin}/price`, format: null }, condition: { op: 'lt', value: 100, for_sec: 120 } });++ const first = await check(w.id);++ assert.strictEqual(first.run.state, 'changed', 'the predicate holds but the debounce has not run');++ assert.strictEqual(Number((await observations(w.id))[0].condition_met), 1, 'the observation records that it held');++ // the trail says it has held since two minutes before this check++ const since = Date.now() - 130000;++ const firedBefore = (await envelopes('watch.watch.triggered')).length;++ await svc.db.prepare('UPDATE observations SET observed_at = ? WHERE watch_id = ?').run(since, w.id);++ const second = await check(w.id);++ assert.strictEqual(second.run.state, 'condition_met', 'an unchanged body still fires once the debounce has run');++ const fired = (await envelopes('watch.watch.triggered')).slice(firedBefore);++ assert.strictEqual(fired.length, 1);++ assert.strictEqual(fired[0].payload.watch_id, w.id);++ assert.strictEqual(fired[0].payload.observation.value, '7.50', 'the trigger points at the observation that holds the value');++ // once per run of holding: the same value and the same run fire nothing more++ const again = await check(w.id);++ assert.strictEqual(again.met, false);++ assert.strictEqual((await envelopes('watch.watch.triggered')).length, firedBefore + 1);++ // a value that stops holding resets the debounce++ const other = await create(person, { source: { kind: 'http', url: `${web.origin}/price`, format: null }, condition: { op: 'gt', value: 100, for_sec: 60 } });++ await check(other.id);++ const held = await svc.db.prepare('SELECT observed_at FROM observations WHERE watch_id = ?').get(other.id);++ await svc.db.prepare('UPDATE observations SET observed_at = ? WHERE watch_id = ?').run(held.observed_at - 600000, other.id);++ const after = await check(other.id);++ assert.strictEqual(after.met, false, 'gt 100 does not hold at 7.50, whatever the trail says');++ assert.strictEqual(after.run.state, 'no_change', 'the body did not change; the condition simply did not hold');++});++++t('retention: keep_snapshots false stores none, and observations_days sets retained_until', async () => {++ const w = await create(person, { retention: { observations_days: 7, keep_snapshots: false } });++ await check(w.id);++ const o = (await observations(w.id))[0];++ assert.strictEqual(o.snapshot, null);++ assert.strictEqual(Math.round((o.retained_until - o.observed_at) / 86400000), 7);++ const pruned = await svc.observations.prune(Date.now() + 8 * 86400000);++ assert.strictEqual(pruned, 1);++ assert.strictEqual((await observations(w.id)).length, 0);++});++++t('an owned-by-a-service watch records everything and emits no person-facing event', async () => {++ const w = await create(service, { condition: { op: 'lt', value: 100 } });++ const out = await check(w.id);++ assert.strictEqual(out.run.state, 'condition_met', 'the trigger is recorded on the run');++ const run = (await runs(w.id)).pop();++ assert.strictEqual(Number(run.triggers), 1);++ assert.strictEqual((await observations(w.id)).length, 1);++ const all = await svc.outbox.all();++ const mine = all.filter(e => e.subject.id === w.id);++ // the contract names usr_/agt_ owners for an observation and a usr_ recipient for a trigger, so++ // a service-owned watch emits neither — it keeps the record in its own tables++ assert.deepStrictEqual(mine.filter(e => e.event_type === 'watch.observation.recorded'), []);++ assert.deepStrictEqual(mine.filter(e => e.event_type === 'watch.watch.triggered'), []);++});++++t('an agent-owned watch emits observation.recorded but never a trigger (recipient is usr_ only)', async () => {++ const w = await create(agent, { condition: { op: 'lt', value: 100 } });++ await check(w.id);++ const all = (await svc.outbox.all()).filter(e => e.subject.id === w.id);++ const observed = all.filter(e => e.event_type === 'watch.observation.recorded');++ assert.strictEqual(observed.length, 1);++ valid('watch.observation.recorded@1', observed[0].payload);++ assert.strictEqual(observed[0].payload.owner, agent.sub);++ assert.deepStrictEqual(all.filter(e => e.event_type === 'watch.watch.triggered'), []);++});++++t('the trail carries previous_hash, and an identical body records no second observation', async () => {++ const w = await create(service, { source: { kind: 'http', url: `${web.origin}/rss`, format: null }, extraction: { kind: 'text' } });++ await check(w.id);++ const first = (await observations(w.id))[0];++ assert.strictEqual(first.previous_hash, null);++ assert.strictEqual(first.value_hash.length, 64);++ // the same body again: no_change, no second observation, and the first row is untouched++ const again = await check(w.id);++ assert.strictEqual(again.run.state, 'no_change');++ assert.strictEqual((await observations(w.id)).length, 1);++});++++t('done', async () => { await web.close(); await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/** Conditions: every op, tolerance/percent, and the for_sec debounce over the observation trail. */++const assert = require('assert');++const { suite } = require('./helpers');++const { evaluate, asNumber, isAbsent } = require('../server/condition');++++const t = suite('condition');++const prev = (value, { at = 0, met = false } = {}) => ({ value, observed_at: at, condition_met: met });++++t('changed: the first observation changed, an equal one did not, tolerance and percent', () => {++ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'a' }).changed, true, 'nothing before it, so it changed');++ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'a', previous: prev('a') }).changed, false);++ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'b', previous: prev('a') }).changed, true);++ // objects compare by canonical JSON, key order does not matter++ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: { a: 1, b: [2, 3] }, previous: prev({ b: [2, 3], a: 1 }) }).changed, false);++ // tolerance: a move smaller than it is not a change++ const tolerance = { mode: 'threshold', tolerance: 0.5 };++ assert.strictEqual(evaluate({ comparison: tolerance, condition: { op: 'changed' } }, { value: 10.2, previous: prev(10) }).changed, false);++ assert.strictEqual(evaluate({ comparison: tolerance, condition: { op: 'changed' } }, { value: 10.6, previous: prev(10) }).changed, true);++ // percent: 20.00 → 18.00 is a 10 % move++ const percent = { mode: 'changed', percent: 5 };++ assert.strictEqual(evaluate({ comparison: percent, condition: { op: 'changed' } }, { value: 19, previous: prev(20) }).changed, false);++ assert.strictEqual(evaluate({ comparison: percent, condition: { op: 'changed' } }, { value: 18, previous: prev(20) }).changed, true);++ // mode none never changes++ assert.strictEqual(evaluate({ comparison: { mode: 'none' }, condition: { op: 'changed' } }, { value: 2, previous: prev(1) }).changed, false);++});++++t('gt/gte/lt/lte: numbers, decimal strings, and no guessing', () => {++ const at = (op, v, value) => evaluate({ condition: { op, value: v } }, { value }).holds;++ assert.strictEqual(at('gt', 10, 11), true);++ assert.strictEqual(at('gt', 10, 10), false);++ assert.strictEqual(at('gte', 10, 10), true);++ assert.strictEqual(at('lt', 10, 9.99), true);++ assert.strictEqual(at('lte', 10, 10), true);++ assert.strictEqual(at('lt', 10, '9.5'), true, 'a decimal string compares as the number it states');++ assert.strictEqual(at('lt', 10, 'n/a'), false, 'a value that is not a number is never low');++ assert.strictEqual(at('gt', 10, null), false);++ assert.strictEqual(at('gt', 10, { price: 3 }), false);++ assert.strictEqual(at('gt', 'm', 'n'), true, 'strings compare as strings when the condition states one');++ // the target may come from the previous value instead of the condition++ assert.strictEqual(evaluate({ condition: { op: 'lt' } }, { value: 9, previous: prev(10) }).holds, true);++});++++t('eq / ne: numbers and everything else', () => {++ const eq = (v, value) => evaluate({ condition: { op: 'eq', value: v } }, { value }).holds;++ assert.strictEqual(eq(10, 10), true);++ assert.strictEqual(eq(10, '10'), true, 'the same number stated as a string is the same number');++ assert.strictEqual(eq(10, 11), false);++ assert.strictEqual(eq('live', 'live'), true);++ assert.strictEqual(eq({ a: 1 }, { a: 1 }), true);++ assert.strictEqual(eq(null, null), true, 'eq null is "states nothing"');++ assert.strictEqual(evaluate({ condition: { op: 'ne', value: 10 } }, { value: 11 }).holds, true);++});++++t('contains / matches', () => {++ const holds = (condition, value) => evaluate({ condition }, { value }).holds;++ assert.strictEqual(holds({ op: 'contains', value: 'sold' }, 'now sold out'), true);++ assert.strictEqual(holds({ op: 'contains', value: 'sold' }, 'in stock'), false);++ assert.strictEqual(holds({ op: 'contains', value: 'b' }, ['a', 'b']), true);++ assert.strictEqual(holds({ op: 'contains', value: 'price' }, { price: 3 }), true);++ assert.strictEqual(holds({ op: 'matches', value: '^v\\d+\\.\\d+' }, 'v2.3 released'), true);++ assert.strictEqual(holds({ op: 'matches', value: '^v\\d+' }, 'vX'), false);++ assert.strictEqual(holds({ op: 'matches', value: '([' }, 'anything'), false, 'an invalid pattern never throws');++ assert.strictEqual(holds({ op: 'matches', value: null }, 'anything'), false);++});++++t('exists / absent', () => {++ const holds = (op, value) => evaluate({ condition: { op } }, { value }).holds;++ assert.strictEqual(holds('exists', 'x'), true);++ assert.strictEqual(holds('exists', 0), true, 'zero is a value the source stated');++ assert.strictEqual(holds('exists', false), true);++ assert.strictEqual(holds('exists', null), false);++ assert.strictEqual(holds('exists', ''), false);++ assert.strictEqual(holds('exists', []), false);++ assert.strictEqual(holds('absent', null), true);++ assert.strictEqual(holds('absent', 'x'), false);++ assert.strictEqual(isAbsent(undefined), true);++ assert.strictEqual(isAbsent({ a: 1 }), false);++});++++t('for_sec: a condition must hold for that long before it is met', () => {++ const now = 1_000_000;++ const opts = { value: 11, now };++ const condition = { op: 'gt', value: 10, for_sec: 60 };++ // first sighting: holds, but the debounce has not run++ const first = evaluate({ condition }, { ...opts, previous: null, heldSince: null });++ assert.deepStrictEqual([first.holds, first.met, first.since], [true, false, now]);++ // the trail says it has held for 30 s: still not met++ assert.strictEqual(evaluate({ condition }, { ...opts, heldSince: now - 30_000 }).met, false);++ // 60 s: met++ assert.strictEqual(evaluate({ condition }, { ...opts, heldSince: now - 60_000 }).met, true);++ // a predicate that stopped holding never counts the earlier run++ const broken = evaluate({ condition }, { value: 9, now, heldSince: now - 120_000 });++ assert.deepStrictEqual([broken.holds, broken.met, broken.since], [false, false, null]);++ // for_sec 0 (or absent) fires at once++ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10, for_sec: 0 } }, opts).met, true);++ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10 } }, opts).met, true);++ // without a trail the debounce starts at this observation (never fires from nothing)++ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10, for_sec: 1 } }, { ...opts, heldSince: null }).met, false);++});++++t('asNumber only reads what is stated', () => {++ assert.strictEqual(asNumber('12.50'), 12.5);++ assert.strictEqual(asNumber('-3'), -3);++ assert.strictEqual(asNumber('1e3'), 1000);++ assert.strictEqual(asNumber(''), null);++ assert.strictEqual(asNumber('free'), null);++ assert.strictEqual(asNumber('12 items'), null);++ assert.strictEqual(asNumber(true), null);++ assert.strictEqual(asNumber(Infinity), null);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test boot (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * WATCH_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../server/db');++++const testDb = ({ store = process.env.WATCH_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'watch', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++/** Extraction: text, html, css (the small subset), json/jsonpath, regex, fields — and `ai` refusing. */++const assert = require('assert');++const { suite } = require('./helpers');++const { extract, ExtractError, at, parseSelector } = require('../server/extract');++++const t = suite('extract');++const body = (s) => Buffer.from(s, 'utf8');++++const PAGE = `<!doctype html>++<html><head><title>Shop</title><style>.price { color: red }</style><script>var x = "<span>9.99</span>";</script></head>++<body>++ <div id="main" class="card wide">++ <h1 class="title">Widget <em>Pro</em></h1>++ <p class="price" data-currency="EUR">€ 19,<span class="cents">99</span></p>++ <ul id="stock"><li>3 in stock</li><li>warehouse B</li></ul>++ <a href="/buy" rel="nofollow">Buy now</a>++ </div>++</body></html>`;++++t('text: the whole body trimmed, capped, never markup-stripped', () => {++ const out = extract({ kind: 'text' }, { body: body(' hello '), contentType: 'text/plain' });++ assert.strictEqual(out.value, 'hello');++ const html = extract({ kind: 'text' }, { body: body('<b>hi</b>') });++ assert.strictEqual(html.value, '<b>hi</b>', 'text states what the source sent');++ const long = extract({ kind: 'text' }, { body: body('x'.repeat(70000)) });++ assert.strictEqual(long.value.length, 65536);++ assert.ok(long.value.endsWith('…'));++});++++t('html: markup stripped, or the text of the first element the selector matches', () => {++ const all = extract({ kind: 'html' }, { body: body(PAGE), contentType: 'text/html' });++ assert.match(all.value, /Widget Pro/);++ assert.match(all.value, /3 in stock/);++ assert.ok(!all.value.includes('var x'), 'scripts are dropped');++ assert.ok(!all.value.includes('color: red'), 'styles are dropped');++ const title = extract({ kind: 'html', selector: 'h1.title' }, { body: body(PAGE) });++ assert.strictEqual(title.value, 'Widget Pro');++ const price = extract({ kind: 'html', selector: '#main .price' }, { body: body(PAGE) });++ assert.strictEqual(price.value, '€ 19,99');++ const attr = extract({ kind: 'html', selector: 'a[rel="nofollow"]' }, { body: body(PAGE) });++ assert.strictEqual(attr.value, 'Buy now');++ const missing = extract({ kind: 'html', selector: '.nothing' }, { body: body(PAGE) });++ assert.strictEqual(missing.value, null, 'a selector that matches nothing states nothing');++});++++t('css: the small selector subset (tag, .class, #id, [attr], descendants, >)', () => {++ const pick = (selector) => extract({ kind: 'css', selector }, { body: body(PAGE) }).value;++ assert.strictEqual(pick('li'), '3 in stock', 'the first match, like regex');++ assert.strictEqual(pick('#stock li'), '3 in stock');++ assert.strictEqual(pick('ul#stock'), '3 in stock warehouse B', 'the whole matched element, in document order');++ assert.strictEqual(pick('.card .title'), 'Widget Pro');++ assert.strictEqual(pick('div#main > h1'), 'Widget Pro');++ assert.strictEqual(pick('div#main > a'), 'Buy now');++ assert.strictEqual(pick('ul[id=stock] li.cents'), null, 'no such element');++ assert.strictEqual(pick('[data-currency]'), '€ 19,99');++ assert.strictEqual(pick('h1 em'), 'Pro');++ // the pseudo-classes and sibling combinators of real CSS are outside the subset, not silently ignored++ assert.strictEqual(parseSelector('li:first-child'), null);++ assert.strictEqual(parseSelector('h1 + p'), null);++ assert.throws(() => extract({ kind: 'css' }, { body: body(PAGE) }), /needs a selector/);++ assert.throws(() => extract({ kind: 'css', selector: 'li:first-child' }, { body: body(PAGE) }), (e) => e.code === 'unsupported');++});++++t('json / jsonpath: the parsed body, a dotted path, array indexes and fields', () => {++ const doc = JSON.stringify({ product: { price: { amount: 19.99, currency: 'EUR' }, tags: ['new', 'sale'] } });++ assert.deepStrictEqual(Object.keys(extract({ kind: 'json' }, { body: body(doc) }).value), ['product']);++ assert.strictEqual(extract({ kind: 'json', value_path: 'product.price.amount' }, { body: body(doc) }).value, 19.99);++ assert.strictEqual(extract({ kind: 'jsonpath', value_path: 'product.tags.1' }, { body: body(doc) }).value, 'sale');++ assert.strictEqual(extract({ kind: 'jsonpath', value_path: 'product.tags[0]' }, { body: body(doc) }).value, 'new');++ assert.strictEqual(extract({ kind: 'json', value_path: 'product.nothing' }, { body: body(doc) }).value, null, 'a path that matches nothing extracts null, never undefined');++ const fields = extract({ kind: 'json', fields: { price: 'product.price.amount', currency: 'product.price.currency', missing: 'product.gone' } }, { body: body(doc) }).value;++ assert.deepStrictEqual(fields, { price: 19.99, currency: 'EUR', missing: null });++ assert.throws(() => extract({ kind: 'json' }, { body: body('not json') }), (e) => e.code === 'parse_error');++});++++t('json reads the document a carrier already parsed (a feed or api mapping)', () => {++ const document = { items: [{ title: 'a', price: 3 }, { title: 'b', price: 5 }], latest: { title: 'a', price: 3 }, count: 2 };++ assert.strictEqual(extract({ kind: 'json', value_path: 'latest.title' }, { body: body('{}'), document }).value, 'a');++ assert.strictEqual(extract({ kind: 'json', value_path: 'items.1.price' }, { body: body('{}'), document }).value, 5);++ assert.strictEqual(extract({ kind: 'json', fields: { latest: 'latest.title', n: 'count' } }, { body: null, document }).value.latest, 'a');++});++++t('regex: the first match, a group when the pattern has one', () => {++ assert.strictEqual(extract({ kind: 'regex', selector: '(\\d+\\.\\d+) EUR' }, { body: body('now 12.50 EUR, then 99.00 EUR') }).value, '12.50');++ assert.strictEqual(extract({ kind: 'regex', selector: '\\d{4}-\\d{2}-\\d{2}' }, { body: body('on 2026-10-07 at noon') }).value, '2026-10-07');++ assert.strictEqual(extract({ kind: 'regex', selector: '/(\\d+) items/i' }, { body: body('12 ITEMS left') }).value, '12');++ assert.strictEqual(extract({ kind: 'regex', selector: 'nothing' }, { body: body('abc') }).value, null);++ assert.throws(() => extract({ kind: 'regex', selector: '(' }, { body: body('x') }), (e) => e.code === 'parse_error');++ assert.throws(() => extract({ kind: 'regex', selector: 'x'.repeat(300) }, { body: body('x') }), (e) => e.code === 'unsupported');++});++++t('ai is not a check: it refuses, and the refusal is `unsupported`', () => {++ assert.throws(() => extract({ kind: 'ai', ai: { prompt: 'read the price' } }, { body: body(PAGE) }), (err) => {++ assert.ok(err instanceof ExtractError);++ assert.strictEqual(err.code, 'unsupported');++ assert.match(err.message, /AI action/);++ return true;++ });++});++++t('the snapshot is a capped copy of the body the value came from', () => {++ const out = extract({ kind: 'text' }, { body: body('hello world'), snapshotMax: 5 });++ assert.strictEqual(out.value, 'hello world');++ assert.strictEqual(out.snapshot, 'hell…');++});++++t('at(): dotted paths, indexes, and nothing that is not a path', () => {++ assert.strictEqual(at({ a: { b: [{ c: 1 }] } }, 'a.b.0.c'), 1);++ assert.strictEqual(at({ a: { b: [{ c: 1 }] } }, 'a.b[0].c'), 1);++ assert.strictEqual(at({ a: 1 }, 'a.b.c'), undefined);++ assert.strictEqual(at({ a: 1 }, 'a; DROP TABLE'), undefined);++ assert.deepStrictEqual(at({ a: 'x' }, ''), { a: 'x' }, 'an empty path is the document itself (items_path "")');++});++++t.run();nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * Shared test fixtures: a generated Network signing key, service and user token minting, a booted++ * Watch service on a random port with a temp database, and stub HTTP servers standing in for the++ * pages, feeds and APIs being watched. Nothing here touches the internet.++ */++const crypto = require('crypto');++const nodeHttp = require('http');++const { serviceAuth } = require('openvibe-contracts');++const { load } = require('../server/config');++const { start } = require('../server/index');++++const ISSUER = 'https://openvibe.network';++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++});++const publicJwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });++++const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };++++/** A service principal's token (svc:…), the shape OpenVibe.Network signs for client credentials. */++function serviceToken(slug, cap, { aud = 'openvibe.watch', exp = Math.floor(Date.now() / 1000) + 300, key = privateKey } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: ISSUER, sub: `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, key);++}++++/** A signed-in person's (usr_…) or agent's (agt_…) bearer token, for audience openvibe.watch. */++function userToken(sub, { aud = 'openvibe.watch', role = 'member', exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, extra = {} } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: ISSUER, sub, aud: [aud], iat: now, exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ role, ...(/^usr_/.test(sub) ? { subject_id: sub } : {}), ...extra,++ }, key);++}++++/** A fresh usr_… subject id (a person), for tests that need an owner. */++const newUser = () => require('openvibe-contracts').ids.newId('user');++/** A fresh agt_… subject id (an agent), for tests that need one. */++const newAgent = () => require('openvibe-contracts').ids.newId('agent');++++/**++ * Boot Watch; the scheduler is off unless worker: 'on'. Loopback is allowlisted for the stubs unless++ * the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).++ * log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).++ */++async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, log = silent, limitsNow = null, allowPrivate = true } = {}) {++ // A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real++ // to fetch and verify against (no test touches the internet). Its URL is overridable via env.++ const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });++ const config = load({++ NODE_ENV: 'test',++ PORT: '0',++ OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,++ WATCH_WORKER: worker,++ WATCH_ALLOW_PRIVATE_HOSTS: allowPrivate ? '127.0.0.1' : '',++ WATCH_HOST_MIN_INTERVAL_MS: '0',++ WATCH_FETCH_TIMEOUT_MS: '1500',++ WATCH_TICK_MS: '50',++ ...env,++ });++ // One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.++ const testdb = await require('./db').testDb();++ const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });++ const base = `http://127.0.0.1:${h.server.address().port}`;++ return { ...h, base, jwksSite, async stop() { await h.close(); await testdb.close(); await jwksSite.close(); } };++}++++async function request(base, method, p, { token, body, headers = {} } = {}) {++ 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: 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 stub website. routes: { '/path': (req, res, ctx) => void | { status, headers, body, delayMs } }.++ * Every request is recorded with its method, headers and arrival time.++ */++async function site(routes = {}) {++ const requests = [];++ const server = nodeHttp.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://stub');++ requests.push({ path: url.pathname, search: url.search, method: req.method, headers: req.headers, at: Date.now() });++ const handler = routes[url.pathname] || routes['*'];++ if (!handler) { res.statusCode = 404; res.end('not found'); return; }++ const out = await handler(req, res, { url, count: requests.filter(r => r.path === url.pathname).length });++ if (!out || res.writableEnded) return;++ if (out.delayMs) await new Promise(r => setTimeout(r, out.delayMs));++ if (res.destroyed) return;++ res.writeHead(out.status || 200, out.headers || {});++ res.end(out.body === undefined ? '' : out.body);++ });++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const origin = `http://127.0.0.1:${server.address().port}`;++ return {++ origin,++ requests,++ hits: (p) => requests.filter(r => r.path === p),++ routes,++ close: () => new Promise(r => { server.closeAllConnections?.(); server.close(() => r()); }),++ };++}++++/** A valid http watch body pointing at a stub (every field the released contract requires). */++function watchDef(overrides = {}) {++ return {++ name: 'Stub watch',++ source: { kind: 'http', url: 'https://example.org/page', format: null },++ cadence: { every_sec: 60, jitter_sec: 0 },++ extraction: { kind: 'text' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ ...overrides,++ };++}++++function rss(items) {++ return `<?xml version="1.0"?><rss version="2.0"><channel><title>Stub</title>${items.map(i =>++ `<item><title>${i.title}</title><link>${i.link}</link><guid isPermaLink="false">${i.guid}</guid>${i.date ? `<pubDate>${i.date}</pubDate>` : ''}<description>${i.description || ''}</description></item>`).join('')}</channel></rss>`;++}++++const sleep = (ms) => new Promise(r => setTimeout(r, ms));++++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, privateKey, publicKey, silent, serviceToken, userToken, newUser, newAgent, boot, request, site,++ watchDef, rss, sleep, suite,++};nnew file mode 100644@@@ -0,0 +1,198 @@++'use strict';++/**++ * The registry: validation against the released contracts, due selection, arming and re-arming on++ * the cadence, soft delete, owner scoping, the keyset page, and the view (which the service++ * validates with openvibe-contracts' own validator).++ */++const assert = require('assert');++const { validate } = require('openvibe-contracts');++const { boot, suite, newUser, newAgent } = require('./helpers');++++const t = suite('registry');++let svc;++const me = { sub: newUser() };++const someoneElse = { sub: newUser() };++++const def = (overrides = {}) => ({++ name: 'a watch',++ source: { kind: 'http', url: 'https://example.org/page', format: null },++ cadence: { every_sec: 60 },++ extraction: { kind: 'text' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification' }],++ ...overrides,++});++const invalid = async (body, re, create = true) => {++ await assert.rejects(++ () => (create ? svc.registry.create(body, me) : svc.registry.patch('wch_01J0000000000000000000000', body, me)),++ (err) => {++ assert.strictEqual(err.code, 'watch.invalid', err.message);++ assert.match(err.message, re);++ return true;++ },++ );++};++++t('boot (worker off)', async () => { svc = await boot(); });++++t('create: what a watch requires, enforced in code over the released schema', async () => {++ for (const field of ['source', 'extraction', 'condition', 'action']) {++ const body = def();++ delete body[field];++ await assert.rejects(() => svc.registry.create(body, me), (err) => {++ assert.strictEqual(err.code, 'watch.invalid');++ assert.match(err.message, new RegExp(`requires ${field}`));++ return true;++ });++ }++ // the schema itself refuses what the contract refuses++ await invalid(def({ source: { kind: 'http' } }), /watch.watch-request@1/);++ await invalid(def({ source: { kind: 'ftp', url: 'ftp://example.org/x' } }), /watch.watch-request@1/);++ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', nope: 1 } }), /watch.watch-request@1/);++ await invalid(def({ cadence: { every_sec: 5 } }), /watch.watch-request@1/, true);++ await invalid(def({ summary: 'not a field' }), /watch.watch-request@1/);++ await invalid(def({ action: [] }), /watch.watch-request@1/);++ await invalid({ ...def(), owner: 'usr_01J0000000000000000000000' }, /watch.watch-request@1/);++ // and the rules a schema cannot carry++ await invalid(def({ cadence: null }), /needs a cadence/);++ await invalid(def({ cadence: {} }), /every_sec/);++ await invalid(def({ source: { kind: 'event', pattern: 'release.published' }, cadence: { every_sec: 60 } }), /never polls/);++ await invalid(def({ source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: { every_sec: 60 } }), /never polls/);++ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', headers: { Host: 'evil.example' } } }), /Host/);++ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', headers: { 'if-none-match': 'x' } } }), /if-none-match/);++ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', auth: { mode: 'header', env: 'SOMETHING_ELSE' } } }), /watch.watch-request@1/);++ // a HEAD source states no body, so only a record extraction can read one++ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', method: 'HEAD' } }), /HEAD source states no body/);++ const head = await svc.registry.create(def({ source: { kind: 'http', url: 'https://example.org/x', method: 'HEAD' }, extraction: { kind: 'json', value_path: 'status' } }), me);++ assert.strictEqual(head.source.method, 'HEAD');++});++++t('create: an id, an owner, a name, the arm and the event', async () => {++ const before = Date.now();++ const row = await svc.registry.create(def({ name: undefined }), me);++ assert.match(row.id, /^wch_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(row.owner_sub, me.sub);++ assert.strictEqual(row.status, 'active');++ assert.strictEqual(row.name, 'example.org/page', 'a watch without a name is named after its source');++ assert.ok(row.next_due_at >= before && row.next_due_at <= before + 1000, 'a new watch is due on the next tick (its first reading)');++ assert.strictEqual(row.updated_by, me.sub);++ const events = (await svc.outbox.all()).filter(e => e.subject.id === row.id);++ assert.deepStrictEqual(events.map(e => e.event_type), ['watch.watch.created']);++ assert.strictEqual(events[0].payload.owner, me.sub);++ const agentOwned = await svc.registry.create(def(), { sub: newAgent() });++ assert.match(agentOwned.owner_sub, /^agt_/);++});++++t('the view is validated against watch.watch@1, and the health tells the runtime truthfully', async () => {++ const row = await svc.registry.create(def(), me);++ const view = svc.registry.view(row);++ assert.ok(validate('watch.watch@1', view).valid);++ assert.deepStrictEqual(Object.keys(view).sort(), [++ 'action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',++ 'labels', 'name', 'owner', 'project_id', 'retention', 'source', 'status', 'updated_at',++ ]);++ assert.deepStrictEqual(view.health.status, 'never_checked');++ assert.strictEqual(view.health.next_due_at != null, true);++ assert.match(view.created_at, /^\d{4}-\d{2}-\d{2}T/);++ // a paused watch is paused, and an event watch waits (it is never due)++ const paused = svc.registry.view(await svc.registry.patch(row.id, { status: 'paused' }, me));++ assert.strictEqual(paused.health.status, 'paused');++ assert.strictEqual(paused.health.next_due_at, null);++ const pushed = await svc.registry.create(def({ source: { kind: 'event', pattern: 'release.published' }, cadence: null, extraction: { kind: 'json', value_path: 'version' } }), me);++ assert.strictEqual(svc.registry.view(pushed).health.status, 'waiting');++ assert.strictEqual(svc.registry.view(pushed).cadence, null);++});++++t('due() selects active, armed watches oldest first, and nothing else', async () => {++ const a = await svc.registry.create(def(), me);++ const b = await svc.registry.create(def(), me);++ const paused = await svc.registry.create(def(), me);++ const eventish = await svc.registry.create(def({ source: { kind: 'event', pattern: 'a.b' }, cadence: null }), me);++ await svc.registry.patch(paused.id, { status: 'paused' }, me);++ await svc.db.prepare('UPDATE watches SET next_due_at = 2000 WHERE id = ?').run(a.id);++ await svc.db.prepare('UPDATE watches SET next_due_at = 1000 WHERE id = ?').run(b.id);++ await svc.db.prepare('UPDATE watches SET next_due_at = 1 WHERE id = ?').run(paused.id);++ await svc.db.prepare('UPDATE watches SET next_due_at = 1 WHERE id = ?').run(eventish.id);++ const due = (await svc.registry.due(50)).map(d => d.id);++ assert.deepStrictEqual(due.slice(0, 2), [b.id, a.id], 'oldest due first');++ assert.ok(!due.includes(paused.id), 'a paused watch is not due');++ assert.ok(!due.includes(eventish.id), 'a watch without a cadence is never due');++ // not_before holds a watch back even when its turn has come++ await svc.db.prepare('UPDATE watches SET not_before = ? WHERE id = ?').run(Date.now() + 3600_000, b.id);++ assert.ok(!(await svc.registry.due(50)).map(d => d.id).includes(b.id));++});++++t('patch re-arms the cadence, and pause/resume through status', async () => {++ const w = await svc.registry.create(def(), me);++ const before = Date.now();++ const slowed = await svc.registry.patch(w.id, { cadence: { every_sec: 3600 } }, me);++ assert.ok(slowed.next_due_at >= before + 3600_000, 're-armed on the new cadence');++ assert.strictEqual(slowed.updated_by, me.sub);++ const paused = await svc.registry.patch(w.id, { status: 'paused' }, me);++ assert.strictEqual(paused.status, 'paused');++ assert.strictEqual(paused.next_due_at, 0);++ const resumed = await svc.registry.patch(w.id, { status: 'active' }, me);++ assert.strictEqual(resumed.status, 'active');++ assert.ok(resumed.next_due_at >= Date.now() + 3600_000 - 1000);++ // only the named fields change++ const renamed = await svc.registry.patch(w.id, { name: 'renamed' }, me);++ assert.strictEqual(renamed.name, 'renamed');++ assert.deepStrictEqual(renamed.source, w.source, 'jsonb rows come back parsed');++ // a patch that would break a rule is refused, and the row is untouched++ await assert.rejects(() => svc.registry.patch(w.id, { cadence: null }, me), (err) => err.code === 'watch.invalid');++ const after = await svc.registry.get(w.id);++ assert.deepStrictEqual(after.cadence, { every_sec: 3600 });++ const events = (await svc.outbox.all()).filter(e => e.subject.id === w.id).map(e => e.event_type);++ assert.deepStrictEqual(events, ['watch.watch.created', 'watch.watch.updated', 'watch.watch.paused', 'watch.watch.updated', 'watch.watch.updated']);++});++++t('soft delete: gone from every read, still resolvable for its observations', async () => {++ const w = await svc.registry.create(def(), me);++ await svc.db.prepare('INSERT INTO observations (id, watch_id, check_run_id, observed_at, value, value_hash, changed, condition_met) VALUES (?,?,?,?,?,?,0,0)')++ .run('wco_01J0000000000000000000000', w.id, 'ckr_01J0000000000000000000000', Date.now(), '"x"', 'a'.repeat(64));++ const out = await svc.registry.remove(w.id, me);++ assert.deepStrictEqual(out, { id: w.id, deleted: true });++ assert.strictEqual(await svc.registry.get(w.id), null);++ assert.ok(!(await svc.registry.all(me)).some(r => r.id === w.id));++ assert.ok(!(await svc.registry.due(200)).some(d => d.id === w.id));++ const raw = await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(w.id);++ assert.ok(raw.deleted_at > 0, 'the row stays, with its observations');++ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?').get(w.id)).n, 1);++ // a second delete is not found++ assert.strictEqual(await svc.registry.remove(w.id, me), null);++});++++t('owner scoping: another subject sees nothing, and every write is scoped too', async () => {++ const w = await svc.registry.create(def(), me);++ assert.strictEqual(await svc.registry.get(w.id, someoneElse), null, '404 for another owner, never 403');++ assert.ok(!(await svc.registry.all(someoneElse)).some(r => r.id === w.id));++ assert.strictEqual(await svc.registry.patch(w.id, { name: 'stolen' }, someoneElse), null);++ assert.strictEqual(await svc.registry.remove(w.id, someoneElse), null);++ assert.strictEqual((await svc.registry.get(w.id, me)).name, 'a watch');++ // a service acting for a person is scoped to that person++ const acted = await svc.registry.create(def(), { sub: 'svc:site', subject: me.sub });++ assert.strictEqual(acted.owner_sub, me.sub);++ assert.ok((await svc.registry.all(me)).some(r => r.id === acted.id));++ assert.ok(!(await svc.registry.all({ sub: 'svc:site' })).some(r => r.id === acted.id));++});++++t('all(): the keyset page is id order (a wch_ ULID is creation order)', async () => {++ const other = { sub: newUser() };++ const ids = [];++ for (let i = 0; i < 5; i++) ids.push((await svc.registry.create(def(), other)).id);++ const page1 = await svc.registry.all(other, { limit: 2 });++ assert.deepStrictEqual(page1.map(r => r.id), [ids[4], ids[3]]);++ const page2 = await svc.registry.all(other, { limit: 2, before: page1[1].id });++ assert.deepStrictEqual(page2.map(r => r.id), [ids[2], ids[1]]);++ const page3 = await svc.registry.all(other, { limit: 2, before: page2[1].id });++ assert.deepStrictEqual(page3.map(r => r.id), [ids[0]]);++ assert.strictEqual((await svc.registry.all(other, { limit: 10 })).length, 5);++ // status filters++ await svc.registry.patch(ids[0], { status: 'paused' }, other);++ assert.deepStrictEqual((await svc.registry.all(other, { status: 'paused' })).map(r => r.id), [ids[0]]);++ assert.strictEqual((await svc.registry.all(other, { status: 'active' })).length, 4);++});++++t('done', async () => { await svc.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,18 @@++#!/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 PGlite 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++ * npm test -- --strict # a skipped test fails the run too++ *++ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with++ * ○ and not counted as passed (openvibe-shared/test-runner).++ */++'use strict';++// 180 s per file: every test that boots the service stands up its own PGlite (a WASM PostgreSQL whose first++// query is seconds of init), and on a loaded machine — the CI shards and the local check run side by side — a++// file that takes ~30 s idle can pass 60 s. Same 180 s as Host's PGlite suite; a hung file still dies.++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 32, parallel: 1 });nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++/**++ * The scheduler: due order (oldest due first), the concurrency cap, not_before/backoff holding a++ * watch back, and the worker actually checking on its own tick.++ */++const assert = require('assert');++const { boot, site, suite, sleep, newUser } = require('./helpers');++++const owner = { sub: newUser() };++let svc;++let web;++++const create = async (name, overrides = {}) => await svc.registry.create({++ name,++ source: { kind: 'http', url: `${web.origin}/page`, format: null },++ cadence: { every_sec: 60 },++ extraction: { kind: 'text' },++ condition: { op: 'eq', value: 'nothing' },++ action: [{ kind: 'notification' }],++ ...overrides,++}, owner);++++const dueAt = async (id, next, notBefore = 0) => {++ await svc.db.prepare('UPDATE watches SET next_due_at = ?, not_before = ? WHERE id = ?').run(next, notBefore, id);++};++const quiet = async () => {++ for (let i = 0; i < 200 && svc.check.inflight().length; i++) await sleep(25);++ assert.deepStrictEqual(svc.check.inflight(), [], 'every check finished');++};++++const t = suite('scheduler');++++t('boot', async () => {++ web = await site({ '/page': () => ({ body: 'a page' }), '/slow': () => ({ delayMs: 300, body: 'slow' }) });++ svc = await boot({ env: { WATCH_MAX_CONCURRENT: '2' } });++});++++t('tick starts the watches whose turn has come, oldest due first', async () => {++ const a = await create('a');++ const b = await create('b');++ const c = await create('c');++ // all due, in an order the creation ids do not happen to share++ await dueAt(c.id, 3000);++ await dueAt(a.id, 1000);++ await dueAt(b.id, 2000);++ assert.deepStrictEqual(await svc.scheduler.tick(), [a.id, b.id], 'two, the cap, oldest first');++ await quiet();++ assert.deepStrictEqual(await svc.scheduler.tick(), [c.id], 'the next tick takes the next one');++ await quiet();++ assert.deepStrictEqual((await svc.registry.due(10)).map(d => d.id), [], 'nothing is left due');++});++++t('the concurrency cap holds: no more than WATCH_MAX_CONCURRENT checks at a time', async () => {++ const ids = [];++ for (let i = 0; i < 4; i++) {++ const w = await create(`slow ${i}`, { source: { kind: 'http', url: `${web.origin}/slow`, format: null }, condition: { op: 'eq', value: 'nothing' } });++ await dueAt(w.id, 1000 + i);++ ids.push(w.id);++ }++ const first = await svc.scheduler.tick();++ assert.strictEqual(first.length, 2, 'two, the cap');++ assert.deepStrictEqual(first, ids.slice(0, 2));++ assert.strictEqual(svc.scheduler.active().length, 2);++ assert.deepStrictEqual(await svc.scheduler.tick(), [], 'nothing is free while both are in flight');++ assert.deepStrictEqual(svc.check.inflight().sort(), first.slice().sort());++ await quiet();++ const third = await svc.scheduler.tick();++ assert.strictEqual(third.length, 2);++ assert.ok(!first.includes(third[0]));++ await quiet();++ assert.deepStrictEqual(svc.scheduler.active(), []);++});++++t('a watch that is not due, or is waiting out a Retry-After, is not started', async () => {++ const later = await create('later');++ const waiting = await create('waiting');++ const paused = await create('paused');++ await svc.registry.patch(paused.id, { status: 'paused' }, owner);++ const gone = await create('gone');++ await svc.registry.remove(gone.id, owner);++ await dueAt(later.id, Date.now() + 3600_000);++ await dueAt(waiting.id, 1000, Date.now() + 3600_000);++ await dueAt(paused.id, 1000);++ await dueAt(gone.id, 1000);++ assert.deepStrictEqual(await svc.scheduler.tick(), [], 'nothing here is due');++ const due = new Set((await svc.registry.due(50)).map(d => d.id));++ for (const w of [later, waiting, paused, gone]) assert.ok(!due.has(w.id), `${w.id} is not in the queue`);++});++++t('a failing watch backs off: the scheduler leaves it alone until its next due time', async () => {++ const w = await create('broken', { source: { kind: 'http', url: `${web.origin}/nope`, format: null } });++ await dueAt(w.id, 0);++ const out = await svc.check.run(w.id, { trigger: 'schedule' });++ assert.strictEqual(out.run.state, 'http_error');++ const row = await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(w.id);++ assert.strictEqual(row.next_due_at - row.last_check_at, 60000, 'first failure: every_sec · 2^0');++ let due = new Set((await svc.registry.due(50)).map(d => d.id));++ assert.ok(!due.has(w.id), 'not due until the backoff has run');++ await dueAt(w.id, row.next_due_at - 120000);++ due = new Set((await svc.registry.due(50)).map(d => d.id));++ assert.ok(due.has(w.id));++});++++t('done', async () => { await web.close(); await svc.stop(); });++++t.run();++++// A second suite: with the worker on, the tick itself checks due watches.++const t2 = suite('scheduler (worker on)');++t2('the worker checks a due watch without anyone asking', async () => {++ const web2 = await site({ '/page': () => ({ body: 'tick' }) });++ const svc2 = await boot({ worker: 'on', env: { WATCH_TICK_MS: '40', WATCH_HOST_MIN_INTERVAL_MS: '0' } });++ try {++ const w = await svc2.registry.create({++ name: 'auto', source: { kind: 'http', url: `${web2.origin}/page`, format: null },++ cadence: { every_sec: 60 }, extraction: { kind: 'text' },++ condition: { op: 'eq', value: 'nothing' }, action: [{ kind: 'notification' }],++ }, { sub: newUser() });++ // a new watch is due at once (its first reading), then armed by cadence after each check;++ // bring its turn forward and let the worker find it on its own++ await svc2.db.prepare('UPDATE watches SET next_due_at = 0 WHERE id = ?').run(w.id);++ for (let i = 0; i < 100 && (await svc2.db.prepare('SELECT COUNT(*) AS n FROM check_runs WHERE watch_id = ?').get(w.id)).n === 0; i++) await sleep(50);++ const runs = await svc2.db.prepare('SELECT * FROM check_runs WHERE watch_id = ?').all(w.id);++ assert.strictEqual(runs.length, 1, 'the scheduler ran exactly one check (the cadence is a minute)');++ assert.strictEqual(runs[0].trigger, 'schedule');++ assert.strictEqual(runs[0].state, 'changed');++++ const ready = await (await fetch(`${svc2.base}/api/ready`)).json();++ assert.strictEqual(ready.checks.db.status, 'ok');++ assert.strictEqual(ready.checks.checker.status, 'ok', JSON.stringify(ready.checks.checker));++ assert.strictEqual(ready.checks.checker.detail.running, true);++ assert.strictEqual(ready.ready, true);++++ const metrics = await (await fetch(`${svc2.base}/metrics`)).text();++ assert.match(metrics, /watch_watches\{status="healthy"\} 1/);++ assert.match(metrics, /watch_last_check_timestamp_seconds/);++ assert.match(metrics, /watch_observations\{state="changed"\} 1/);++ } finally { await svc2.stop(); await web2.close(); }++});++t2.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+# ═══════════════════════════════════════════════════════════════
+# OpenVibe.Watch — Environment Configuration
+# Copy to .env and fill in values.
+# In production this lives at /etc/openvibe/watch.env (0600); the systemd unit sets
+# NODE_ENV and PORT itself.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4730
+HOST=127.0.0.1
+NODE_ENV=development
+#BASE_URL=https://openvibe.watch
+
+# Identity — OpenVibe.Network signs the tokens this service verifies. Service tokens (svc:…) carry
+# capability grants (audience openvibe.watch); a person's bearer token (usr_…) is verified for the
+# same audience and acts on the watches it owns.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+#OV_NETWORK_ISSUER=https://openvibe.network
+# The JWKS the service verifies tokens against; by default Network's internal JWKS
+# (OV_NETWORK_INTERNAL_URL/api/.well-known/jwks). openvibe-sdk/auth caches it and keeps it fresh.
+#OV_NETWORK_JWKS_URL=https://openvibe.network/api/.well-known/jwks
+
+# Per-actor limits (server/api/actor-limits.js): API reads one app or module (app:…, mod:…) may make per
+# minute and per hour; past them 429 rate_limited with Retry-After. First-party services (svc:…) are not
+# counted on reads; writes have their own numbers (README).
+#WATCH_LIMITS_MINUTE=120
+#WATCH_LIMITS_HOUR=3000
+
+# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish).
+OV_OAUTH_CLIENT_ID=watch
+OV_OAUTH_CLIENT_SECRET=
+
+# Store (PostgreSQL, ADR-035), written by OpenVibe.Host roles/data/add-service.sh watch: the runtime role through
+# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+
+# OpenVibe.Events. The outbox relay runs only when EVENTS_URL is set.
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+
+# Check worker (in-process scheduler)
+#WATCH_WORKER=on
+#WATCH_TICK_MS=5000
+#WATCH_MAX_CONCURRENT=8
+#WATCH_MAX_BACKOFF_MS=21600000
+
+# Fetching (the SSRF-guarded fetcher every carrier goes through)
+#WATCH_USER_AGENT=OpenVibeWatch/0.1.0 (+https://openvibe.watch; [email protected])
+#WATCH_FETCH_TIMEOUT_MS=20000
+#WATCH_MAX_BYTES=5242880
+#WATCH_MAX_REDIRECTS=5
+# Least time between two requests to one host, whatever a watch says.
+#WATCH_HOST_MIN_INTERVAL_MS=1000
+#WATCH_ALLOWED_PORTS=80,443
+# Exact hosts/IPs allowed to resolve to private/loopback addresses. Leave EMPTY in production.
+#WATCH_ALLOW_PRIVATE_HOSTS=
+
+# Observations
+#WATCH_OBSERVATIONS_MAX_PER_CHECK=100
+#WATCH_RETENTION_DEFAULT_DAYS=90
+
+# OpenVibe.Run — the expensive checks (browser/code), step 6. Never used until then: a run source
+# answers watch.carrier_unavailable and nothing is executed.
+#WATCH_RUN_URL=http://127.0.0.1:4920
+#WATCH_RUN_OAUTH_CLIENT_SECRET=
+
+# Source credentials: a watch's source.auth names the variable (auth.env), never the value. Names
+# must start with WATCH_CRED_. Read at fetch time, never stored, never logged.
+#WATCH_CRED_EXAMPLE_API_KEY=
+
+# Webhook secrets (step 5): a watch's source.endpoint is signed with the named variable. Names must
+# start with WATCH_HOOK_ and hold at least 32 characters.
+#WATCH_HOOK_EXAMPLE_SECRET=
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ contracts-service: watch
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.log
+*.pem
+*.key
+node_modules
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,310 @@
@@ -0,0 +1,310 @@
+# OpenVibe.Watch
+
+> Persistent, user-defined observations and conditions over pages, feeds and APIs: define what to
+> watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or
+> the site's own ETag will do.
+
+**Status:** alpha (plan T18 steps 2–3). The registry, the check engine and the pull carriers exist;
+the event, webhook, Node and Run rungs and the actions do not yet.
+**Domain:** `openvibe.watch` (a short "internal service" page and health on this repository's vhost;
+the API is loopback-only). Install steps: [Public host](#public-host).
+**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete),
+the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison,
+condition, action, budget, retention".
+**License:** AGPL-3.0.
+
+## Purpose
+
+Sources ingests the sources a product chose; Watch is the place a **person or an agent** defines
+their own observation: "this page's price", "this feed's latest release", "this API's stock", and
+the condition on it. Sources performs permitted structured ingestion; **Watch owns user-defined
+persistent observations and conditions**; Run executes expensive browser/code checks; Node observes
+local/LAN things; Actor interprets and acts; Events transports the trigger.
+
+A Watch is a first-class OpenVibe resource (`watch.watch@1`), so products compose it instead of
+each growing a watcher: Rent, Homes, Work, Deals, Reviews, price trackers, uptime, releases.
+
+## Running it
+
+```bash
+npm install
+cp .env.example .env
+npm run dev # http://127.0.0.1:4730
+npm test # every test/*.test.js against local stub sites; no internet
+npm run test:pg # the same through PostgreSQL + PgBouncer (the CI path)
+```
+
+Node 22 in production. Production: `/opt/openvibe.watch`, env `/etc/openvibe/watch.env`, unit
+[deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service), database `ov_watch`
+on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh watch`), nginx
+[deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf).
+
+`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the
+database fails; a Network key that has not loaded and a check worker that is off, stopped or behind
+(a watch due for more than 15 minutes) degrade it. It reports watch counts by health status, checks
+in flight, observations by state and the outbox backlog. `GET /metrics` (openvibe-shared/metrics)
+answers direct loopback callers only: golden signals by route template, `watch_watches{status}`,
+`watch_observations{state}`, the check queue (`watch_checks_due`,
+`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),
+`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.
+
+## The watch model
+
+A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,
+`action`, `budget`, `retention`, plus `owner`, `status`, `labels`. It is validated against
+`watch.watch-request@1` on the way in and served as `watch.watch@1` on the way out — both with
+openvibe-contracts' own validator, never a hand copy of the schema. Rules a schema cannot carry
+([server/registry.js](server/registry.js)): a create requires source, extraction, condition and
+action; a source that polls (`http`, `feed`, `api`, `node`, `run`) needs `cadence.every_sec`; an
+`event` or `webhook` source is pushed to and never polls (`cadence: null`); the headers the fetcher
+owns (`Host`, `Cookie`, `User-Agent`, `If-None-Match`, …) cannot be overridden.
+
+**Ownership is the authority.** Every read and write is scoped to the acting subject — a person's
+`usr_…`/`agt_…` bearer token, or a first-party service acting for a person with `X-OV-Subject` —
+and another owner's watch is **404, never 403**: an id must not confirm that someone else's watch
+exists. A service principal that does not name a person owns what it creates.
+
+## Carrier preference (binding, plan T18)
+
+ webhook/event → ETag/Last-Modified → feed → API → and only then browser polling through Run
+
+The rungs run from cheapest to most expensive, and `source.kind` fixes which one a watch uses.
+This release carries the pull rungs:
+
+| Kind | Carrier | State |
+|---|---|---|
+| `http` | one URL, conditional | implemented |
+| `feed` | RSS 2.0 / RSS 1.0 (RDF) / Atom 1.0 → `{ items, latest, count }` | implemented |
+| `api` | JSON/XML with `items_path` + `fields`, or the body itself | implemented |
+| `event` | delivered by OpenVibe.Events | step 5 |
+| `webhook` | delivered to `/internal/hooks/…` | step 5 |
+| `run` | browser/code polling through OpenVibe.Run | step 6 |
+| `node` | a probe through OpenVibe.Node | step 7 |
+
+A kind whose carrier does not exist yet is **accepted by the registry but never fetched**: the
+check is recorded `skipped` with `watch.carrier_unavailable` and the reason names the step. That is
+what keeps a half-built rung from silently turning into polling.
+
+## A check
+
+An in-process scheduler ([server/scheduler.js](server/scheduler.js)) starts checks for active
+watches whose `next_due_at` and `not_before` have passed, oldest first, at most
+`WATCH_MAX_CONCURRENT` at a time; `check.run()` refuses a second check of the same watch, so a
+manual `POST …/check` never overlaps a scheduled one. One check
+([server/check.js](server/check.js)):
+
+1. **Carrier** chosen by `source.kind` (never for event/webhook/node/run — see above).
+2. **Fetch** through the one way out: the SSRF guard, per-host spacing, conditional GET.
+ - `If-None-Match` / `If-Modified-Since` from the stored validators; a **304 → `not_modified`**
+ (no body, no observation).
+ - When the watch only needs to know *whether* it changed (`condition.op: changed`), a **HEAD**
+ is tried first while a validator is stored: two headers instead of a body.
+ - A 200 whose body hashes to the last body → **`no_change`** (no observation).
+ - 429/503 `Retry-After` → `not_before`, so Watch waits as long as the site asked.
+3. **Extract** the value (table below) from the body, or from the records a feed/api carrier mapped.
+4. **Compare and evaluate**: `changed` (with `tolerance`/`percent`), the predicate, and the
+ `for_sec` debounce measured from the observation trail — an unchanged body still evaluates the
+ condition, so "this has been true for five minutes" can fire without the page changing.
+5. **Record**, in ONE transaction: the `check_runs` row, the `observations` row (value, hashes,
+ changed, condition_met, capped snapshot, `retained_until`) and the events.
+6. **Arm** `next_due_at` (the cadence plus jitter, or an exponential backoff after failures, capped
+ at `WATCH_MAX_BACKOFF_MS`).
+
+A condition that fires is recorded on the check run (`state: condition_met`, `triggers: 1`) and
+emits `watch.watch.triggered`; it fires again only for a value that is new or for a new run of
+holding, never once per check while nothing moves. **Actions are step 4 of the plan**: a fired
+condition records the trigger and emits the event, and nothing else is dispatched yet.
+
+Nothing is invented: a value the source did not state is `null`, a failed check is a recorded state,
+and the validators/body hash are stored only after a body was read successfully, so an unreadable
+body is fetched and parsed again next time.
+
+`check_runs.state` is one of `ok | not_modified | no_change | changed | condition_met | http_error |
+timeout | parse_error | rate_limited | budget_exceeded | skipped | disabled`; every check re-arms the
+watch on every terminal path.
+
+### Extraction (`watch.watch@1` `$defs/extraction`)
+
+| Kind | Reads |
+|---|---|
+| `text` | the whole body as text, trimmed (capped at 64 KiB) |
+| `html` | the body's text, or the text of the first element `selector` matches |
+| `css` | the same, with the small selector subset: `tag`, `.class`, `#id`, `[attr]`, `[attr=value]`, descendants and `>` |
+| `json` / `jsonpath` | the parsed body, or a carrier's mapped `{ items, latest, count }`; `value_path` picks a part (`latest.price`, `items.0.title`), `fields` several at once |
+| `regex` | the first match of `selector` (its first group when it has one), e.g. `(\d+\.\d+) EUR` |
+| `ai` | **not a check**: an AI reading is an action (step 10). The watch is accepted, the check is recorded `skipped` with `watch.extraction_unavailable`, and nothing is fetched |
+
+A path that matches nothing and a pattern that does not match extract `null` — which `absent` then
+answers truthfully.
+
+### Conditions (`watch.watch@1` `$defs/condition`)
+
+`changed` (the default), `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `contains`, `matches`, `exists`,
+`absent`; `for_sec` debounces a condition that must hold that long. Comparison refuses to guess: a
+value that is not a number (or a plain decimal string) is never "greater than" anything, and an
+unknown value is never low — the same rule Deals' watches use. `comparison.tolerance` (absolute)
+and `comparison.percent` decide whether a move counts as a change at all.
+
+### Observations
+
+Immutable rows (`watch.observation@1`): the extracted `value`, its SHA-256 `value_hash` over the
+canonical JSON, `previous_hash`, `changed`, `condition_met`, a capped `snapshot` of the body it came
+from, and `retained_until` (the watch's `retention.observations_days`, `WATCH_RETENTION_DEFAULT_DAYS`
+= 90 by default; `keep_snapshots: false` stores no snapshot). An hourly prune drops what is past its
+retention.
+
+## API (bearer tokens, one capability per route)
+
+Callers present an OpenVibe.Network token for audience `openvibe.watch`: a service's
+client-credentials token (judged by its `cap` claim with the contracts grant rule) or a signed-in
+person's or agent's token (`usr_…`, `agt_…` — a watch is theirs, and ownership scoping is the
+authority). A first-party service (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person;
+an app or module may not.
+
+| Capability | Routes |
+|---|---|
+| `watch.watch.read` | `GET /api/v1/watches?status=&project_id=&before=&limit=`, `GET /api/v1/watches/:id` |
+| `watch.watch.manage` | `POST /api/v1/watches`, `PATCH /api/v1/watches/:id`, `POST /api/v1/watches/:id/pause`, `POST /api/v1/watches/:id/resume`, `DELETE /api/v1/watches/:id` |
+| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |
+| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`) |
+
+The list answer is exactly `watch.watch-result@1` (`{ watches }`): page with
+`before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view,
+observation view and check-run view is validated against its released contract before it is sent.
+Errors are problem+json (`watch.not_found` 404, `watch.invalid` 422 with the validator's detail).
+
+### Per-actor limits
+
+Every route also limits the principal that passed its capability guard, before any work:
+[server/api/actor-limits.js](server/api/actor-limits.js), openvibe-sdk/limits. Past a limit: `429`
+problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and
+`watch_rate_limited_total{limit,window}`.
+
+| Routes | Per principal, a minute / an hour |
+|---|---|
+| Reads by an app or module (`app:…`, `mod:…`) | `WATCH_LIMITS_MINUTE` / `WATCH_LIMITS_HOUR` (120 / 3000) |
+| `POST`/`PATCH`/`DELETE /api/v1/watches[/:id]`, pause, resume | 30 / 600 |
+| `POST /api/v1/watches/:id/check` | 10 / 120 |
+
+A first-party service (`svc:…`) is not counted on reads: the public site and agent runtimes read for
+many people, and one budget for the whole service would refuse real people's work. Watch's own
+worker (the scheduler, the carriers, the outbox relay) runs in-process and never passes through
+HTTP. Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the home page.
+
+## Events (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set)
+
+- `watch.observation.recorded` (low) — a check recorded an observation, with the value as the source
+ stated it and its hash, never the raw body.
+- `watch.watch.triggered` (important) — a condition fired, with the observation that fired it. This
+ is what OpenVibe.Network turns into a `WATCH_TRIGGERED` notification for `payload.recipient`.
+- `watch.check.failed` (important) — a check ended in a failure state, with `consecutive_failures`
+ so a consumer can tell a blip from a watch that is down.
+
+All `visibility: internal`, subject `{ type: watch, id: <watch_id> }`, actor `service:watch`,
+envelopes validated as `events.event-envelope@1` and payloads against their released contracts. The
+watch-lifecycle events (`watch.watch.created`, `watch.watch.updated`, `watch.watch.paused`,
+`watch.watch.removed`) are emitted on the registry's writes; they have no payload contract yet.
+
+A watch owned by a service (`svc:…`) still records every observation and trigger in its own tables,
+but the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), so
+those two events are logged as not emitted rather than sent with a payload the contract refuses.
+
+## Security
+
+Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
+
+- **Auth.** Every `/api/v1` route needs a token for audience `openvibe.watch` with that route's ONE
+ capability (`svc:…` by capability grant, `usr_…`/`agt_…` by identity), then passes the per-actor
+ limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*`
+ loopback-only.
+- **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers
+ 404 on every route.
+- **Egress.** A watch fetches outside URLs by design, only through the guard:
+ [server/net/guard.js](server/net/guard.js), http(s) on `WATCH_ALLOWED_PORTS` (80/443), every
+ resolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one
+ deadline and a byte cap per response. Exactly one path reaches the network — the carriers, through
+ the fetcher — and only for a kind whose carrier exists. `test/carriers.test.js`.
+- **Secrets.** A source's credential is the **name** of a `WATCH_CRED_*` variable, read at fetch
+ time, sent only to the configured origin, never stored or logged. `OV_OAUTH_CLIENT_SECRET` and the
+ credentials live in `/etc/openvibe/watch.env` (0600).
+- **Data.** An observation records what the source stated, never a guess; snapshots are capped and
+ pruned by retention; a failed check never records an observation.
+
+## Owns
+
+- the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` /
+ `watch.watch-request@1` / `watch.watch-result@1` shapes it serves
+- checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff
+- `watch.*` events
+
+## Does not own
+
+- structured source ingestion for publication (OpenVibe.Sources' registry and adapters): Watch
+ observes what one person or agent asked for, not what a product publishes
+- expensive browser/code execution (OpenVibe.Run, step 6) and local/LAN probes (OpenVibe.Node,
+ step 7) — Watch asks them, they run
+- interpreting a trigger and acting on it (OpenVibe.Actor, OpenVibe.Codes, actions = step 4)
+- delivery of a notification (OpenVibe.Network consumes `watch.watch.triggered`)
+
+## Depends on
+
+- PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
+ `openvibe-sdk/db`
+- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,
+ the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`)
+- OpenVibe.Network (signing key; service principal `watch`)
+- OpenVibe.Events (outbound events)
+
+## Capabilities
+
+Implemented here (the service manifest's `capabilities`; routes in [API](#api-bearer-tokens-one-capability-per-route)):
+`watch.watch.read`, `watch.watch.manage`, `watch.observation.read` and `watch.check.run`, each
+checked by [server/auth.js](server/auth.js) with the contracts grant rule for audience
+`openvibe.watch`.
+
+Called elsewhere, as the service principal `watch` (client credentials from OpenVibe.Network):
+
+| Service | Grant | Why |
+|---|---|---|
+| OpenVibe.Events | `events.event.publish` (audience `openvibe.events`) | the outbox relays `watch.observation.recorded`, `watch.watch.triggered`, `watch.check.failed` and the lifecycle events |
+| OpenVibe.Run | `run.*` (plan T18 step 6) | not yet: a `run` source answers `watch.carrier_unavailable` today, and `WATCH_RUN_URL` (`http://127.0.0.1:4920`) is where it will call |
+
+## Acceptance (tests)
+
+- the release contract on both sides: bodies validated against `watch.watch-request@1`, every watch,
+ observation and check-run view against `watch.watch@1` / `watch.observation@1` /
+ `watch.check-run@1`, and every emitted payload against its released event contract
+ (`test/api.test.js`, `test/registry.test.js`, `test/check.test.js`)
+- conditional GET: validators stored, 304 → `not_modified`, identical body → `no_change`, a HEAD
+ answers `changed` without a body (`test/carriers.test.js`)
+- the guard refuses a private host before any socket, a redirect into a private address, a port
+ outside `WATCH_ALLOWED_PORTS`, URL credentials (`test/carriers.test.js`)
+- credentials by variable name, read at fetch time, never stored, missing → `disabled` with no
+ request (`test/carriers.test.js`)
+- feeds and mapped APIs, `items_path`/`fields`, and the parse errors they can raise
+ (`test/carriers.test.js`)
+- every condition op, tolerance and percent, `for_sec` debounce (`test/condition.test.js`); every
+ extraction kind, the selector subset, and `ai` refusing (`test/extract.test.js`)
+- a check writes its run and its observation and emits `watch.observation.recorded`; a fired
+ condition emits `watch.watch.triggered`; failures emit `watch.check.failed` and back off;
+ event/webhook/node/run sources and an `ai` extraction are never fetched; retention prune
+ (`test/check.test.js`)
+- due order, the concurrency cap, `not_before`, backoff, and the worker checking on its own tick
+ (`test/scheduler.test.js`)
+- the API: one capability per route, owner scoping 404, 422 from the validator, pause/resume/delete,
+ X-OV-Subject delegation, the check and observation reads, per-actor 429 (`test/api.test.js`)
+- the registry: required fields, cadence rules, forbidden headers, due selection, re-arm, soft
+ delete, the keyset page (`test/registry.test.js`)
+
+Not yet demonstrated: a check against a real site from a deployed service (nothing is deployed
+yet), the event/webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the
+actions (step 4) — each answers `watch.carrier_unavailable` and fetches nothing today.
+
+## Public host
+
+`openvibe.watch` is an internal service, like `sources.openvibe.network`: its vhost answers only
+`/`, `/api/health`, `/api/ready`, `/release.json` and `/robots.txt`, and keeps `/api/v1/*`
+loopback-only (`deploy/nginx/openvibe.watch.conf`). Install: copy the unit and the vhost, write
+`/etc/openvibe/watch.env` (0600) from `.env.example`, run
+`sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then
+`sudo ovhost deploy watch`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,
+openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable
+contact details are at `/.well-known/security.txt` on each site.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+{
+ "repository": "OpenVibers/OpenVibe.Watch",
+ "stage": "alpha",
+ "code": true,
+ "wave": "W18",
+ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026) — T18 Watch complete, steps 2-3",
+ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
+ "port": 4730,
+ "domain": "openvibe.watch (vhost in deploy/nginx: an internal-service page, health and JSON 404s; the API is loopback-only; README \"Public host\")",
+ "contracts": "openvibe-contracts v0.107.0",
+ "capabilities": [
+ "watch.watch.read",
+ "watch.watch.manage",
+ "watch.observation.read",
+ "watch.check.run"
+ ],
+ "capabilitiesRegistered": true,
+ "contractsUsed": [
+ "watch.watch@1, watch.watch-request@1, watch.watch-result@1",
+ "watch.observation@1, watch.check-run@1",
+ "watch.observation.recorded@1, watch.watch.triggered@1, watch.check.failed@1"
+ ],
+ "carriers": [
+ "http",
+ "feed",
+ "api"
+ ],
+ "carriersPending": [
+ "event (step 5)",
+ "webhook (step 5)",
+ "run (step 6)",
+ "node (step 7)"
+ ],
+ "seededWatches": 0,
+ "deployed": false,
+ "deployment": "Not deployed. Plan T18 steps 2-3 are implemented and tested in the repository (registry, check engine, http/feed/api carriers, conditional GET, extraction, conditions, observations, check runs, the API, the events); nothing runs on the host yet, and openvibe.watch has no DNS or vhost installed.",
+ "features": [
+ "watch registry with owner scoping (404 for another owner) validated against the released contracts",
+ "binding carrier preference order: webhook/event → ETag/Last-Modified → feed → API → Run",
+ "conditional GET: If-None-Match/If-Modified-Since, HEAD-first when only change is needed, 304 and unchanged-body short circuits",
+ "SSRF-guarded fetcher (ports, private hosts, every redirect hop, byte cap, one deadline) and per-host spacing",
+ "feed (RSS/Atom) and mapped-API carriers",
+ "extraction: text, html, css subset, json/jsonpath, regex; ai refused as a later step",
+ "conditions: changed/threshold with tolerance and percent, for_sec debounce over the observation trail",
+ "observations with value hashes, capped snapshots and retention",
+ "check runs with the full terminal-state set, exponential backoff and Retry-After",
+ "POST /api/v1/watches/:id/check plus the observations and checks reads",
+ "watch.* events through the outbox in the same transaction as the state change",
+ "per-actor limits (openvibe-sdk/limits)"
+ ],
+ "updated": "2026-10-07"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.watch — Nginx Configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.watch.conf — keep them in sync,
+## and reload nginx after changing it. Install: README "Public host".
+##
+## Watch is an internal service: it holds what people and agents watch and when their conditions
+## fired. The public host answers only:
+## / a short page saying what the service is (HTML for browsers, text otherwise)
+## /api/health, /api/ready, /release.json
+## /robots.txt disallow everything
+## and keeps the rest off the public edge:
+## /api/v1/* service-to-service API (bearer token, one capability per route): loopback
+## only. Services on this host call 127.0.0.1:4730 directly.
+## /internal/hooks/* webhook deliveries (plan T18 step 5): not exposed yet; when the carrier
+## lands it goes here with its own signature check and no /api/v1 reach.
+## /metrics 404 here; Prometheus text for direct loopback callers of 127.0.0.1:4730
+## anything else a JSON 404 that says why, so the name never falls through to another site
+## ═══════════════════════════════════════════════════════════════
+
+# ── HTTP → HTTPS redirect ──────────────────────────────────
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.watch;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.watch$request_uri; }
+}
+
+# ── HTTPS server ───────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.watch;
+
+ # The host has the openvibe.network wildcard certificate, not a per-name one.
+ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.watch.access.log;
+ error_log /var/log/nginx/openvibe.watch.error.log;
+
+ client_max_body_size 256k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header X-Robots-Tag "noindex, nofollow" always;
+
+ # Client address only from $remote_addr (realip has already replaced it with the visitor's
+ # address for Cloudflare's ranges); never append to or pass through a client-sent header.
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ location = /api/health { proxy_pass http://127.0.0.1:4730; }
+ location = /api/ready { proxy_pass http://127.0.0.1:4730; }
+ location = /release.json { proxy_pass http://127.0.0.1:4730; }
+
+ # Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics).
+ location = /metrics { return 404; }
+
+ location = / {
+ limit_except GET { deny all; }
+ proxy_pass http://127.0.0.1:4730;
+ }
+
+ location = /robots.txt {
+ default_type text/plain;
+ return 200 "User-agent: *\nDisallow: /\n";
+ }
+
+ # ── Service API: loopback only (see header) ─────────────
+ location ^~ /api/v1/ {
+ allow 127.0.0.1;
+ allow ::1;
+ deny all;
+ proxy_pass http://127.0.0.1:4730;
+ }
+
+ # ── Anything else: an honest JSON 404, never another site ──
+ location / {
+ default_type application/problem+json;
+ return 404 '{"type":"about:blank","title":"Not Found","status":404,"code":"watch.not_found","detail":"openvibe.watch is an internal service of the OpenVibe network with no public pages or API. Its API is for OpenVibe services and their signed-in people on the host. About: https://openvibe.watch/ and https://github.com/OpenVibers/OpenVibe.Watch"}';
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+[Unit]
+Description=OpenVibe.Watch — persistent user-defined observations and conditions (pages, feeds, APIs)
+# Network (openvibe-network) signs the tokens this service verifies, and Events carries watch.*
+# events. Watch starts without either: the SDK's JWKS client keeps the last good keys and backs
+# off (/api/ready degrades while no key has loaded), and the outbox waits for the relay.
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.watch
+EnvironmentFile=/etc/openvibe/watch.env
+Environment=NODE_ENV=production
+Environment=PORT=4730
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.watch/server/index.js
+Restart=always
+RestartSec=5
+# SIGTERM: the scheduler stops starting checks; checks in flight finish (each is bounded by the
+# fetch timeout, and the graceful drain gives them 15 s).
+KillSignal=SIGTERM
+TimeoutStopSec=25
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# The code tree stays read-only; watches, checks and observations live in PostgreSQL (ADR-035).
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+-- phase: expand
+-- OpenVibe.Watch on PostgreSQL (ADR-035). Additive only; never edited after it runs.
+--
+-- The whole model on purpose (plan T18 step 2): the watch registry, where each watch's conditional
+-- fetch state lives, what every check recorded as a run and as an observation, and the outbox that
+-- carries watch.observation.recorded / watch.watch.triggered / watch.check.failed. Later steps of
+-- T18 (actions, event and webhook carriers, Run) add code, not tables.
+
+CREATE TABLE watches (
+ id text COLLATE "C" PRIMARY KEY, -- wch_<ULID>
+ project_id text COLLATE "C",
+ owner_sub text COLLATE "C" NOT NULL, -- usr_… / agt_… / svc:…
+ name text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','disabled','failed')),
+ source jsonb NOT NULL, -- watch.watch@1 source (validated in code against the contract)
+ cadence jsonb, -- {every_sec,jitter_sec,timezone}; NULL for event/webhook
+ extraction jsonb NOT NULL DEFAULT '{}',
+ comparison jsonb,
+ condition jsonb NOT NULL DEFAULT '{}',
+ action jsonb NOT NULL DEFAULT '[]',
+ budget jsonb NOT NULL DEFAULT '{}',
+ retention jsonb NOT NULL DEFAULT '{}',
+ labels jsonb NOT NULL DEFAULT '{}',
+ -- runtime state
+ next_due_at bigint NOT NULL DEFAULT 0,
+ not_before bigint NOT NULL DEFAULT 0, -- Retry-After / rate-limit wait
+ last_check_at bigint,
+ last_success_at bigint,
+ last_state text COLLATE "C",
+ consecutive_failures bigint NOT NULL DEFAULT 0,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ updated_by text COLLATE "C",
+ deleted_at bigint
+);
+CREATE INDEX idx_watches_due ON watches (next_due_at) WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL;
+CREATE INDEX idx_watches_owner ON watches (owner_sub, created_at DESC);
+
+CREATE TABLE watch_endpoint_state (
+ watch_id text COLLATE "C" NOT NULL,
+ url text COLLATE "C" NOT NULL,
+ etag text COLLATE "C",
+ last_modified text COLLATE "C",
+ last_status bigint,
+ last_fetch_at bigint,
+ last_body_hash text COLLATE "C",
+ PRIMARY KEY (watch_id, url)
+);
+
+CREATE TABLE check_runs (
+ rid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id text COLLATE "C" NOT NULL UNIQUE, -- ckr_<ULID>
+ watch_id text COLLATE "C" NOT NULL,
+ trigger text COLLATE "C" NOT NULL CHECK (trigger IN ('schedule','manual','event','webhook')),
+ carrier text COLLATE "C", -- event|webhook|http|feed|api|node|run
+ started_at bigint NOT NULL,
+ finished_at bigint NOT NULL,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('ok','not_modified','no_change','changed','condition_met','http_error','timeout','parse_error','rate_limited','budget_exceeded','skipped','disabled')),
+ http_status bigint,
+ error_code text COLLATE "C",
+ detail text COLLATE "C",
+ bytes bigint,
+ raw_body_hash text COLLATE "C",
+ cost_usd numeric(12,6) NOT NULL DEFAULT 0,
+ observations bigint NOT NULL DEFAULT 0,
+ triggers bigint NOT NULL DEFAULT 0
+);
+CREATE INDEX idx_check_runs_watch ON check_runs (watch_id, rid DESC);
+
+CREATE TABLE observations (
+ rid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id text COLLATE "C" NOT NULL UNIQUE, -- wco_<ULID>
+ watch_id text COLLATE "C" NOT NULL,
+ check_run_id text COLLATE "C" NOT NULL,
+ observed_at bigint NOT NULL,
+ value jsonb NOT NULL,
+ value_hash text COLLATE "C" NOT NULL,
+ previous_hash text COLLATE "C",
+ changed bigint NOT NULL DEFAULT 0,
+ condition_met bigint NOT NULL DEFAULT 0,
+ snapshot text COLLATE "C",
+ retained_until bigint
+);
+CREATE INDEX idx_observations_watch ON observations (watch_id, rid DESC);
+CREATE INDEX idx_observations_retain ON observations (retained_until);
+
+CREATE TABLE counters ( name text COLLATE "C" PRIMARY KEY, value bigint NOT NULL );
+
+-- server/events/outbox.js ensureSchema() (identical to OpenVibe.Sources)
+CREATE TABLE event_outbox (
+ seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text COLLATE "C" NOT NULL UNIQUE, event_type text COLLATE "C" NOT NULL,
+ envelope text COLLATE "C" NOT NULL, created_at bigint NOT NULL,
+ attempts bigint NOT NULL DEFAULT 0, next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint, rejected_at bigint, last_error text COLLATE "C"
+);
+CREATE INDEX idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1287 @@
@@ -0,0 +1,1287 @@
+{
+ "name": "openvibe-watch",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-watch",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "fast-xml-parser": "^4.5.3",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "ajv": "^8.20.0",
+ "ajv-formats": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/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/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/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/fast-xml-parser": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz",
+ "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^1.0.5"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.107.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
+ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "2.11.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/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/strnum": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
+ "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+{
+ "name": "openvibe-watch",
+ "version": "0.1.0",
+ "private": true,
+ "main": "server/index.js",
+ "description": "OpenVibe.Watch: persistent user-defined observations and conditions over pages, feeds, APIs, events and devices.",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "WATCH_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe",
+ "watch",
+ "monitoring",
+ "observations",
+ "conditions"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Watch",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "fast-xml-parser": "^4.5.3",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "ajv": "^8.20.0",
+ "ajv-formats": "^3.0.1",
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * Per-actor rate limits on /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * Every API route takes a token and one capability (auth.js); the limits count requests by the
+ * principal that passed that guard (req.principal.sub: svc:watch, a person's usr_…, an app:app_…),
+ * before the route does any work. Watch's own worker (the scheduler, the carriers, the outbox
+ * relay) runs in this process and never goes through HTTP, so nothing here can slow a check.
+ *
+ * Reads (watch.watch.read, watch.observation.read) take WATCH_LIMITS_MINUTE / WATCH_LIMITS_HOUR,
+ * 120 and 3000, for an app or module (app:…, mod:…). A first-party service (svc:…) is not counted
+ * on reads: the public site and an agent's runtime read for many people, and one budget for the
+ * whole service would refuse real people's work.
+ *
+ * Writes (watch.watch.manage) and manual checks (watch.check.run) are counted for every principal
+ * with the budgets below: a person creating watches from a page, or a service nudging a check.
+ *
+ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After; the refusal is
+ * logged once and counted in watch_rate_limited_total{limit,window}. Counters live in this process:
+ * a restart forgets them. Never limited: /api/health, /api/ready, /release.json, /metrics and the
+ * home page.
+ */
+const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+
+const FIRST_PARTY = /^svc:/;
+
+function actor(req) {
+ const p = req.principal;
+ if (p && typeof p.sub === 'string' && p.sub) return p.sub;
+ return defaultActor(req);
+}
+
+/** Counted on reads: anything but a first-party service (an app or module acting as itself). */
+function countedRead(req) {
+ const p = req.principal;
+ return !(p && FIRST_PARTY.test(String(p.sub)));
+}
+
+/** The writes, each with its numbers per principal (a minute, an hour). */
+const BUDGETS = {
+ // Creating, changing, pausing or deleting a watch is a deliberate act by a person or a service
+ // acting for one: a form every few seconds at most.
+ 'watch.write': { minute: 30, hour: 600 },
+ // A manual check spends the watched site's bandwidth now (the per-host spacing still applies),
+ // so it is rarer than a write and always bounded by the watch's own cadence.
+ 'watch.check.run': { minute: 10, hour: 120 },
+};
+
+/**
+ * limits(name, own) middleware, plus limits.reads(name) (the defaults on a counted read) and
+ * limits.budget(name) (one of BUDGETS).
+ */
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'watch_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const limiter = createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor,
+ now,
+ onLimited(e) {
+ // The actor is a principal (or an address), never a token.
+ log.warn(`[limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name) => {
+ const limit = limiter(name);
+ return function actorReadLimit(req, res, next) { return countedRead(req) ? limit(req, res, next) : next(); };
+ };
+ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
+ limiter.budget = (name) => {
+ const m = budgets.get(name);
+ if (!m) throw new Error(`limits: no budget named ${name}`);
+ return m;
+ };
+ return limiter;
+}
+
+module.exports = { createActorLimits, actor, countedRead, BUDGETS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Checks, observations and check runs (one capability per route).
+ *
+ * watch.check.run
+ * POST /api/v1/watches/:id/check run one check now, outside the cadence
+ * watch.observation.read
+ * GET /api/v1/watches/:id/observations?before=&limit= newest first, cursor by rid
+ * GET /api/v1/watches/:id/checks?before=&limit= the check runs, newest first
+ *
+ * A manual check of a watch that is paused, disabled or failed is refused with 409: it is recorded
+ * as `disabled` in the check runs, and nothing is fetched. A source whose carrier is a later step
+ * (event, webhook, node, run) answers 422 watch.carrier_unavailable — also without fetching.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { CAPS } = require('../auth');
+
+function checksRouter({ registry, check, observations, auth, limits }) {
+ const router = express.Router();
+ const read = [auth.requireCap(CAPS.observations), limits.reads('watch.read')];
+ const runNow = [auth.requireCap(CAPS.run), limits.budget('watch.check.run')];
+
+ const notFound = (res, req) => http.sendProblem(res, 404, 'watch.not_found', { detail: 'no such watch', ctx: req.ov });
+ const before = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
+ const pageLimit = (q, d = 50, max = 200) => Math.min(Math.max(parseInt(q, 10) || d, 1), max);
+
+ /** The watch must exist and belong to the acting subject before anything else happens. */
+ const owned = async (req, res) => {
+ const row = await registry.get(req.params.id, req.principal);
+ if (!row) { notFound(res, req); return null; }
+ return row;
+ };
+
+ router.post('/api/v1/watches/:id/check', runNow, async (req, res) => {
+ const row = await owned(req, res);
+ if (!row) return;
+ const out = await check.run(row.id, { trigger: 'manual' });
+ if (!out) return notFound(res, req);
+ if (out.busy) return http.sendProblem(res, 409, 'watch.busy', { detail: 'a check of this watch is already running', ctx: req.ov });
+ if (out.disabled) return http.sendProblem(res, 409, 'watch.disabled', { detail: `the watch is ${row.status}: resume it first`, ctx: req.ov });
+ if (out.unavailable) return http.sendProblem(res, 422, out.unavailable.code, { detail: out.unavailable.detail, ctx: req.ov });
+ return res.json(check.runView(out.run));
+ });
+
+ router.get('/api/v1/watches/:id/observations', read, async (req, res) => {
+ const row = await owned(req, res);
+ if (!row) return;
+ const limit = pageLimit(req.query.limit);
+ const rows = await observations.list(row.id, { before: before(req.query.before), limit });
+ res.json({
+ observations: rows.map(observations.view),
+ next_before: rows.length === limit ? rows[rows.length - 1].rid : null,
+ });
+ });
+
+ router.get('/api/v1/watches/:id/checks', read, async (req, res) => {
+ const row = await owned(req, res);
+ if (!row) return;
+ const limit = pageLimit(req.query.limit);
+ const rows = await check.list(row.id, { before: before(req.query.before), limit });
+ res.json({
+ checks: rows.map(check.runView),
+ next_before: rows.length === limit ? rows[rows.length - 1].rid : null,
+ });
+ });
+
+ return router;
+}
+
+module.exports = { checksRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * The watch registry API (one capability per route; every answer is scoped to the acting subject).
+ *
+ * watch.watch.read
+ * GET /api/v1/watches?status=&project_id=&before=&limit= the acting subject's watches
+ * GET /api/v1/watches/:id
+ * watch.watch.manage
+ * POST /api/v1/watches create (source, extraction, condition and action required)
+ * PATCH /api/v1/watches/:id change fields; pause/resume through status
+ * POST /api/v1/watches/:id/pause status = paused
+ * POST /api/v1/watches/:id/resume status = active (and re-armed)
+ * DELETE /api/v1/watches/:id soft delete (observations stay resolvable)
+ *
+ * Another owner's watch is 404, never 403: an id must not confirm that someone else's watch exists.
+ * Errors are problem+json (http.sendProblem): watch.not_found, watch.invalid (422 from the released
+ * watch.watch-request@1 validator), watch.exists (409, never used by this route shaping but kept
+ * for the same shape as Sources), and the per-actor limit's 429.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { CAPS } = require('../auth');
+const { RegistryError, STATUSES } = require('../registry');
+
+function watchesRouter({ registry, auth, limits }) {
+ const router = express.Router();
+ const read = [auth.requireCap(CAPS.read), limits.reads('watch.read')];
+ const manage = [auth.requireCap(CAPS.manage), limits.budget('watch.write')];
+
+ const fail = (res, req, err) => {
+ if (err instanceof RegistryError) {
+ const status = err.code === 'watch.internal' ? 500 : (err.code === 'watch.exists' ? 409 : 422);
+ return http.sendProblem(res, status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ throw err;
+ };
+ const notFound = (res, req) => http.sendProblem(res, 404, 'watch.not_found', { detail: 'no such watch', ctx: req.ov });
+
+ router.get('/api/v1/watches', read, async (req, res) => {
+ const status = req.query.status ? String(req.query.status) : null;
+ if (status && !STATUSES.includes(status)) {
+ return http.sendProblem(res, 400, 'watch.bad_query', { detail: `status must be one of ${STATUSES.join('|')}`, ctx: req.ov });
+ }
+ const before = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
+ const rows = await registry.all(req.principal, {
+ status,
+ project_id: req.query.project_id ? String(req.query.project_id) : null,
+ before,
+ limit,
+ });
+ // The answer is exactly watch.watch-result@1 ("{ watches }"), so the page cursor is the last
+ // watch's own id: `GET /api/v1/watches?before=<that id>` is the next page (wch_ ids are ULIDs,
+ // i.e. creation order).
+ res.json({ watches: rows.map(registry.view) });
+ });
+
+ router.get('/api/v1/watches/:id', read, async (req, res) => {
+ const row = await registry.get(req.params.id, req.principal);
+ if (!row) return notFound(res, req);
+ res.json({ watch: registry.view(row) });
+ });
+
+ router.post('/api/v1/watches', manage, async (req, res) => {
+ try {
+ const row = await registry.create(req.body, req.principal);
+ res.status(201).json({ watch: registry.view(row) });
+ } catch (err) { fail(res, req, err); }
+ });
+
+ router.patch('/api/v1/watches/:id', manage, async (req, res) => {
+ try {
+ const row = await registry.patch(req.params.id, req.body, req.principal);
+ if (!row) return notFound(res, req);
+ res.json({ watch: registry.view(row) });
+ } catch (err) { fail(res, req, err); }
+ });
+
+ /** pause/resume are the two status changes a person makes by name. */
+ const setStatus = (status) => async (req, res) => {
+ try {
+ const row = await registry.patch(req.params.id, { status }, req.principal);
+ if (!row) return notFound(res, req);
+ res.json({ id: row.id, status: row.status });
+ } catch (err) { fail(res, req, err); }
+ };
+ router.post('/api/v1/watches/:id/pause', manage, setStatus('paused'));
+ router.post('/api/v1/watches/:id/resume', manage, setStatus('active'));
+
+ router.delete('/api/v1/watches/:id', manage, async (req, res) => {
+ try {
+ const out = await registry.remove(req.params.id, req.principal);
+ if (!out) return notFound(res, req);
+ res.json(out);
+ } catch (err) { fail(res, req, err); }
+ });
+
+ return router;
+}
+
+module.exports = { watchesRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/** Express app: request context, the v1 API, health/readiness, metrics. */
+const path = require('path');
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { instrument } = require('openvibe-shared/metrics');
+const { createRelease } = require('openvibe-shared/release');
+const { createWatchReadiness, registerWatchGauges } = require('./observability');
+const { watchesRouter } = require('./api/watches');
+const { checksRouter } = require('./api/checks');
+const { createActorLimits } = require('./api/actor-limits');
+const pkg = require('../package.json');
+
+const TEXT_INDEX = [
+ 'OpenVibe.Watch: persistent user-defined observations and conditions over pages, feeds and APIs.',
+ 'A watch states what it observes (source), how often or what pushes to it (cadence / event pattern),',
+ 'what value it takes (extraction), how that value is compared and when the condition fires, and what',
+ 'it wakes. A watch never polls when an event or a webhook will do, and it never fabricates a value:',
+ 'a check that failed is recorded as failed, and an absent value stays absent.',
+ '',
+ 'This is an internal service. Its API is for OpenVibe services and their signed-in people on the',
+ 'production host (bearer tokens); openvibe.watch answers only this page, /api/health, /api/ready and',
+ '/release.json.',
+ '',
+ 'GET /api/v1/watches, /api/v1/watches/:id (watch.watch.read)',
+ 'POST/PATCH/DELETE /api/v1/watches[/:id], POST /api/v1/watches/:id/pause, /resume (watch.watch.manage)',
+ 'GET /api/v1/watches/:id/observations, /api/v1/watches/:id/checks (watch.observation.read)',
+ 'POST /api/v1/watches/:id/check (watch.check.run)',
+ 'GET /api/health, /api/ready, /release.json',
+ '',
+ 'Source: https://github.com/OpenVibers/OpenVibe.Watch',
+ '',
+].join('\n');
+
+const HOME_HTML = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<meta name="color-scheme" content="light dark">
+<title>OpenVibe.Watch</title>
+<style>
+:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --accent: #2456d6; }
+@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --accent: #7aa2ff; } }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
+main { max-width: 640px; margin: 0 auto; padding: 32px 16px; }
+h1 { font-size: 1.4rem; margin: 0 0 12px; }
+p { margin: 0 0 12px; }
+.muted { color: var(--muted); font-size: .9rem; }
+a { color: var(--accent); }
+</style>
+</head>
+<body>
+<main>
+<h1>OpenVibe.Watch</h1>
+<p>This is an internal service of the OpenVibe network, not a website. It keeps the watches people and
+agents define — an observation over a page, a feed or an API, with a condition on it — and tells them,
+or their Actors and Runners, when it changes or fires.</p>
+<p>A watch prefers the cheapest way to know: an event or a webhook when one is delivered, then the
+site's own ETag/Last-Modified, then a feed, then an API, and only then an expensive browser check.
+A failed check is recorded as a failure; a value the source did not state stays absent.</p>
+<p class="muted">Health: <a href="/api/health">/api/health</a> · Readiness: <a href="/api/ready">/api/ready</a> ·
+Source code: <a href="https://github.com/OpenVibers/OpenVibe.Watch">OpenVibers/OpenVibe.Watch</a></p>
+</main>
+</body>
+</html>
+`;
+
+function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null }) {
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', 'loopback');
+ const release = createRelease({ service: 'watch', root: path.join(__dirname, '..') });
+ // HTTP golden signals by route template, process metrics, release_info and the Watch gauges;
+ // GET /metrics answers direct loopback callers only (Track O).
+ const metrics = instrument(app, { service: 'watch', release: release.release });
+ registerWatchGauges(metrics.registry, { db, watches: registry, check, observations, outbox, now });
+ app.use(http.middleware());
+ // One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
+ require('openvibe-shared/trace').install(app);
+ app.use((req, res, next) => {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Cache-Control', 'no-store');
+ next();
+ });
+ app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
+
+ app.get('/api/health', (_req, res) => {
+ res.json({ status: 'ok', service: 'openvibe-watch', version: pkg.version });
+ });
+
+ // Readiness (openvibe-shared/ready): 503 only when the database fails; the Network key and the
+ // check worker (running, queue keeping up) are optional and degrade it (see observability.js).
+ const readiness = createWatchReadiness({ db, config, registry, check, observations, scheduler, outbox, relay, now, release: release.release });
+ app.get('/api/ready', readiness.handler);
+ // GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
+ release.mount(app, { registry: metrics.registry });
+
+ // Per-actor limits (api/actor-limits.js), counted by the principal once a route's capability guard
+ // passed. limitsNow: the limiter's clock (tests; default the wall clock, not the check clock).
+ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
+ app.use(watchesRouter({ registry, auth, limits }));
+ app.use(checksRouter({ registry, check, observations, auth, limits }));
+
+ // The public host (openvibe.watch) shows only this page, health, readiness and /release.json:
+ // Watch is internal. Browsers get a short honest HTML page, other clients the text route index.
+ // Never cached, never indexed.
+ app.get('/', (req, res) => {
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+ res.setHeader('Vary', 'Accept');
+ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {
+ // The page runs no script of its own. Cloudflare Web Analytics: Cloudflare injects its beacon at the
+ // edge and the privacy text says it may measure performance; script-src loads it, connect-src is
+ // where it reports.
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src https://static.cloudflareinsights.com; connect-src https://cloudflareinsights.com; base-uri 'none'; frame-ancestors 'none'");
+ return res.type('html').send(HOME_HTML);
+ }
+ return res.type('text/plain').send(TEXT_INDEX);
+ });
+
+ app.use((req, res) => http.sendProblem(res, 404, 'watch.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, 'watch.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });
+ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'watch.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, 'watch.internal', { detail: 'internal error', ctx: req.ov });
+ });
+
+ app.locals.metrics = metrics;
+ return app;
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+/**
+ * Authentication and capability checks. Watch serves two kinds of caller, and every API route is
+ * guarded by exactly ONE capability (server/api/*.js):
+ *
+ * a service an RS256 client-credentials token from OpenVibe.Network (audience openvibe.watch,
+ * sub svc:… / app:… / mod:…), verified offline against Network's JWKS
+ * (openvibe-sdk/auth, one client per URL, kept fresh and served through an outage).
+ * Its `cap` claim is judged by openvibe-contracts' own grant rule (an exact id or a
+ * `family.*` grant; capability.unknown for an id the release does not know). The
+ * watch.* capabilities are released, so the library decides every one of them.
+ *
+ * a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified
+ * with openvibe-sdk/auth verifyUserToken for the same issuer and audience. Such a
+ * token carries no capability list: watches are user-defined resources and the guard
+ * that matters is ownership — every route scopes to the acting subject, and another
+ * owner's watch answers 404 (registry.js). A person may therefore use all four watch
+ * capabilities, on their own watches only.
+ *
+ * X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site
+ * going through Network/Services, or an agent's runtime), names the subject a route acts for; the
+ * watch it creates is owned by that person and its reads are scoped to them. An app:… or mod:…
+ * principal is a third party and may never name one, exactly as in OpenVibe.Deals.
+ */
+const { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const { jwksClient, verifyUserToken } = require('openvibe-sdk/auth');
+
+const CAPS = Object.freeze({
+ read: 'watch.watch.read',
+ manage: 'watch.watch.manage',
+ observations: 'watch.observation.read',
+ run: 'watch.check.run',
+});
+
+/** Service-side principals: their token carries `cap` and is judged by the capability rule. */
+const SERVICE_SUB = /^(svc|app|mod):/;
+/** A person (usr_…) or an agent (agt_…) subject: the token is the identity, ownership is the authority. */
+const PERSON_SUB = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function checkCapability(claims, capabilityId) {
+ return capabilities.check(claims, capabilityId);
+}
+
+/** Payload of an (unverified) token, used only to pick the verifier — never to decide anything. */
+function tokenPayload(token) {
+ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function createAuth({ config, log = console }) {
+ const headerKid = (token) => { try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; } };
+
+ /** { ok, claims } or { ok: false, code, reason }. The SDK's error names the internal JWKS URL: logged, never answered. */
+ async function verifyService(token) {
+ const kid = headerKid(token);
+ let keys;
+ try { keys = await jwksClient(config.jwksUrl, { log }).keysForKid(kid); } catch (err) {
+ log.error(`[auth] Network keys unavailable: ${(err && err.message) || err}`);
+ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
+ }
+ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];
+ let last = { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
+ for (const k of byKid.length ? byKid : keys) {
+ last = serviceAuth.verifyServiceToken(token, { publicKey: k.key, issuer: config.issuer, audience: config.audience });
+ if (last.ok || last.code !== 'token.bad_signature') return last;
+ }
+ return last;
+ }
+
+ /** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */
+ async function verifyUser(token) {
+ try {
+ const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience: config.audience });
+ return { ok: true, claims };
+ } catch (err) {
+ const code = (err && err.code) || 'token.malformed';
+ const status = Number(err && err.status) === 503 || code === 'token.no_key' ? 503 : 401;
+ return { ok: false, code: status === 503 ? 'token.unavailable' : code, reason: status === 503 ? 'signing key not loaded yet' : ((err && err.message) || 'token rejected'), status };
+ }
+ }
+
+ /**
+ * Express guard: one capability. Sets req.principal = { sub, kind, role, cap, jti, subject }.
+ * `kind` is 'service' or 'person'; `subject` is the person a trusted service acts for (else null).
+ */
+ function requireCap(id) {
+ return async function capGuard(req, res, next) {
+ const ctx = req.ov;
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });
+ const token = h.slice(7).trim();
+ const payload = tokenPayload(token);
+ if (!payload || typeof payload.sub !== 'string') return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });
+
+ if (SERVICE_SUB.test(payload.sub)) {
+ // The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a
+ // Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'
+ // verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).
+ let r;
+ try { r = await verifyService(token); } catch (err) { return next(err); }
+ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
+ const claims = r.claims;
+ const c = checkCapability(claims, id);
+ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
+ req.principal = { sub: claims.sub, kind: 'service', role: null, cap: claims.cap, jti: claims.jti, subject: null };
+ } else {
+ // A person or an agent: usr_…/agt_… only. verifyUserToken refuses a service principal, a typed token
+ // (typ/purpose) and every rule this service does not serve.
+ const r = await verifyUser(token);
+ if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });
+ const claims = r.claims;
+ req.principal = { sub: claims.sub, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };
+ }
+
+ // X-OV-Subject: only a first-party service may name the person it acts for, and only a
+ // usr_…/agt_… subject; the watch is then owned by and scoped to that subject.
+ const named = req.get('x-ov-subject');
+ if (named) {
+ if (req.principal.kind !== 'service' || !/^svc:/.test(req.principal.sub)) {
+ return http.sendProblem(res, 403, 'watch.subject_not_delegated', { detail: 'only a first-party service token may name the person it acts for (X-OV-Subject)', ctx });
+ }
+ if (!PERSON_SUB.test(String(named))) return http.sendProblem(res, 400, 'watch.subject_invalid', { detail: 'X-OV-Subject must be a usr_… or agt_… subject id', ctx });
+ req.principal.subject = String(named);
+ }
+ return next();
+ };
+ }
+
+ return { requireCap, verifyService, verifyUser };
+}
+
+/** The subject a route acts for: the person a trusted service names, else the caller itself. */
+function ownerOf(principal) {
+ return (principal && (principal.subject || principal.sub)) || null;
+}
+
+module.exports = { CAPS, createAuth, checkCapability, ownerOf, SERVICE_SUB, PERSON_SUB };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * The api carrier: an official JSON (or XML) endpoint, mapped by the watch itself — no code per
+ * provider (OpenVibe.Sources/server/adapters/api.js is the same idea).
+ *
+ * source.items_path dot path to the records ('' or absent: the body is the array)
+ * source.fields { name: path } → each record becomes { name: value, … }
+ *
+ * With a mapping, extraction reads the mapped records: `value_path: 'latest.price'` (the document
+ * is { items, latest, count }) or `extraction.fields` for several values at once. Without one, the
+ * parsed body IS the document and extraction.json / extraction.jsonpath reads it directly — the
+ * `format: 'json'` case of plan T18 step 3.
+ *
+ * Fetching is conditional (see carriers/http.js); a 304 or an unchanged body records no observation.
+ */
+const { parseXml, decodeBody, ParseError } = require('../util');
+const { at } = require('../extract');
+const { conditionalGet, onlyChange, ACCEPT } = require('./http');
+const { mapFields } = require('./feed');
+
+const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;
+
+/** '' means "the body itself is the array"; anything else must be a dotted path. */
+function checkItemsPath(p) {
+ return p === undefined || p === '' || PATH_RE.test(String(p).replace(/\[(\d+)\]/g, '.$1').replace(/^\./, ''));
+}
+
+function mapped(document, itemsPath) {
+ if (itemsPath === undefined || itemsPath === '') return document;
+ return at(document, itemsPath);
+}
+
+function createApiCarrier({ fetcher, config }) {
+ return {
+ name: 'api',
+ run: async ({ watch, state }) => {
+ const source = watch.source;
+ const out = await conditionalGet({
+ source, state, fetcher, secrets: config.secrets, accept: ACCEPT.api,
+ onlyChangeNeeded: onlyChange(watch),
+ });
+ if (out.state !== 'ok' || !out.body) return out;
+ const format = source.format || 'json';
+ let body;
+ try {
+ if (format === 'xml') body = parseXml(decodeBody(out.body, out.content_type));
+ else {
+ try { body = JSON.parse(decodeBody(out.body, out.content_type)); }
+ catch { throw new ParseError('the body is not valid JSON'); }
+ }
+ } catch (err) {
+ return { ...out, state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'carrier_error', detail: String(err.message).slice(0, 500), body: null };
+ }
+ const hasMapping = Boolean(source.items_path) || Boolean(source.fields && Object.keys(source.fields).length);
+ if (!hasMapping) return { ...out, document: body };
+ if (!checkItemsPath(source.items_path)) {
+ return { ...out, state: 'parse_error', error_code: 'items_path', detail: `items_path "${source.items_path}" is malformed`, body: null };
+ }
+ const list = mapped(body, source.items_path);
+ if (!Array.isArray(list)) {
+ return { ...out, state: 'parse_error', error_code: 'items_path', detail: `items_path "${source.items_path || ''}" is not an array in the response`, body: null };
+ }
+ const items = mapFields(list.filter((r) => r && typeof r === 'object'), source.fields);
+ return {
+ ...out,
+ document: { format, items, latest: items[0] || null, count: items.length },
+ };
+ },
+ };
+}
+
+module.exports = { createApiCarrier, checkItemsPath };
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+/**
+ * The feed carrier: RSS 2.0, RSS 1.0 (RDF) and Atom 1.0, parsed with fast-xml-parser (the reader is
+ * OpenVibe.Sources/server/adapters/feed.js, which is where feeds are read for the network).
+ *
+ * The feed's own order is kept, and `latest` is its first entry — a watch over "the latest release"
+ * reads `latest.title`, `latest.link` or `latest.published_at` through extraction.value_path, or
+ * maps several values at once with extraction.fields. An entry the feed gave no identity for is
+ * skipped: it is never given a made-up identity.
+ *
+ * Fetching is conditional (see carriers/http.js): ETag/Last-Modified first, `not_modified` and
+ * `no_change` cost no observation.
+ */
+const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');
+const { at } = require('../extract');
+const { conditionalGet, onlyChange, ACCEPT } = require('./http');
+
+function atomLink(entry, base) {
+ const links = asArray(entry.link);
+ const alt = links.find(l => typeof l === 'object' && (!l['@_rel'] || l['@_rel'] === 'alternate') && l['@_href'])
+ || links.find(l => typeof l === 'object' && l['@_href'])
+ || links.find(l => typeof l === 'string');
+ if (!alt) return null;
+ return canonicalUrl(typeof alt === 'string' ? alt : alt['@_href'], base);
+}
+
+function namesOf(list) {
+ return asArray(list).map((a) => {
+ if (a == null) return null;
+ if (typeof a === 'object') return toText(a.name ?? a['#text'] ?? a.__cdata, 200);
+ return toText(a, 200);
+ }).filter(Boolean).slice(0, 20);
+}
+
+function termsOf(list) {
+ return asArray(list).map((c) => {
+ if (c == null) return null;
+ if (typeof c === 'object') return toText(c['@_term'] ?? c['#text'] ?? c.__cdata, 100);
+ return toText(c, 100);
+ }).filter(Boolean).slice(0, 30);
+}
+
+function rssItem(it, base, summaryMax) {
+ const guidNode = Array.isArray(it.guid) ? it.guid[0] : it.guid;
+ const guid = toId(guidNode);
+ const link = canonicalUrl(pick(it, 'link'), base);
+ const guidIsPermalink = !(guidNode && typeof guidNode === 'object' && guidNode['@_isPermaLink'] === 'false');
+ const url = link || (guid && guidIsPermalink ? canonicalUrl(guid) : null);
+ const identity = guid || url;
+ if (!identity) return null;
+ const enclosure = asArray(it.enclosure).find(e => e && typeof e === 'object' && e['@_url']);
+ return {
+ identity,
+ kind: 'entry',
+ title: toText(pick(it, 'title'), 500),
+ link: url,
+ summary: toText(pick(it, 'description', 'content:encoded'), summaryMax),
+ authors: namesOf(it['dc:creator'] ?? it.author),
+ published_at: toIsoDate(pick(it, 'pubDate', 'dc:date')),
+ updated_at: toIsoDate(pick(it, 'atom:updated', 'dc:modified')),
+ categories: termsOf(it.category ?? it['dc:subject']),
+ enclosure: enclosure ? { url: canonicalUrl(enclosure['@_url'], base), type: toId(enclosure['@_type'], 100) } : null,
+ };
+}
+
+function atomEntry(e, base, summaryMax) {
+ const id = toId(pick(e, 'id'));
+ const url = atomLink(e, base);
+ const identity = id || url;
+ if (!identity) return null;
+ return {
+ identity,
+ kind: 'entry',
+ title: toText(pick(e, 'title'), 500),
+ link: url,
+ summary: toText(pick(e, 'summary', 'content'), summaryMax),
+ authors: namesOf(e.author),
+ published_at: toIsoDate(pick(e, 'published', 'issued')),
+ updated_at: toIsoDate(pick(e, 'updated', 'modified')),
+ categories: termsOf(e.category),
+ enclosure: null,
+ };
+}
+
+/** parseFeed(text, baseUrl, { summaryMax, maxItems }) → { format, title, link, items, skipped }. */
+function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {
+ const doc = parseXml(text);
+ let raw = [];
+ let meta = {};
+ let make;
+ if (doc.rss && doc.rss.channel) {
+ const ch = Array.isArray(doc.rss.channel) ? doc.rss.channel[0] : doc.rss.channel;
+ raw = asArray(ch.item);
+ meta = { format: 'rss2', title: toText(pick(ch, 'title'), 300), link: canonicalUrl(pick(ch, 'link'), url) };
+ make = (it) => rssItem(it, url, summaryMax);
+ } else if (doc['rdf:RDF']) {
+ const rdf = doc['rdf:RDF'];
+ raw = asArray(rdf.item);
+ const ch = Array.isArray(rdf.channel) ? rdf.channel[0] : rdf.channel;
+ meta = { format: 'rss1', title: ch ? toText(pick(ch, 'title'), 300) : null, link: null };
+ make = (it) => rssItem({ ...it, guid: it['@_rdf:about'] ? { '#text': it['@_rdf:about'] } : undefined }, url, summaryMax);
+ } else if (doc.feed) {
+ const feed = doc.feed;
+ raw = asArray(feed.entry);
+ meta = { format: 'atom', title: toText(pick(feed, 'title'), 300), link: atomLink(feed, url) };
+ make = (e) => atomEntry(e, url, summaryMax);
+ } else {
+ throw new ParseError('not an RSS or Atom document');
+ }
+ const items = [];
+ let skipped = 0;
+ const seen = new Set();
+ for (const r of raw) {
+ if (items.length >= maxItems) { skipped++; continue; }
+ const it = r && typeof r === 'object' ? make(r) : null;
+ if (!it || seen.has(it.identity)) { skipped++; continue; }
+ seen.add(it.identity);
+ items.push(it);
+ }
+ return { ...meta, items, skipped };
+}
+
+function createFeedCarrier({ fetcher, config }) {
+ return {
+ name: 'feed',
+ run: async ({ watch, state }) => {
+ const out = await conditionalGet({
+ source: watch.source, state, fetcher, secrets: config.secrets, accept: ACCEPT.feed,
+ onlyChangeNeeded: onlyChange(watch),
+ });
+ if (out.state !== 'ok' || !out.body) return out;
+ const text = decodeBody(out.body, out.content_type);
+ let parsed;
+ try {
+ parsed = parseFeed(text, out.final_url || watch.source.url, { summaryMax: 2000 });
+ } catch (err) {
+ return { ...out, state: 'parse_error', error_code: err.code === 'parse_error' ? 'unreadable' : 'carrier_error', detail: String(err.message).slice(0, 500), body: null };
+ }
+ const items = mapFields(parsed.items, watch.source.fields);
+ return {
+ ...out,
+ document: { format: parsed.format, title: parsed.title, link: parsed.link, items, latest: items[0] || null, count: items.length },
+ };
+ },
+ };
+}
+
+/** source.fields: { name: path } over each mapped record; a path that matches nothing is null. */
+function mapFields(items, fields) {
+ const names = Object.keys(fields || {});
+ if (!names.length) return items;
+ return items.map((item) => {
+ const out = {};
+ for (const name of names) {
+ const v = at(item, fields[name]);
+ out[name] = v === undefined ? null : v;
+ }
+ return out;
+ });
+}
+
+module.exports = { createFeedCarrier, parseFeed, mapFields };
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * The http carrier: one URL, fetched conditionally.
+ *
+ * The binding preference order of plan T18 puts ETag/Last-Modified first among the pull rungs, so
+ * every carrier here goes through conditionalGet():
+ *
+ * - source.conditional !== false and a stored validator → If-None-Match / If-Modified-Since;
+ * a 304 is `not_modified` and costs nothing (no body, no observation).
+ * - when the watch only needs to know WHETHER the page changed (condition.op 'changed'), a HEAD is
+ * tried first while a validator is stored: the answer is the validators, not the body.
+ * - a 200 whose body hashes to the hash of the last body is `no_change`: the value cannot have
+ * changed, so no observation is recorded and the cadence is not disturbed.
+ * - 429 and 503+Retry-After set not_before: Watch waits as long as the site asked.
+ *
+ * Every URL goes through the SSRF guard inside the fetcher (ports 80/443 unless configured, no
+ * private hosts, every redirect hop re-checked, byte cap, one deadline). A source's credential is
+ * read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,
+ * logged, or sent to another origin than the one it was configured for.
+ */
+const { sha256, decodeBody } = require('../util');
+
+const ACCEPT = {
+ http: 'text/html, application/xhtml+xml, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1',
+ feed: 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.1',
+ api: 'application/json, application/xml;q=0.9, */*;q=0.1',
+};
+
+/** Retry-After (seconds or an HTTP date) → ms from now, or null. */
+function retryAfterMs(value, now) {
+ if (value == null) return null;
+ const s = String(value).trim();
+ if (/^\d{1,7}$/.test(s)) return Number(s) * 1000;
+ const t = Date.parse(s);
+ return Number.isFinite(t) ? Math.max(0, t - now) : null;
+}
+
+/**
+ * The headers a source asks for: its own (minus the ones the fetcher owns), its Accept, and its
+ * credential when it names an environment variable that is set.
+ */
+function requestFor(source, secrets, accept) {
+ const headers = { ...(source.headers || {}) };
+ const sensitive = [];
+ let url = String(source.url);
+ if (accept) headers.Accept = accept;
+ const auth = source.auth;
+ if (auth && auth.mode && auth.mode !== 'none') {
+ const secret = secrets ? secrets[auth.env] : null;
+ if (!secret) return { missing: auth.env };
+ if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }
+ else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }
+ else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }
+ }
+ return { url, headers, sensitive };
+}
+
+/** True when the watch only needs to know whether the value changed (a HEAD may answer it). */
+function onlyChange(watch) {
+ const mode = (watch.comparison && watch.comparison.mode) || 'changed';
+ const op = (watch.condition && watch.condition.op) || 'changed';
+ return op === 'changed' && mode !== 'none';
+}
+
+/**
+ * One conditional fetch of a source, shared by the http, feed and api carriers.
+ * → { state, http_status, bytes, raw_body_hash, etag, last_modified, body, content_type, detail, error_code, notBefore, final_url }
+ */
+async function conditionalGet({ source, state = {}, fetcher, secrets, accept, now = () => Date.now(), onlyChangeNeeded = false }) {
+ const req = requestFor(source, secrets, accept);
+ if (req.missing) {
+ return {
+ state: 'disabled', error_code: 'credential_missing',
+ detail: `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,
+ etag: null, last_modified: null, body: null, content_type: null, notBefore: null,
+ };
+ }
+ const conditional = source.conditional !== false;
+ const etag = conditional ? (state.etag || null) : null;
+ const lastModified = conditional ? (state.last_modified || null) : null;
+ const base = {
+ http_status: null, bytes: null, raw_body_hash: null, etag, last_modified: lastModified,
+ body: null, content_type: null, detail: null, error_code: null, notBefore: null,
+ };
+ const fetchOpts = { headers: req.headers, etag, lastModified, sensitiveHeaders: req.sensitive };
+ const method = String(source.method || 'GET').toUpperCase() === 'HEAD' ? 'HEAD' : 'GET';
+
+ const sameValidators = (h) => Boolean(
+ (etag && h.etag === etag) || (lastModified && h['last-modified'] === lastModified),
+ );
+
+ // HEAD first when only change is needed and there is something to compare with: a page that has
+ // not changed costs two headers instead of a body.
+ if (onlyChangeNeeded && conditional && (etag || lastModified) && method === 'GET') {
+ let head = null;
+ try { head = await fetcher.fetchUrl(req.url, { ...fetchOpts, method: 'HEAD' }); } catch { head = null; }
+ if (head) {
+ if (head.status === 304) return { ...base, state: 'not_modified', http_status: 304 };
+ if ((head.status === 200 || head.status === 204) && sameValidators(head.headers)) {
+ return { ...base, state: 'not_modified', http_status: head.status };
+ }
+ }
+ }
+
+ let res;
+ try {
+ res = await fetcher.fetchUrl(req.url, { ...fetchOpts, method });
+ } catch (err) {
+ const code = err.code || 'network';
+ return {
+ ...base,
+ state: code === 'timeout' ? 'timeout' : 'http_error',
+ error_code: code,
+ detail: String(err.message).slice(0, 500),
+ };
+ }
+ const out = {
+ ...base,
+ http_status: res.status,
+ bytes: res.bytes != null ? res.bytes : null,
+ etag: res.headers.etag || etag,
+ last_modified: res.headers['last-modified'] || lastModified,
+ content_type: res.headers['content-type'] || null,
+ final_url: res.finalUrl,
+ };
+ if (res.status === 304) return { ...out, state: 'not_modified' };
+ if (res.status === 429) {
+ const wait = Math.min(retryAfterMs(res.headers['retry-after'], now()) ?? 10 * 60 * 1000, 24 * 3600 * 1000);
+ return { ...out, state: 'rate_limited', error_code: 'upstream_429', detail: `the site asked us to wait ${Math.round(wait / 1000)} s`, notBefore: now() + wait };
+ }
+ if (res.status === 503) {
+ const wait = retryAfterMs(res.headers['retry-after'], now());
+ return { ...out, state: 'http_error', error_code: 'http_503', detail: 'HTTP 503', notBefore: wait == null ? null : now() + Math.min(wait, 24 * 3600 * 1000) };
+ }
+ if (res.status !== 200 && res.status !== 203) {
+ return { ...out, state: 'http_error', error_code: `http_${res.status}`, detail: `HTTP ${res.status}` };
+ }
+ if (method === 'HEAD') {
+ return { ...out, state: 'ok', document: { status: res.status, etag: out.etag, last_modified: out.last_modified, content_type: out.content_type, content_length: Number(res.headers['content-length']) || null } };
+ }
+ const hash = sha256(res.body);
+ out.raw_body_hash = hash;
+ out.bytes = res.body.length;
+ if (state.last_body_hash && state.last_body_hash === hash) {
+ return { ...out, state: 'no_change', detail: `the body hashed ${hash.slice(0, 16)}… again` };
+ }
+ return { ...out, state: 'ok', body: res.body };
+}
+
+function createHttpCarrier({ fetcher, config }) {
+ return {
+ name: 'http',
+ run: async ({ watch, state }) => conditionalGet({
+ source: watch.source, state, fetcher, secrets: config.secrets, accept: ACCEPT.http,
+ onlyChangeNeeded: onlyChange(watch),
+ }),
+ };
+}
+
+module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, ACCEPT, decodeBody };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Carrier choice. The plan's preference order is binding:
+ *
+ * webhook/event → ETag/Last-Modified → feed → API → and only then browser polling (through Run)
+ *
+ * The rungs are cheap to expensive, so a watch takes the first one its source makes possible: a
+ * pushed event or webhook costs nothing, a conditional GET costs two headers, a feed and an API are
+ * ordinary conditional GETs, and a Run check is an expensive browser/code execution. `source.kind`
+ * fixes the carrier (migration 0001); PREFERENCE records the order the service is built around.
+ *
+ * This release carries the pull rungs only:
+ *
+ * http feed api implemented here (step 3)
+ * event webhook step 5
+ * run step 6
+ * node step 7
+ *
+ * A kind whose carrier is not built yet is accepted by the registry (a watch may be defined before
+ * its transport exists) but is NEVER fetched: choose() answers `watch.carrier_unavailable`, the
+ * check is recorded as `skipped`, and no request leaves this process. That is what keeps a
+ * half-built rung from silently turning into polling.
+ */
+const { createHttpCarrier } = require('./http');
+const { createFeedCarrier } = require('./feed');
+const { createApiCarrier } = require('./api');
+
+/** The documented order. A source kind fixes its carrier; this is the order a watch would prefer. */
+const PREFERENCE = ['event', 'webhook', 'http', 'feed', 'api'];
+
+/** The step of plan T18 that carries each kind this release does not. */
+const LATER = {
+ event: { step: 5, note: 'an event source is delivered by OpenVibe.Events, never polled' },
+ webhook: { step: 5, note: 'a webhook source is delivered to /internal/hooks/, never polled' },
+ run: { step: 6, note: 'browser polling goes through OpenVibe.Run only after every cheaper rung was unavailable' },
+ node: { step: 7, note: 'a node probe observes a local/LAN thing through OpenVibe.Node' },
+};
+
+function unavailable(kind) {
+ const later = LATER[kind] || { step: null, note: 'no carrier for this source kind' };
+ return {
+ code: 'watch.carrier_unavailable',
+ carrier: kind,
+ detail: later.step
+ ? `a ${kind} source is not checked yet: the ${kind} carrier arrives in plan T18 step ${later.step} (${later.note}). Nothing was fetched.`
+ : `no carrier handles a ${kind} source. Nothing was fetched.`,
+ };
+}
+
+function createCarriers({ fetcher, config, log = console }) {
+ const carriers = {
+ http: createHttpCarrier({ fetcher, config, log }),
+ feed: createFeedCarrier({ fetcher, config, log }),
+ api: createApiCarrier({ fetcher, config, log }),
+ };
+
+ /**
+ * choose(watch) → { name, run } | { unavailable: { code, carrier, detail } }.
+ * Never fetches, never throws.
+ */
+ function choose(watch) {
+ const kind = watch && watch.source ? watch.source.kind : null;
+ const carrier = carriers[kind];
+ if (!carrier) return { unavailable: unavailable(kind) };
+ return { name: carrier.name, run: carrier.run };
+ }
+
+ return { choose, carriers, PREFERENCE, LATER };
+}
+
+module.exports = { createCarriers, PREFERENCE, LATER, unavailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,388 @@
@@ -0,0 +1,388 @@
+'use strict';
+/**
+ * The check engine: one execution of one watch (plan T18 step 3).
+ *
+ * run(id, { trigger }) —
+ * 1 one check of a watch at a time (a second call answers { busy: true })
+ * 2 load the watch; a watch that is not active is recorded `disabled` and never fetched
+ * 3 choose the carrier (carriers/index.js). event, webhook, node and run sources are accepted
+ * by the registry but have no carrier yet: the check is recorded `skipped` with
+ * watch.carrier_unavailable and NOTHING is fetched (steps 5-7)
+ * 4 carrier.run() → the body, its validators and the terminal state; watch_endpoint_state is
+ * updated with what the site said (ETag, Last-Modified, status, body hash)
+ * 5 extract → value; record an observation (value, hashes, changed, condition, retention)
+ * 6 evaluate the condition; a condition that MET writes check_runs(triggers = 1, state
+ * 'condition_met') and emits watch.watch.triggered. Actions are step 4 of the plan and are
+ * NOT dispatched here: a met condition records the trigger and emits the event, nothing else
+ * 7 arm next_due_at (+ jitter, or exponential backoff after a failure, capped) and not_before
+ * (Retry-After)
+ *
+ * The run row, the observation and every event commit in ONE transaction: an event exists exactly
+ * when its effect does. Nothing polls a source whose condition is 'changed' twice for the same
+ * body: a 304 is `not_modified` and an identical body hash is `no_change`, and neither records an
+ * observation.
+ */
+const { ids: contractIds, validate } = require('openvibe-contracts');
+const { nextDue } = require('./registry');
+const { ExtractError } = require('./extract');
+
+/** States that count as a failure, back off, and emit watch.check.failed. */
+const FAILURE_STATES = new Set(['http_error', 'timeout', 'parse_error', 'rate_limited', 'budget_exceeded', 'skipped', 'disabled']);
+
+/** The owner a watch event can name: observation.recorded takes usr_…/agt_…, triggered usr_… only. */
+const PERSON_OWNER = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_OWNER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, now = () => Date.now(), log = console, relay = null, ids = contractIds }) {
+ const inflight = new Set();
+
+ const st = {
+ insertRun: db.prepare(`INSERT INTO check_runs (id, watch_id, trigger, carrier, started_at, finished_at, state, http_status,
+ error_code, detail, bytes, raw_body_hash, cost_usd, observations, triggers)
+ VALUES (@id, @watch_id, @trigger, @carrier, @started_at, @finished_at, @state, @http_status,
+ @error_code, @detail, @bytes, @raw_body_hash, @cost_usd, @observations, @triggers)`),
+ endpoint: db.prepare('SELECT * FROM watch_endpoint_state WHERE watch_id = ? AND url = ?'),
+ touchEndpoint: db.prepare(`INSERT INTO watch_endpoint_state (watch_id, url, last_status, last_fetch_at) VALUES (@w, @u, @status, @at)
+ ON CONFLICT(watch_id, url) DO UPDATE SET last_status = excluded.last_status, last_fetch_at = excluded.last_fetch_at`),
+ saveEndpoint: db.prepare(`INSERT INTO watch_endpoint_state (watch_id, url, etag, last_modified, last_status, last_fetch_at, last_body_hash)
+ VALUES (@w, @u, @etag, @lm, @status, @at, @hash)
+ ON CONFLICT(watch_id, url) DO UPDATE SET etag = excluded.etag, last_modified = excluded.last_modified,
+ last_status = excluded.last_status, last_fetch_at = excluded.last_fetch_at, last_body_hash = excluded.last_body_hash`),
+ finish: db.prepare(`UPDATE watches SET last_check_at = @at, last_success_at = COALESCE(@success_at, last_success_at),
+ last_state = @state, consecutive_failures = @failures, next_due_at = @next_due, not_before = @not_before WHERE id = @id`),
+ runs: db.prepare('SELECT * FROM check_runs WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT ?'),
+ run: db.prepare('SELECT * FROM check_runs WHERE id = ?'),
+ // The last check that fired the condition: a repeat needs a value that is new or a new run
+ // of holding, so a satisfied watch does not fire once per check while nothing moves.
+ lastTrigger: db.prepare('SELECT id, started_at FROM check_runs WHERE watch_id = ? AND triggers > 0 ORDER BY rid DESC LIMIT 1'),
+ };
+
+ const boolOf = (v) => Boolean(Number(v));
+
+ /** check-run.v1.json, validated with the released schema. */
+ function runView(row) {
+ const v = {
+ id: row.id,
+ watch_id: row.watch_id,
+ trigger: row.trigger,
+ carrier: row.carrier == null ? null : row.carrier,
+ started_at: new Date(row.started_at).toISOString(),
+ finished_at: new Date(row.finished_at).toISOString(),
+ state: row.state,
+ http_status: row.http_status == null ? null : Number(row.http_status),
+ observations: Number(row.observations),
+ triggers: Number(row.triggers),
+ cost_usd: Number(row.cost_usd),
+ detail: row.detail == null ? null : row.detail,
+ };
+ const r = validate('watch.check-run@1', v);
+ if (!r.valid) throw new Error(`check-run view does not match watch.check-run@1: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ return v;
+ }
+
+ function baseRun(watch, trigger, carrier, startedAt) {
+ return {
+ id: `ckr_${ids.ulid(startedAt)}`, watch_id: watch.id, trigger, carrier,
+ started_at: startedAt, finished_at: startedAt, state: null, http_status: null, error_code: null,
+ detail: null, bytes: null, raw_body_hash: null, cost_usd: 0, observations: 0, triggers: 0,
+ };
+ }
+
+ // ── Events ───────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * The three events OpenVibe.Watch publishes (released payload contracts). They are emitted only
+ * when the watch's owner is a subject the payload can name: observation.recorded takes usr_… or
+ * agt_…, and a trigger names a usr_… recipient. A watch owned by a service still records every
+ * observation and trigger in its own tables; it just has no notification recipient, and the
+ * service logs that rather than emitting a payload the contract refuses.
+ */
+ async function eventsFor({ watch, owner, observation, value, met, firedOn = null, firedAt = null, previousValue, failures, run }) {
+ const events = [];
+ if (observation) {
+ if (PERSON_OWNER.test(owner)) {
+ events.push({
+ event_type: 'watch.observation.recorded',
+ subject: { type: 'watch', id: watch.id },
+ visibility: 'internal', priority: 'low',
+ payload: {
+ watch_id: watch.id, owner,
+ observed_at: new Date(observation.observed_at).toISOString(),
+ value,
+ value_hash: observation.value_hash,
+ changed: boolOf(observation.changed),
+ condition_met: boolOf(observation.condition_met),
+ },
+ });
+ } else {
+ log.warn(`[check] ${watch.id}: observation.recorded not emitted: watch.observation.recorded@1 names usr_/agt_ owners, this watch belongs to ${owner}`);
+ }
+ }
+ if (met && firedOn) {
+ if (USER_OWNER.test(owner)) {
+ events.push({
+ event_type: 'watch.watch.triggered',
+ subject: { type: 'watch', id: watch.id },
+ visibility: 'internal', priority: 'important',
+ payload: {
+ watch_id: watch.id, recipient: owner, project_id: null, name: watch.name,
+ condition: watch.condition,
+ fired_at: new Date(firedAt == null ? firedOn.observed_at : firedAt).toISOString(),
+ observation: {
+ id: firedOn.id,
+ observed_at: new Date(firedOn.observed_at).toISOString(),
+ value,
+ previous_value: previousValue === undefined ? null : previousValue,
+ },
+ },
+ });
+ } else {
+ log.warn(`[check] ${watch.id}: watch.triggered not emitted: watch.watch.triggered@1 names a usr_ recipient, this watch belongs to ${owner}`);
+ }
+ }
+ if (FAILURE_STATES.has(run.state)) {
+ events.push({
+ event_type: 'watch.check.failed',
+ subject: { type: 'watch', id: watch.id },
+ visibility: 'internal',
+ payload: {
+ watch_id: watch.id, state: run.state, carrier: run.carrier,
+ http_status: run.http_status == null ? null : Number(run.http_status),
+ error_code: run.error_code, consecutive_failures: Math.max(1, failures),
+ },
+ });
+ }
+ return events;
+ }
+
+ /**
+ * One terminal path, in ONE transaction: the observation (when there is one), the check run,
+ * the events and the watch's re-armed runtime state all commit together, or none of them do.
+ * `failures` is the consecutive failure count (0 when the check succeeded).
+ */
+ async function settle({ watch, run, failures, successAt = null, notBefore = 0, events = [], observation = null, endpoint = null }) {
+ const armed = nextDue(watch, run.finished_at, { failures, maxBackoffMs: config.worker.maxBackoffMs });
+ await db.tx(async () => {
+ if (observation) await observations.insert(observation);
+ await st.insertRun.run(run);
+ if (endpoint) await st.saveEndpoint.run(endpoint);
+ await st.finish.run({
+ id: watch.id, at: run.finished_at, success_at: successAt, state: run.state,
+ failures, next_due: armed, not_before: notBefore,
+ });
+ for (const e of events || []) await outbox.enqueue(e);
+ });
+ if (relay) relay.flush().catch(() => { });
+ return { run, observation, met: run.triggers > 0, failures };
+ }
+
+ // ── One check ────────────────────────────────────────────────────────────────────────────
+
+ async function runOnce(row, trigger) {
+ const watch = registry.fromRow(row);
+ const startedAt = now();
+ const carrier = watch.source && watch.source.kind ? watch.source.kind : null;
+ const failuresBefore = Number(row.consecutive_failures) || 0;
+
+ const skip = async (state, error_code, detail) => {
+ const run = {
+ ...baseRun(watch, trigger, carrier, startedAt),
+ state, error_code, detail, finished_at: now(),
+ };
+ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });
+ return await settle({ watch, run, failures: failuresBefore + 1, events });
+ };
+
+ // A watch that is not active is never fetched, and says so.
+ if (row.status !== 'active') {
+ return { disabled: true, ...(await skip('disabled', `watch_${row.status}`, `the watch is ${row.status}`)) };
+ }
+
+ // An extraction this release cannot run (an AI reading is an action, step 10): never fetch.
+ if (watch.extraction && watch.extraction.kind === 'ai') {
+ const out = await skip('skipped', 'watch.extraction_unavailable', 'ai extraction is an AI action, not a check (plan T18 step 10). Nothing was fetched.');
+ return { unavailable: { code: 'watch.extraction_unavailable' }, ...out };
+ }
+
+ const choice = carriers.choose(watch);
+ if (choice.unavailable) {
+ const out = await skip('skipped', choice.unavailable.code, choice.unavailable.detail);
+ return { unavailable: choice.unavailable, ...out };
+ }
+
+ const url = watch.source.url || null;
+ const state = url ? (await st.endpoint.get(watch.id, url)) || {} : {};
+ let out;
+ try {
+ out = await choice.run({ watch, state, now });
+ } catch (err) {
+ log.error(`[check] ${watch.id}: carrier ${carrier} crashed: ${err.stack || err}`);
+ out = { state: 'http_error', error_code: 'internal', detail: 'the carrier failed unexpectedly' };
+ }
+ const finishedAt = now();
+
+ // What the site said about this URL. Status and fetch time are always recorded; the
+ // validators and the body hash are stored only after a body that was read successfully
+ // (OpenVibe.Sources' rule), so a body that failed to parse is fetched and parsed again on
+ // the next check instead of being skipped as unchanged forever.
+ const endpointFor = (hash) => (url && out.http_status != null ? {
+ w: watch.id, u: url, etag: out.etag ?? null, lm: out.last_modified ?? null,
+ status: out.http_status, at: finishedAt, hash,
+ } : null);
+ const endpoint = endpointFor(out.raw_body_hash ?? state.last_body_hash ?? null);
+ if (url && out.http_status != null) await st.touchEndpoint.run({ w: watch.id, u: url, status: out.http_status, at: finishedAt });
+
+ // A failed fetch: the run is the record, and nothing was stated.
+ if (FAILURE_STATES.has(out.state)) {
+ const run = {
+ ...baseRun(watch, trigger, carrier, startedAt),
+ state: out.state, http_status: out.http_status ?? null, error_code: out.error_code ?? null,
+ detail: out.detail ? String(out.detail).slice(0, 500) : null,
+ bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
+ };
+ const failures = failuresBefore + 1;
+ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });
+ const result = await settle({ watch, run, failures, events, endpoint, notBefore: out.notBefore || 0 });
+ return { ...result, rateLimitedUntil: out.notBefore || null };
+ }
+
+ // The value: extracted from the body, or the one we already hold. `not_modified` (304) and
+ // `no_change` (an identical body) state nothing new, so no observation is recorded — but the
+ // condition is still evaluated against the held value, because a condition that must hold
+ // for for_sec becomes met while the page has not changed.
+ const stated = out.state === 'ok';
+ let value = null;
+ let snapshot = null;
+ if (stated) {
+ try {
+ const extracted = extract(watch.extraction, {
+ body: out.body === undefined ? null : out.body,
+ contentType: out.content_type || null,
+ document: out.document,
+ snapshotMax: config.observations.snapshotMax,
+ });
+ value = extracted.value;
+ snapshot = extracted.snapshot;
+ } catch (err) {
+ const unsupported = err instanceof ExtractError && err.code === 'unsupported';
+ const run = {
+ ...baseRun(watch, trigger, carrier, startedAt),
+ state: unsupported ? 'skipped' : 'parse_error',
+ error_code: unsupported ? 'watch.extraction_unavailable' : 'unreadable',
+ detail: String(err.message).slice(0, 500),
+ http_status: out.http_status ?? null, bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null,
+ finished_at: finishedAt,
+ };
+ const failures = failuresBefore + 1;
+ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });
+ return await settle({ watch, run, failures, events });
+ }
+ } else {
+ const held = await observations.latest(watch.id);
+ if (!held) {
+ const run = {
+ ...baseRun(watch, trigger, carrier, startedAt),
+ state: out.state, http_status: out.http_status ?? null, error_code: out.error_code ?? null,
+ detail: out.detail ? String(out.detail).slice(0, 500) : null,
+ bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
+ };
+ const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });
+ return await settle({ watch, run, failures: 0, successAt: finishedAt, events, endpoint });
+ }
+ value = held.value;
+ }
+
+ const latest = await observations.latest(watch.id);
+ const previous = latest ? {
+ value: latest.value, // jsonb is parsed by the db layer
+ condition_met: boolOf(latest.condition_met),
+ observed_at: latest.observed_at,
+ } : null;
+ const heldSince = await observations.heldSince(watch.id);
+ const verdict = condition.evaluate({ comparison: watch.comparison, condition: watch.condition }, {
+ value, previous, heldSince, now: finishedAt,
+ changed: stated ? undefined : false, // a body that did not change states no new value
+ });
+
+ // A condition fires when it becomes satisfied and stays satisfied: it fires again only for a
+ // value that is new (changed) or for a new run of holding (the trail began after the last
+ // firing) — never once per check while nothing moves.
+ const lastFired = await st.lastTrigger.get(watch.id);
+ const repeat = verdict.met && !verdict.changed && lastFired != null && verdict.since != null && lastFired.started_at >= verdict.since;
+ const met = verdict.met && !repeat;
+
+ const retentionDays = watch.retention && Number.isInteger(watch.retention.observations_days)
+ ? watch.retention.observations_days
+ : config.retention.defaultDays;
+ const keepSnapshots = !(watch.retention && watch.retention.keep_snapshots === false);
+
+ const run = {
+ ...baseRun(watch, trigger, carrier, startedAt),
+ state: met ? 'condition_met' : (stated ? (verdict.changed ? 'changed' : 'ok') : out.state),
+ http_status: out.http_status ?? null, bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null,
+ observations: stated ? 1 : 0, triggers: met ? 1 : 0, finished_at: finishedAt,
+ };
+
+ // The observation row is built before the transaction (it needs the previous one's hash),
+ // then inserted with the run, the endpoint state and the events in one commit. A check that
+ // stated nothing inserts no observation; its trigger (if any) points at the last one.
+ let observation = null;
+ let firedOn = latest;
+ let previousValue = previous ? previous.value : null;
+ // WATCH_OBSERVATIONS_MAX_PER_CHECK bounds what one check may write (a mapped feed or API can
+ // carry several watched records; a pull check today carries one value).
+ if (stated && run.observations < Math.max(1, config.observations.maxPerCheck)) {
+ observation = await observations.build({
+ watch_id: watch.id, check_run_id: run.id, value, snapshot,
+ changed: verdict.changed, condition_met: verdict.holds,
+ retentionDays, keepSnapshots, observed_at: finishedAt,
+ });
+ firedOn = observation;
+ } else if (met && latest) {
+ const beforeLatest = await observations.before(latest);
+ previousValue = beforeLatest ? beforeLatest.value : null;
+ }
+
+ const events = await eventsFor({
+ watch, owner: row.owner_sub, observation, value, met, run,
+ firedOn, firedAt: finishedAt, previousValue, failures: 0,
+ });
+ const result = await settle({ watch, run, failures: 0, successAt: finishedAt, events, observation, endpoint });
+ return {
+ ...result, met, value, verdict: { ...verdict, met, repeat },
+ observation: observation ? observations.view(observation) : null,
+ };
+ }
+
+ /**
+ * run(id, { trigger, event }) →
+ * null no such watch
+ * { busy: true } a check of this watch is already in flight
+ * { disabled: true } the watch is paused/disabled/failed; recorded, not fetched
+ * { unavailable: { code, detail } } the carrier (or extraction) is a later step; nothing fetched
+ * { run, observation, met, value } the check ran
+ */
+ async function run(id, { trigger = 'schedule' } = {}) {
+ const key = String(id);
+ // Claimed before the first await, so two callers racing on one watch cannot both proceed.
+ if (inflight.has(key)) return { busy: true };
+ inflight.add(key);
+ try {
+ const row = await registry.get(key);
+ if (!row) return null;
+ return await runOnce(row, trigger);
+ } finally {
+ inflight.delete(key);
+ }
+ }
+
+ /** Newest first; `before` is the rid of the last row of the previous page. */
+ async function list(watch_id, { before = null, limit = 50 } = {}) {
+ const b = before != null && Number.isSafeInteger(Number(before)) ? Number(before) : Number.MAX_SAFE_INTEGER;
+ return await st.runs.all(watch_id, b, limit);
+ }
+
+ return { run, list, runView, inflight: () => [...inflight], FAILURE_STATES };
+}
+
+module.exports = { createCheck, FAILURE_STATES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Comparison and conditions (watch.watch@1 $defs/comparison, $defs/condition).
+ *
+ * changed the value differs from the previous observation. No previous observation means it
+ * changed (there is nothing it is equal to). `tolerance` (absolute) and `percent`
+ * apply to numbers: a price that moved less than the tolerance has not changed.
+ * holds the predicate over the value: gt/gte/lt/lte (numbers, else strings), eq/ne, contains
+ * (a substring, an array member, or a key of an object), matches (a regular expression
+ * over the text), exists/absent.
+ * met holds AND the predicate has held continuously for `for_sec` seconds. The "since" is
+ * measured from the observations themselves (observations.heldSince), never from a
+ * timer in this process: a restart does not lose a pending debounce.
+ *
+ * Numeric comparison refuses to guess: a value that is not a number (or a plain decimal string) is
+ * never "greater than" anything, and an unknown value is never low (the same rule Deals' watches
+ * use). `matches` compiles the watch's own pattern with a length cap and tests a bounded string.
+ */
+const { canonicalJson } = require('./util');
+
+const MAX_PATTERN = 200;
+// Bounded input: the watch's own pattern only ever runs over this much text (see extract.js: a
+// value is capped at the same size), so a pathological pattern cannot scan a whole body.
+const MAX_TESTED = 65536;
+
+/** The numeric value of a number, or of a string that is exactly a decimal number; else null. */
+function asNumber(v) {
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
+ if (typeof v !== 'string') return null;
+ const s = v.trim();
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
+ const n = Number(s);
+ return Number.isFinite(n) ? n : null;
+}
+
+const same = (a, b) => canonicalJson(a) === canonicalJson(b);
+
+function isAbsent(v) {
+ if (v === null || v === undefined) return true;
+ if (typeof v === 'string') return v.trim() === '';
+ if (Array.isArray(v)) return v.length === 0;
+ if (typeof v === 'object') return Object.keys(v).length === 0;
+ return false;
+}
+
+/** changed: does `value` differ from the previous observation's value? */
+function hasChanged({ comparison }, { value, previous }) {
+ const mode = (comparison && comparison.mode) || 'changed';
+ if (mode === 'none') return false;
+ if (!previous) return true;
+ const before = previous.value;
+ const a = asNumber(value);
+ const b = asNumber(before);
+ const tolerance = comparison && comparison.tolerance != null ? Number(comparison.tolerance) : null;
+ const percent = comparison && comparison.percent != null ? Number(comparison.percent) : null;
+ if (a != null && b != null && tolerance != null && Number.isFinite(tolerance)) return Math.abs(a - b) > tolerance;
+ if (a != null && b != null && percent != null && Number.isFinite(percent)) return Math.abs(a - b) > Math.abs(b) * (percent / 100);
+ return !same(value, before);
+}
+
+/** The predicate alone: does the condition hold for this value? */
+function holds({ comparison, condition }, { value, previous, changed }) {
+ const op = (condition && condition.op) || 'changed';
+ const target = condition ? condition.value : null;
+ switch (op) {
+ case 'changed': return changed;
+ case 'gt': case 'gte': case 'lt': case 'lte': {
+ const a = asNumber(value);
+ const b = target === undefined ? (previous ? asNumber(previous.value) : null) : asNumber(target);
+ if (a != null && b != null) {
+ if (op === 'gt') return a > b;
+ if (op === 'gte') return a >= b;
+ if (op === 'lt') return a < b;
+ return a <= b;
+ }
+ if (typeof value === 'string' && typeof target === 'string') {
+ if (op === 'gt') return value > target;
+ if (op === 'gte') return value >= target;
+ if (op === 'lt') return value < target;
+ return value <= target;
+ }
+ return false;
+ }
+ case 'eq': {
+ if (target === undefined || target === null) return isAbsent(value);
+ const a = asNumber(value);
+ const b = asNumber(target);
+ return a != null && b != null ? a === b : same(value, target);
+ }
+ case 'ne': return !holds({ comparison, condition: { ...condition, op: 'eq' } }, { value, previous, changed });
+ case 'contains': {
+ if (typeof value === 'string') return typeof target === 'string' && value.includes(target);
+ if (Array.isArray(value)) return value.some((v) => same(v, target));
+ if (value && typeof value === 'object') return typeof target === 'string' && Object.prototype.hasOwnProperty.call(value, target);
+ return false;
+ }
+ case 'matches': {
+ if (typeof value !== 'string' || typeof target !== 'string') return false;
+ if (!target || target.length > MAX_PATTERN) return false;
+ let re;
+ try { re = new RegExp(target); } catch { return false; }
+ return re.test(value.slice(0, MAX_TESTED));
+ }
+ case 'exists': return !isAbsent(value);
+ case 'absent': return isAbsent(value);
+ default: return false;
+ }
+}
+
+/**
+ * evaluate({ comparison, condition }, { value, previous, heldSince, now })
+ * → { changed, holds, met, since }
+ *
+ * `previous` is the previous observation ({ value, condition_met, observed_at }) or null;
+ * `heldSince` (ms) is when the predicate began holding continuously, from the observation trail —
+ * pass it from observations.heldSince(). When the predicate holds and no start is known, it starts
+ * now, so a for_sec condition never fires on the first sighting.
+ */
+function evaluate({ comparison = null, condition = {} } = {}, { value, previous = null, heldSince = null, now = Date.now(), changed: given } = {}) {
+ // The caller may know better: a check that got a 304 or an unchanged body knows nothing changed,
+ // whatever comparing the values would say.
+ const changed = given === undefined ? hasChanged({ comparison }, { value, previous }) : Boolean(given);
+ const hold = holds({ comparison, condition }, { value, previous, changed });
+ const forSec = condition && Number.isInteger(condition.for_sec) ? Math.max(0, condition.for_sec) : 0;
+ const since = hold ? (heldSince != null ? heldSince : now) : null;
+ const met = hold && (forSec === 0 || (since != null && now - since >= forSec * 1000));
+ return { changed, holds: hold, met, since };
+}
+
+module.exports = { evaluate, hasChanged, holds, asNumber, isAbsent };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * OpenVibe.Watch configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/watch.env in production); see .env.example for the documented list.
+ *
+ * load(env) is pure so tests can build a config without touching process.env. `env` is also kept
+ * as config.secrets: a watch source's credential is read from it by environment-variable NAME at
+ * fetch time and never stored, logged or returned.
+ *
+ * The carrier preference order is binding (plan T18): webhook/event → ETag/Last-Modified → feed →
+ * API → and only then browser polling through OpenVibe.Run. `run` below is where that last rung
+ * lives; nothing in this release executes it (steps 5-7).
+ */
+const pkg = require('../package.json');
+
+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(/\/$/, '');
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4730);
+ const networkUrl = strip(env.OV_NETWORK_URL || 'https://openvibe.network');
+ const networkInternalUrl = strip(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');
+ // The JWKS the SDK's client (openvibe-sdk/auth jwksClient) fetches, caches and keeps fresh; one URL,
+ // since the SDK keeps one client per URL. Defaults to Network's internal JWKS.
+ const jwksUrl = strip(env.OV_NETWORK_JWKS_URL || `${networkInternalUrl}/api/.well-known/jwks`);
+ return {
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ serviceId: 'watch',
+ baseUrl: strip(env.BASE_URL || (isProduction ? 'https://openvibe.watch' : `http://localhost:${port}`)),
+
+ networkUrl,
+ networkInternalUrl,
+ jwksUrl,
+ issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
+ audience: 'openvibe.watch',
+ // Per-actor limits (server/api/actor-limits.js): the API reads one app or module may make per
+ // minute and per hour. Writes and manual checks set their own numbers there.
+ limits: {
+ minute: Math.max(1, int(env.WATCH_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.WATCH_LIMITS_HOUR, 3000)),
+ },
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'watch',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ },
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+
+ events: {
+ url: strip(env.EVENTS_URL || ''),
+ relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ },
+
+ // The check worker: every WATCH_TICK_MS, start checks for active watches whose turn has come.
+ worker: {
+ enabled: env.WATCH_WORKER !== 'off',
+ tickMs: int(env.WATCH_TICK_MS, 5000),
+ maxConcurrent: int(env.WATCH_MAX_CONCURRENT, 8),
+ // Longest wait between checks of a failing watch (exponential backoff cap).
+ maxBackoffMs: int(env.WATCH_MAX_BACKOFF_MS, 6 * 3600 * 1000),
+ },
+ fetch: {
+ userAgent: env.WATCH_USER_AGENT || `OpenVibeWatch/${pkg.version} (+https://openvibe.watch; [email protected])`,
+ timeoutMs: int(env.WATCH_FETCH_TIMEOUT_MS, 20000),
+ maxBytes: int(env.WATCH_MAX_BYTES, 5 * 1024 * 1024),
+ maxRedirects: int(env.WATCH_MAX_REDIRECTS, 5),
+ // Minimum gap between any two requests to one host, whatever the watches say.
+ hostMinIntervalMs: int(env.WATCH_HOST_MIN_INTERVAL_MS, 1000),
+ allowedPorts: list(env.WATCH_ALLOWED_PORTS, ['80', '443']).map(Number),
+ // Exact hostnames/IPs that may resolve to private or loopback addresses (tests and
+ // deliberate on-host watches only). Empty in production.
+ allowPrivateHosts: list(env.WATCH_ALLOW_PRIVATE_HOSTS, []),
+ },
+ observations: {
+ // How many observations one check may record (a mapped feed or API can yield several
+ // values; the cap keeps one pathological check from writing thousands of rows).
+ maxPerCheck: int(env.WATCH_OBSERVATIONS_MAX_PER_CHECK, 100),
+ // A snapshot is a capped copy of the body the value came from (observation.v1.json).
+ snapshotMax: 8 * 1024,
+ },
+ retention: {
+ // observations_days when the watch states none; a watch may override it (watch.watch@1).
+ defaultDays: int(env.WATCH_RETENTION_DEFAULT_DAYS, 90),
+ },
+
+ // OpenVibe.Run (plan T14): the expensive last rung — browser polling — and never used before
+ // steps 5-7 exist. WATCH_RUN_URL is Run's loopback port.
+ run: {
+ url: strip(env.WATCH_RUN_URL || 'http://127.0.0.1:4920'),
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'watch',
+ clientSecret: env.WATCH_RUN_OAUTH_CLIENT_SECRET || '',
+ },
+ },
+
+ secrets: env,
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * The Watch database: PostgreSQL (ADR-035), owned by this service only; the schema is migrations/NNNN_*.sql, applied
+ * at boot.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+/**
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
+ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
+ */
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh watch)');
+ log.warn(`[watch] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ const db = createDb({ pglite: DEV_PGLITE, service: 'watch', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'watch-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'watch', registry, log });
+}
+
+/** Next value of a named monotonic counter (call inside a transaction). */
+async function nextSeq(db, name) {
+ return await db.prepare('INSERT INTO counters (name, value) VALUES (?, 1) ON CONFLICT (name) DO UPDATE SET value = counters.value + 1 RETURNING counters.value').pluck().get(name);
+}
+
+module.exports = { openDb, nextSeq, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'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 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 */
+ async 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('; ')}`);
+ await 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: async () => (await q.pending.get()).n,
+ rejected: async () => (await q.rejectedCount.get()).n,
+ /** Every envelope in order (tests and operators). */
+ all: async () => (await q.list.all()).map(r => JSON.parse(r.envelope)),
+ prune: async (olderThanMs = 7 * 24 * 3600 * 1000) => (await 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 };
+ }
+
+ async function mark(rows, fn) { await db.tx(async () => { for (const r of rows) await fn(r); }); }
+
+ async function publishRows(rows) {
+ let r;
+ try {
+ r = await post(rows);
+ } catch (err) {
+ await mark(rows, async row => await 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) {
+ await mark(rows, async row => await 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)) {
+ await mark(rows, async row => await q.rejected.run(now(), msg, row.seq));
+ log.warn(`[outbox] event refused by Events: ${msg}`);
+ return { sent: 0, failed: 0, rejected: rows.length };
+ }
+ await mark(rows, async row => await 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 = await 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 = { createOutbox, createRelay };
new file mode 100644
new file mode 100644
@@ -0,0 +1,291 @@
@@ -0,0 +1,291 @@
+'use strict';
+/**
+ * Extraction: what value a watch takes from the body a carrier fetched (watch.watch@1 $defs/extraction).
+ *
+ * text the whole body as text, trimmed
+ * html the body's text (markup stripped), or the text of the first element a `selector` matches
+ * json the parsed body (a `value_path` picks a part of it); an api or feed carrier hands in
+ * its own document — the mapped { items, latest, … } — so a path reads the watched
+ * record rather than a provider's shape
+ * jsonpath the same, named for the dotted path it takes
+ * regex the first match of `selector` (its first group when it has one, else the whole match)
+ * css the text of the first element a `selector` matches, in the small selector subset below
+ * ai not here: an AI reading is an action (step 10), and this returns `unsupported`
+ *
+ * A value the source did not state is null, never guessed: a path that matches nothing and a
+ * regular expression that does not match both extract null, which `absent` then answers truthfully.
+ * `fields` (name → path) extracts an object of several values at once, each null when it is missing.
+ */
+const { toText, decodeBody } = require('./util');
+
+/** Longest text value one extraction stores (a 5 MB page as `text` is a mistake, not a value). */
+const VALUE_MAX = 64 * 1024;
+
+class ExtractError extends Error {
+ constructor(code, detail) { super(detail); this.code = code; }
+}
+
+// ── The small selector subset ────────────────────────────────────────────────────────────────
+const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
+const RAW = new Set(['script', 'style', 'noscript']);
+const MAX_DEPTH = 200;
+
+function parseAttrs(text) {
+ const attrs = {};
+ const re = /([:@\w.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
+ let m;
+ while ((m = re.exec(text))) {
+ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
+ }
+ return attrs;
+}
+
+/** A forgiving HTML reader: elements, attributes and text, enough to select from. Never throws. */
+function parseHtml(html) {
+ const root = { type: 'element', tag: '#root', attrs: {}, children: [], parent: null };
+ const stack = [root];
+ const push = (node) => {
+ const top = stack[stack.length - 1];
+ const last = top.children[top.children.length - 1];
+ if (node.type === 'text') {
+ if (last && last.type === 'text') { last.value += node.value; return; }
+ }
+ top.children.push(node);
+ };
+ const text = (s) => { if (s) push({ type: 'text', value: s }); };
+ let i = 0;
+ while (i < html.length) {
+ const lt = html.indexOf('<', i);
+ if (lt < 0) { text(html.slice(i)); break; }
+ if (lt > i) text(html.slice(i, lt));
+ if (html.startsWith('<!--', lt)) { const end = html.indexOf('-->', lt + 4); i = end < 0 ? html.length : end + 3; continue; }
+ if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) { const end = html.indexOf('>', lt); i = end < 0 ? html.length : end + 1; continue; }
+ if (html[lt + 1] === '/') {
+ const end = html.indexOf('>', lt);
+ const tag = html.slice(lt + 2, end < 0 ? html.length : end).trim().toLowerCase();
+ for (let k = stack.length - 1; k > 0; k--) if (stack[k].tag === tag) { stack.length = k; break; }
+ i = end < 0 ? html.length : end + 1;
+ continue;
+ }
+ // The tag ends at the first '>' outside a quoted attribute value.
+ let end = lt + 1;
+ let quote = null;
+ for (; end < html.length; end++) {
+ const ch = html[end];
+ if (quote) { if (ch === quote) quote = null; continue; }
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
+ if (ch === '>') break;
+ }
+ if (end >= html.length) { text(html.slice(lt)); break; }
+ const raw = html.slice(lt + 1, end);
+ const selfClosing = raw.endsWith('/');
+ const m = /^([a-zA-Z][\w:-]*)/.exec(selfClosing ? raw.slice(0, -1) : raw);
+ if (!m) { text(html.slice(lt, end + 1)); i = end + 1; continue; }
+ const tag = m[1].toLowerCase();
+ const el = { type: 'element', tag, attrs: parseAttrs(raw.slice(m[1].length)), children: [], parent: stack[stack.length - 1] };
+ push(el);
+ i = end + 1;
+ if (VOID.has(tag) || selfClosing) continue;
+ if (RAW.has(tag)) {
+ const close = html.toLowerCase().indexOf(`</${tag}`, i);
+ const stop = close < 0 ? html.length : close;
+ if (stop > i) el.children.push({ type: 'text', value: html.slice(i, stop) });
+ if (close < 0) { i = html.length; continue; }
+ const gt = html.indexOf('>', close);
+ i = gt < 0 ? html.length : gt + 1;
+ continue;
+ }
+ if (stack.length < MAX_DEPTH) stack.push(el);
+ }
+ return root;
+}
+
+const COMPOUND_RE = /^([a-zA-Z][\w-]*|\*)?((?:[.#][\w-]+|\[[^\]]{0,120}\])*)$/;
+const PART_RE = /([.#])([\w-]+)|\[([^\]]*)\]/g;
+
+/** 'div.card > a[href] .price' → [{ comb:' ', compound }, …]; null when it is not in the subset. */
+function parseSelector(selector) {
+ const text = String(selector || '').trim();
+ if (!text) return null;
+ const parts = [];
+ const tokens = text.split(/(\s*>\s*|\s+)/).filter((s) => s !== '');
+ let comb = ' ';
+ for (const token of tokens) {
+ if (/^\s*>\s*$/.test(token)) { comb = '>'; continue; }
+ if (/^\s+$/.test(token)) { comb = ' '; continue; }
+ const m = COMPOUND_RE.exec(token);
+ if (!m) return null;
+ const compound = { tag: m[1] && m[1] !== '*' ? m[1].toLowerCase() : null, id: null, classes: [], attrs: [] };
+ let p;
+ PART_RE.lastIndex = 0;
+ while ((p = PART_RE.exec(m[2]))) {
+ if (p[1] === '#') compound.id = p[2];
+ else if (p[1] === '.') compound.classes.push(p[2]);
+ else {
+ const a = /^([:@\w.-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?$/.exec(p[3].trim());
+ if (!a) return null;
+ compound.attrs.push({ name: a[1].toLowerCase(), value: a[2] ?? a[3] ?? a[4] ?? null });
+ }
+ }
+ parts.push({ comb, compound });
+ comb = ' ';
+ }
+ return parts.length ? parts : null;
+}
+
+function matchesCompound(el, c) {
+ if (c.tag && el.tag !== c.tag) return false;
+ if (c.id && el.attrs.id !== c.id) return false;
+ if (c.classes.length && !c.classes.every((x) => String(el.attrs.class || '').split(/\s+/).includes(x))) return false;
+ for (const a of c.attrs) {
+ if (el.attrs[a.name] === undefined) return false;
+ if (a.value != null && el.attrs[a.name] !== a.value) return false;
+ }
+ return true;
+}
+
+function matchesFrom(el, parts, i) {
+ if (!matchesCompound(el, parts[i].compound)) return false;
+ if (i === 0) return true;
+ if (parts[i].comb === '>') return el.parent != null && matchesFrom(el.parent, parts, i - 1);
+ for (let p = el.parent; p; p = p.parent) if (matchesFrom(p, parts, i - 1)) return true;
+ return false;
+}
+
+/** Elements matching a parsed selector, in document order. */
+function selectAll(root, parts) {
+ const out = [];
+ const walk = (el) => {
+ for (const child of el.children) {
+ if (child.type !== 'element') continue;
+ if (matchesFrom(child, parts, parts.length - 1)) out.push(child);
+ walk(child);
+ }
+ };
+ walk(root);
+ return out;
+}
+
+/** The text of an element, markup stripped, in document order (script and style dropped). */
+function innerText(el, max = VALUE_MAX) {
+ let s = '';
+ const push = (t) => { if (s.length < max) s += t; };
+ const walk = (node) => {
+ if (s.length >= max) return;
+ for (const child of node.children) {
+ if (child.type === 'text') push(child.value);
+ else if (!RAW.has(child.tag)) { walk(child); push(' '); }
+ }
+ };
+ walk(el);
+ return (toText(s, max) || '').slice(0, max);
+}
+
+// ── Paths (json / jsonpath / fields) ─────────────────────────────────────────────────────────
+const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;
+
+/** Dot path with array indexes: 'items.0.fields.price' or 'items[0].price'. Missing → undefined. */
+function at(obj, path) {
+ if (path == null || path === '') return obj;
+ const clean = String(path).replace(/\[(\d+)\]/g, '.$1').replace(/^\./, '');
+ if (!PATH_RE.test(clean)) return undefined;
+ let cur = obj;
+ for (const seg of clean.split('.')) {
+ if (cur == null || typeof cur !== 'object') return undefined;
+ cur = cur[seg];
+ }
+ return cur;
+}
+
+function parseJson(text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ throw new ExtractError('parse_error', 'the body is not valid JSON');
+ }
+}
+
+/** '/<re>/flags' or a bare pattern → a RegExp; oversized or invalid patterns throw. */
+function compileRegex(pattern) {
+ const s = String(pattern || '');
+ if (!s || s.length > 200) throw new ExtractError('unsupported', 'regex extraction needs a pattern of at most 200 characters');
+ const m = /^\/(.*)\/([gimsuy]*)$/.exec(s);
+ try {
+ return m ? new RegExp(m[1], m[2].replace('g', '')) : new RegExp(s);
+ } catch {
+ throw new ExtractError('parse_error', 'selector is not a valid regular expression');
+ }
+}
+
+function capped(text, max) {
+ const s = String(text == null ? '' : text);
+ return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;
+}
+
+/**
+ * extract(extraction, { body, contentType, document, snapshotMax })
+ * → { value, snapshot }
+ *
+ * `body` is the fetched body (Buffer or text); `document` is what a carrier already parsed (a
+ * feed's or api's mapped records) and is used by json/jsonpath when present. `snapshot` is the
+ * capped copy of the body the value came from (the caller decides whether to keep it).
+ */
+function extract(extraction = {}, { body = null, contentType = null, document = undefined, snapshotMax = 8 * 1024 } = {}) {
+ const kind = extraction.kind || 'text';
+ const text = body == null ? null : (Buffer.isBuffer(body) ? decodeBody(body, contentType) : String(body));
+ const snapshot = text == null ? null : capped(text, snapshotMax);
+ const doc = () => (document !== undefined ? document : parseJson(text == null ? '' : text));
+
+ switch (kind) {
+ case 'ai':
+ throw new ExtractError('unsupported', 'ai extraction is an AI action, not a check (plan T18 step 10): this watch cannot be checked yet');
+ case 'text':
+ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');
+ return { value: capped(text.trim(), VALUE_MAX), snapshot };
+ case 'html': {
+ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');
+ if (!extraction.selector) return { value: toText(text, VALUE_MAX), snapshot };
+ const parts = parseSelector(extraction.selector);
+ if (!parts) throw new ExtractError('unsupported', `selector "${extraction.selector}" is not in the supported subset`);
+ const first = selectAll(parseHtml(text), parts)[0];
+ return { value: first ? innerText(first) || null : null, snapshot };
+ }
+ case 'css': {
+ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');
+ if (!extraction.selector) throw new ExtractError('unsupported', 'css extraction needs a selector');
+ const parts = parseSelector(extraction.selector);
+ if (!parts) throw new ExtractError('unsupported', `selector "${extraction.selector}" is not in the supported subset`);
+ const first = selectAll(parseHtml(text), parts)[0];
+ return { value: first ? innerText(first) || null : null, snapshot };
+ }
+ case 'json':
+ case 'jsonpath': {
+ const d = doc();
+ const value = extraction.fields ? fieldsOf(d, extraction.fields) : (extraction.value_path ? at(d, extraction.value_path) : d);
+ return { value: value === undefined ? null : value, snapshot };
+ }
+ case 'regex': {
+ if (text == null) throw new ExtractError('parse_error', 'there is no body to extract from');
+ const re = compileRegex(extraction.selector);
+ // Bounded input: a watch's own pattern is compiled as-is, so it only ever runs over a
+ // window of VALUE_MAX characters (a value beyond that is not a value this service keeps).
+ const m = re.exec(text.slice(0, VALUE_MAX));
+ if (!m) return { value: null, snapshot };
+ return { value: m.length > 1 ? (m[1] === undefined ? null : m[1]) : m[0], snapshot };
+ }
+ default:
+ throw new ExtractError('unsupported', `extraction kind "${kind}" is not supported`);
+ }
+}
+
+/** extraction.fields: { name: path } → an object of the values the document states (null when absent). */
+function fieldsOf(document, fields) {
+ const out = {};
+ for (const [name, path] of Object.entries(fields || {})) {
+ const v = at(document, path);
+ out[name] = v === undefined ? null : v;
+ }
+ return out;
+}
+
+module.exports = { extract, ExtractError, at, parseSelector, parseHtml, selectAll, VALUE_MAX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * OpenVibe.Watch entry point.
+ *
+ * node server/index.js (systemd: openvibe-watch.service)
+ *
+ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
+ * clock/fetch/log/DNS lookup, and returns handles to every part. Nothing starts at module load:
+ * the database, the JWKS client, the scheduler and the outbox relay are all started here, and every
+ * one of them is stopped by close().
+ */
+const { load } = require('./config');
+const { openDb } = require('./db');
+const { createRegistry } = require('./registry');
+const { createObservations } = require('./observations');
+const { createGuard } = require('./net/guard');
+const { createFetcher } = require('./net/fetcher');
+const { createSpacer } = require('./spacer');
+const { createCarriers } = require('./carriers');
+const { extract } = require('./extract');
+const condition = require('./condition'); // { evaluate, hasChanged, holds, … }
+const { createCheck } = require('./check');
+const { createScheduler } = require('./scheduler');
+const { createOutbox, createRelay } = require('./events/outbox');
+const { jwksClient } = require('openvibe-sdk/auth');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { createAuth } = require('./auth');
+const { createApp } = require('./app');
+
+async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null } = {}) {
+ config = config || load();
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
+ const db = givenDb || await openDb(config, { log });
+ const outbox = createOutbox(db, { source: config.serviceId, 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 registry = createRegistry({ db, now, outbox });
+ const observations = createObservations({ db, now });
+ // The only way out to the internet: SSRF guard (ports, private hosts, every redirect hop), byte
+ // cap, one deadline, conditional GET; per-host spacing in front of every request.
+ const guard = createGuard({ allowPrivateHosts: config.fetch.allowPrivateHosts, allowedPorts: config.fetch.allowedPorts, ...(lookupImpl ? { lookupImpl } : {}) });
+ const fetcher = createFetcher({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.fetch.timeoutMs, maxBytes: config.fetch.maxBytes, maxRedirects: config.fetch.maxRedirects });
+ const spacer = createSpacer({ hostMinIntervalMs: config.fetch.hostMinIntervalMs });
+ // Every carrier goes out through the spacer, so two watches over one host never hammer it.
+ const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log });
+ const check = createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, now, log, relay });
+ const scheduler = createScheduler({ db, check, config, now, log });
+ const keys = jwksClient(config.jwksUrl, { fetch: fetchImpl, log });
+ const auth = createAuth({ config, log });
+ const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow });
+ // One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
+ // on an unref'd timer, keeping the last good keys through a Network outage.
+ keys.start();
+ const keyLoaded = keys.keys().catch(() => null);
+ relay.start();
+ if (config.worker.enabled) scheduler.start();
+ const pruneTimer = setInterval(async () => {
+ try { await observations.prune(); } catch (err) { log.error(`[observations] prune: ${err.message}`); }
+ }, 3600 * 1000);
+ pruneTimer.unref?.();
+ const outboxPruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
+ outboxPruneTimer.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(`[watch] listening on http://${config.host}:${server.address().port}`);
+ }
+
+ async function close() {
+ clearInterval(pruneTimer);
+ clearInterval(outboxPruneTimer);
+ keys.stop();
+ await scheduler.stop();
+ await relay.stop();
+ if (server) {
+ server.closeAllConnections?.();
+ await new Promise(resolve => server.close(() => resolve()));
+ }
+ if (!givenDb) await db.close();
+ }
+
+ return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, app, server, close };
+}
+
+/**
+ * The fetcher every carrier uses: one request per host at a time, spaced like any other client to
+ * that host — including every redirect hop, which the fetcher hands us before it follows it. A URL
+ * the spacer cannot read is not a URL the guard would pass either; the real fetcher refuses it and
+ * the carrier records the refusal.
+ */
+function spacedFetcher(fetcher, spacer) {
+ return {
+ fetchUrl: async (url, opts = {}) => {
+ await spacer.space(new URL(url).host);
+ const inner = opts.beforeHop;
+ return await fetcher.fetchUrl(url, {
+ ...opts,
+ beforeHop: async (next) => {
+ await spacer.space(next.host);
+ return inner ? await inner(next) : null;
+ },
+ });
+ },
+ };
+}
+
+if (require.main === module) {
+ require('dotenv').config();
+ start().then((handles) => {
+ // SIGTERM/SIGINT (openvibe-sdk/service, docs/service.md's handles family, Watch 25 s): requests in
+ // flight get 15 s, then handles.close() (the prune timers and JWKS refresher stopped, the scheduler,
+ // relay and server stopped, the database closed; a rejection exits 1); past 25 s the process exits 1.
+ gracefulStop({ name: 'Watch', server: handles.server, handles, drainMs: 15000, deadlineMs: 25000 });
+ }).catch((err) => {
+ console.error(`[watch] failed to start: ${err.stack || err}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { start };
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+/**
+ * The only way Watch reaches the internet: GET (or HEAD) with an address guard (server/net/guard.js),
+ * manual redirects re-checked hop by hop, conditional headers, one overall deadline covering
+ * connect + headers + body, a byte cap applied to the decoded body, and no cookies.
+ *
+ * fetchUrl(url, { method, headers, etag, lastModified, beforeHop(url) }) →
+ * { kind: 'response', status, headers, body (Buffer|null), finalUrl, bytes }
+ * or throws FetchError { code: timeout | too_large | address_refused | port_refused | bad_url |
+ * too_many_redirects | network | hop_refused, detail }
+ *
+ * Copied from OpenVibe.Sources/server/net/fetcher.js; Watch adds `method: 'HEAD'`, which a
+ * conditional watch uses to ask "has this changed?" without pulling the body (the dossier's
+ * "HEAD first when only change is needed").
+ */
+const http = require('http');
+const https = require('https');
+const zlib = require('zlib');
+const { GuardError } = require('./guard');
+
+class FetchError extends Error {
+ constructor(code, detail) { super(detail); this.code = code; }
+}
+
+function createFetcher({ guard, userAgent, timeoutMs = 20000, maxBytes = 5 * 1024 * 1024, maxRedirects = 5 }) {
+ function once(url, method, headers, deadline, capBytes) {
+ return new Promise((resolve, reject) => {
+ const mod = url.protocol === 'https:' ? https : http;
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) return reject(new FetchError('timeout', 'deadline passed'));
+ let settled = false;
+ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v); } };
+ const req = mod.request(url, {
+ method,
+ headers,
+ lookup: guard.lookup,
+ agent: false,
+ timeout: remaining,
+ }, (res) => {
+ const status = res.statusCode;
+ if (status >= 300 && status < 400 && res.headers.location) {
+ res.resume();
+ return done(resolve, { redirect: res.headers.location, status, headers: res.headers });
+ }
+ // A HEAD answer carries no body by definition; nothing to cap or collect.
+ if (method === 'HEAD') {
+ res.resume();
+ return done(resolve, { status, headers: res.headers, body: null, bytes: 0 });
+ }
+ const declared = Number(res.headers['content-length']);
+ if (Number.isFinite(declared) && declared > capBytes && status === 200) {
+ req.destroy();
+ return done(reject, new FetchError('too_large', `content-length ${declared} exceeds ${capBytes} bytes`));
+ }
+ let stream = res;
+ const enc = String(res.headers['content-encoding'] || '').trim().toLowerCase();
+ if (enc === 'gzip' || enc === 'x-gzip') stream = res.pipe(zlib.createGunzip());
+ else if (enc === 'deflate') stream = res.pipe(zlib.createInflate());
+ else if (enc === 'br') stream = res.pipe(zlib.createBrotliDecompress());
+ const chunks = [];
+ let bytes = 0;
+ stream.on('data', (c) => {
+ bytes += c.length;
+ if (bytes > capBytes) {
+ req.destroy();
+ done(reject, new FetchError('too_large', `body exceeds ${capBytes} bytes`));
+ return;
+ }
+ chunks.push(c);
+ });
+ stream.on('end', () => done(resolve, { status, headers: res.headers, body: Buffer.concat(chunks), bytes }));
+ stream.on('error', (err) => done(reject, new FetchError('network', `body: ${err.message}`)));
+ res.on('aborted', () => done(reject, new FetchError('network', 'response aborted')));
+ });
+ const timer = setTimeout(() => {
+ req.destroy();
+ done(reject, new FetchError('timeout', `no complete response within ${timeoutMs} ms`));
+ }, remaining);
+ req.on('timeout', () => {
+ req.destroy();
+ done(reject, new FetchError('timeout', `no complete response within ${timeoutMs} ms`));
+ });
+ req.on('error', (err) => {
+ if (err instanceof GuardError) return done(reject, new FetchError(err.code, err.message));
+ done(reject, new FetchError('network', `${err.code || 'error'}: ${err.message}`));
+ });
+ req.end();
+ });
+ }
+
+ async function fetchUrl(input, { method = 'GET', headers = {}, etag = null, lastModified = null, beforeHop = null, capBytes = maxBytes, sensitiveHeaders = [] } = {}) {
+ const verb = String(method).toUpperCase() === 'HEAD' ? 'HEAD' : 'GET';
+ const deadline = Date.now() + timeoutMs;
+ let url;
+ try { url = guard.checkUrl(input); } catch (err) { throw new FetchError(err.code || 'bad_url', err.message); }
+ const h = {
+ 'User-Agent': userAgent,
+ Accept: headers.Accept || '*/*',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ ...headers,
+ };
+ if (etag) h['If-None-Match'] = etag;
+ if (lastModified) h['If-Modified-Since'] = lastModified;
+ for (let hop = 0; ; hop++) {
+ const r = await once(url, verb, h, deadline, capBytes);
+ if (!r.redirect) return { kind: 'response', status: r.status, headers: r.headers, body: r.body, bytes: r.bytes, finalUrl: url.toString() };
+ if (hop >= maxRedirects) throw new FetchError('too_many_redirects', `more than ${maxRedirects} redirects`);
+ let next;
+ try { next = guard.checkUrl(new URL(r.redirect, url).toString()); } catch (err) { throw new FetchError(err.code || 'bad_url', `redirect: ${err.message}`); }
+ if (beforeHop) {
+ const verdict = await beforeHop(next);
+ if (verdict && verdict.refuse) throw new FetchError('hop_refused', verdict.refuse);
+ }
+ // Conditional headers belong to the original resource only; credentials never leave
+ // the origin they were configured for.
+ delete h['If-None-Match'];
+ delete h['If-Modified-Since'];
+ if (next.origin !== url.origin) for (const name of sensitiveHeaders) delete h[name];
+ url = next;
+ }
+ }
+
+ return { fetchUrl };
+}
+
+module.exports = { createFetcher, FetchError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * Outbound address policy (roadmap anti-goal 13: no fetcher may reach internal addresses).
+ *
+ * A URL is fetchable only when it is http(s), on an allowed port, and every address its host
+ * resolves to is public unicast. The check runs at connect time through the socket's `lookup`
+ * (so a DNS answer cannot change between check and connect) and up front for IP literals (which
+ * never go through lookup). SOURCES_ALLOW_PRIVATE_HOSTS lists exact hostnames/IPs exempted —
+ * for tests and deliberate on-host sources only.
+ */
+const dns = require('dns');
+const net = require('net');
+const sharedEgress = require('openvibe-shared/egress');
+
+const blocked = new net.BlockList();
+for (const [addr, prefix] of [
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
+ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
+ ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
+]) blocked.addSubnet(addr, prefix, 'ipv4');
+for (const [addr, prefix] of [
+ ['::', 128], ['::1', 128], ['100::', 64], ['2001:db8::', 32], ['2001::', 32], ['fc00::', 7],
+ ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
+]) blocked.addSubnet(addr, prefix, 'ipv6');
+
+class GuardError extends Error {
+ constructor(code, detail) { super(detail); this.code = code; }
+}
+
+/** IPv4 embedded in IPv4-mapped (::ffff:a.b.c.d), NAT64 (64:ff9b::/96) or 6to4 (2002::/16). */
+function embeddedV4(ip) {
+ const lower = ip.toLowerCase();
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
+ if (mapped) return mapped[1];
+ const full = expandV6(lower);
+ if (!full) return null;
+ const hex = (i) => parseInt(full[i], 16);
+ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
+ if (full.slice(0, 5).every(h => h === '0000') && full[5] === 'ffff') return v4(hex(6), hex(7));
+ if (full[0] === '0064' && full[1] === 'ff9b' && full.slice(2, 6).every(h => h === '0000')) return v4(hex(6), hex(7));
+ if (full[0] === '2002') return v4(hex(1), hex(2));
+ return null;
+}
+
+function expandV6(ip) {
+ if (!net.isIPv6(ip)) return null;
+ let s = ip;
+ const v4tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
+ if (v4tail) {
+ const p = v4tail[1].split('.').map(Number);
+ s = s.slice(0, -v4tail[1].length) + ((p[0] << 8) | p[1]).toString(16) + ':' + ((p[2] << 8) | p[3]).toString(16);
+ }
+ const [head, tail] = s.split('::');
+ const h = head ? head.split(':') : [];
+ const t = tail !== undefined ? (tail ? tail.split(':') : []) : [];
+ const fill = tail !== undefined ? Array(8 - h.length - t.length).fill('0') : [];
+ const parts = [...h, ...fill, ...t];
+ if (parts.length !== 8) return null;
+ return parts.map(x => x.padStart(4, '0'));
+}
+
+/**
+ * Is this IP address public unicast? It must pass this file's list AND the platform's rule
+ * (openvibe-shared/egress, which Live, Events, Chat and Tools use): the list here had drifted and
+ * let IPv4-compatible IPv6 (::127.0.0.1, ::7f00:1) and the local-use NAT64 prefix (64:ff9b:1::/48)
+ * through. test/security-ssrf.test.js holds the two rules equal.
+ */
+function isPublicAddress(ip) {
+ const family = net.isIP(ip);
+ let ok = false;
+ if (family === 4) ok = !blocked.check(ip, 'ipv4');
+ else if (family === 6) {
+ const v4 = embeddedV4(ip);
+ ok = v4 ? !blocked.check(v4, 'ipv4') : !blocked.check(ip, 'ipv6');
+ }
+ return ok && sharedEgress.isPublicAddress(ip);
+}
+
+function createGuard({ allowPrivateHosts = [], allowedPorts = [80, 443], lookupImpl = dns.lookup } = {}) {
+ const exempt = new Set(allowPrivateHosts.map(h => h.toLowerCase().replace(/^\[|\]$/g, '')));
+
+ /** Synchronous URL checks: scheme, credentials, port, IP literal. Throws GuardError. */
+ function checkUrl(input) {
+ let url;
+ try { url = new URL(input); } catch { throw new GuardError('bad_url', 'not a URL'); }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new GuardError('bad_url', `scheme ${url.protocol} is not fetched`);
+ if (url.username || url.password) throw new GuardError('bad_url', 'credentials in URLs are not allowed');
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
+ const port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80);
+ if (!exempt.has(host) && !allowedPorts.includes(port)) throw new GuardError('port_refused', `port ${port} is not allowed`);
+ if (net.isIP(host) && !exempt.has(host) && !isPublicAddress(host)) throw new GuardError('address_refused', `${host} is not a public address`);
+ return url;
+ }
+
+ /** dns.lookup replacement for http(s).request: refuses non-public answers at connect time. */
+ function lookup(hostname, options, callback) {
+ if (typeof options === 'function') { callback = options; options = {}; }
+ const opts = typeof options === 'number' ? { family: options } : { ...(options || {}) };
+ const host = String(hostname).toLowerCase();
+ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
+ if (err) return callback(err);
+ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];
+ if (!list.length) return callback(Object.assign(new Error(`no address for ${hostname}`), { code: 'ENOTFOUND' }));
+ if (!exempt.has(host)) {
+ const bad = list.find(a => !isPublicAddress(a.address));
+ if (bad) {
+ const e = new GuardError('address_refused', `${hostname} resolves to a non-public address`);
+ e.errno = 'EADDRREFUSED';
+ return callback(e);
+ }
+ }
+ if (opts.all) return callback(null, list);
+ return callback(null, list[0].address, list[0].family);
+ });
+ }
+
+ return { checkUrl, lookup, isPublicAddress };
+}
+
+module.exports = { createGuard, isPublicAddress, GuardError, embeddedV4 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * Track O: truthful readiness for GET /api/ready and the Watch gauges on GET /metrics
+ * (openvibe-shared/ready and openvibe-shared/metrics).
+ *
+ * db required a real query on the watch registry: without it nothing is served or checked
+ * network_jwks optional the Network signing keys have loaded into the SDK's JWKS client. Without them
+ * no token can be verified (the API answers 503), but checks keep running, so
+ * it degrades rather than fails
+ * checker optional the check worker is on and running, and its queue keeps up: no active watch
+ * has waited longer than OVERDUE_MS. Without it watches stop being checked
+ *
+ * Gauges: watches by health status, the check queue (due, in flight), the time of the last finished
+ * check and of the last successful one, observations by state, and the outbox backlog.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { jwksStatus } = require('openvibe-sdk/auth');
+
+const OVERDUE_MS = 15 * 60 * 1000;
+const STATUSES = ['healthy', 'failing', 'never_checked', 'waiting', 'paused', 'disabled', 'failed'];
+const OBSERVATION_STATES = ['unchanged', 'changed', 'condition_met'];
+
+function readers(db, { startedAt }) {
+ // A watch waits from when it became due, when it was last changed (a new or re-enabled watch is
+ // due at once, from next_due_at 0) or when this process started, whichever is latest.
+ const due = db.prepare(`SELECT COUNT(*) AS n, MIN(GREATEST(next_due_at, not_before, updated_at)) AS oldest FROM watches
+ WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL AND next_due_at <= @now AND not_before <= @now`);
+ const lastCheck = db.prepare('SELECT finished_at FROM check_runs ORDER BY rid DESC LIMIT 1');
+ const lastSuccess = db.prepare('SELECT MAX(last_success_at) AS t FROM watches');
+ const observations = db.prepare(`SELECT COUNT(*) AS n,
+ SUM(CASE WHEN condition_met = 1 THEN 1 ELSE 0 END) AS met,
+ SUM(CASE WHEN condition_met = 0 AND changed = 1 THEN 1 ELSE 0 END) AS changed FROM observations`);
+ return {
+ /** Active watches whose turn has come, and how long the oldest has waited. */
+ async queue(t) {
+ const r = await due.get({ now: t });
+ return { due: r.n, oldest_wait_ms: r.n ? Math.max(0, t - Math.max(r.oldest, startedAt)) : 0 };
+ },
+ lastCheckAt: async () => { const r = await lastCheck.get(); return r ? r.finished_at : null; },
+ lastSuccessAt: async () => (await lastSuccess.get()).t,
+ observations: async () => {
+ const r = await observations.get();
+ const n = Number(r.n) || 0;
+ const met = Number(r.met) || 0;
+ const changed = Number(r.changed) || 0;
+ return { unchanged: n - met - changed, changed, condition_met: met };
+ },
+ };
+}
+
+function createWatchReadiness({ db, config, registry, check, observations, scheduler, outbox, relay, now, release = null }) {
+ const read = readers(db, { startedAt: now() });
+ const iso = (v) => (v == null ? null : new Date(v).toISOString());
+ return createReadiness({
+ service: 'watch',
+ release,
+ checks: [
+ // A real round trip that names the store (postgresql / pglite), and the schema present.
+ { name: 'db', required: true, check: async () => { const r = await db.ready(); if (!r.ok) return r.error; await db.prepare('SELECT COUNT(*) AS n FROM watches').get(); return { ok: true, detail: r.detail }; } },
+ { name: 'network_jwks', required: false, check: () => {
+ // The SDK's client for the JWKS this service verifies against (server/index.js started it).
+ // Public: counts and times only — never the internal JWKS URL and never the fetch error
+ // behind it (both are logged by the SDK client, not answered).
+ const st = jwksStatus().find((s) => s.url === config.jwksUrl) || null;
+ if (!st) return 'the Network JWKS client has not started: no token can be verified';
+ const detail = { keys: st.keys || 0, stale: Boolean(st.stale), failures: st.failures || 0, fetchedAt: iso(st.fetchedAt) };
+ if (!st.ready) return { ok: false, error: 'Network signing key not loaded yet: no token can be verified', detail };
+ return { ok: true, detail };
+ } },
+ {
+ name: 'checker', required: false,
+ check: async () => {
+ const q = await read.queue(now());
+ const detail = {
+ enabled: config.worker.enabled, running: scheduler.running(), in_flight: check.inflight().length,
+ max_concurrent: config.worker.maxConcurrent, due: q.due, oldest_wait_ms: q.oldest_wait_ms,
+ last_check_at: iso(await read.lastCheckAt()),
+ };
+ if (!config.worker.enabled) return { ok: false, error: 'the check worker is off (WATCH_WORKER=off): nothing is checked', detail };
+ if (!scheduler.running()) return { ok: false, error: 'the check worker is not running', detail };
+ if (q.oldest_wait_ms > OVERDUE_MS) return { ok: false, error: `the check queue is behind: ${q.due} watch(es) due, the oldest for ${Math.round(q.oldest_wait_ms / 60000)} min`, detail };
+ return { ok: true, detail };
+ },
+ },
+ ],
+ details: async (body) => {
+ const dbOk = body.checks.db.status === 'ok';
+ let watches = null;
+ if (dbOk) {
+ watches = {};
+ for (const r of await registry.all(null)) {
+ const s = registry.health(r).status;
+ watches[s] = (watches[s] || 0) + 1;
+ }
+ }
+ return {
+ watches,
+ checks_in_flight: check.inflight().length,
+ observations: dbOk ? await read.observations() : null,
+ outbox: dbOk ? { pending: await outbox.pending(), rejected: await outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
+ };
+ },
+ });
+}
+
+/** Watch gauges on the openvibe-shared/metrics registry. */
+function registerWatchGauges(registry, { db, watches, check, outbox, now }) {
+ const read = readers(db, { startedAt: now() });
+ const seconds = (ms) => (ms == null ? null : ms / 1000);
+ registry.gauge({
+ name: 'watch_watches', help: 'Watches by health status', labelNames: ['status'],
+ collect: async () => {
+ const n = Object.fromEntries(STATUSES.map((s) => [s, 0]));
+ for (const r of await watches.all(null)) { const s = watches.health(r).status; n[s] = (n[s] || 0) + 1; }
+ return Object.entries(n).map(([status, value]) => ({ labels: { status }, value }));
+ },
+ });
+ registry.gauge({
+ name: 'watch_observations', help: 'Observations held, by state', labelNames: ['state'],
+ collect: async () => Object.entries(await read.observations()).map(([state, value]) => ({ labels: { state }, value })),
+ });
+ registry.gauge({ name: 'watch_checks_due', help: 'Active watches whose next check is due now (the check queue)', collect: async () => (await read.queue(now())).due });
+ registry.gauge({ name: 'watch_checks_oldest_wait_seconds', help: 'How long the longest-waiting due watch has waited', collect: async () => (await read.queue(now())).oldest_wait_ms / 1000 });
+ registry.gauge({ name: 'watch_checks_in_flight', help: 'Checks in progress', collect: () => check.inflight().length });
+ // Left out until there is a check: a timestamp of 0 would read as "1970", not "never".
+ registry.gauge({ name: 'watch_last_check_timestamp_seconds', help: 'When the last check finished (any outcome), Unix seconds', collect: async () => seconds(await read.lastCheckAt()) });
+ registry.gauge({ name: 'watch_last_success_timestamp_seconds', help: 'When a watch was last checked successfully, Unix seconds', collect: async () => seconds(await read.lastSuccessAt()) });
+ registry.gauge({ name: 'watch_outbox_pending', help: 'Events waiting in the outbox', collect: async () => await outbox.pending() });
+}
+
+
+module.exports = { createWatchReadiness, registerWatchGauges, OVERDUE_MS, OBSERVATION_STATES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * Observations: what a check recorded for a watch — the extracted value exactly as the source
+ * stated it, when it was seen, which check run saw it, whether it changed and whether the watch's
+ * condition held. Rows are immutable (watch.observation@1) and carry a capped snapshot of the body
+ * the value came from until `retained_until`, which the watch's retention sets.
+ *
+ * value_hash is SHA-256 over the canonical JSON of the value, so equal values hash equal whatever
+ * order their keys came in; previous_hash is the value_hash of the observation before it (null for
+ * the first), which is what makes "changed" answerable without reading the earlier row back.
+ */
+const { validate, ids: contractIds } = require('openvibe-contracts');
+const { hashValue } = require('./util');
+
+const DAY_MS = 24 * 3600 * 1000;
+
+function createObservations({ db, now = () => Date.now(), ids = contractIds }) {
+ const st = {
+ insert: db.prepare(`INSERT INTO observations (id, watch_id, check_run_id, observed_at, value, value_hash, previous_hash,
+ changed, condition_met, snapshot, retained_until)
+ VALUES (@id, @watch_id, @check_run_id, @observed_at, @value, @value_hash, @previous_hash, @changed, @condition_met, @snapshot, @retained_until)`),
+ latest: db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid DESC LIMIT 1'),
+ before: db.prepare('SELECT * FROM observations WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT 1'),
+ page: db.prepare('SELECT * FROM observations WHERE watch_id = ? AND rid < ? ORDER BY rid DESC LIMIT ?'),
+ count: db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?'),
+ trail: db.prepare('SELECT observed_at, condition_met FROM observations WHERE watch_id = ? ORDER BY rid DESC LIMIT 200'),
+ prune: db.prepare('DELETE FROM observations WHERE retained_until IS NOT NULL AND retained_until <= ?'),
+ };
+
+ const bool = (v) => Boolean(Number(v));
+
+ /** observation.v1.json, validated with the released schema. */
+ function view(row) {
+ const v = {
+ id: row.id,
+ watch_id: row.watch_id,
+ check_run_id: row.check_run_id,
+ observed_at: new Date(row.observed_at).toISOString(),
+ // jsonb comes back parsed from both adapters (openvibe-sdk/db): the value as stored.
+ value: row.value,
+ value_hash: row.value_hash,
+ previous_hash: row.previous_hash == null ? null : row.previous_hash,
+ changed: bool(row.changed),
+ condition_met: bool(row.condition_met),
+ snapshot: row.snapshot == null ? null : row.snapshot,
+ };
+ const r = validate('watch.observation@1', v);
+ if (!r.valid) throw new Error(`observation view does not match watch.observation@1: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ return v;
+ }
+
+ /**
+ * Build the row an observation will be (reads the previous one for previous_hash; writes
+ * nothing). The caller inserts it inside the transaction that also writes the check run and the
+ * events, so all three commit together.
+ */
+ async function build({ watch_id, check_run_id, value, snapshot = null, changed = false, condition_met = false, retentionDays = null, keepSnapshots = true, observed_at = null }) {
+ const at = observed_at == null ? now() : observed_at;
+ const previous = await st.latest.get(watch_id);
+ const v = value === undefined ? null : value;
+ return {
+ id: `wco_${ids.ulid(at)}`,
+ watch_id,
+ check_run_id,
+ observed_at: at,
+ value: JSON.stringify(v),
+ value_hash: hashValue(v),
+ previous_hash: previous ? previous.value_hash : null,
+ changed: changed ? 1 : 0,
+ condition_met: condition_met ? 1 : 0,
+ snapshot: keepSnapshots && snapshot != null ? snapshot : null,
+ retained_until: retentionDays == null ? null : at + retentionDays * DAY_MS,
+ };
+ }
+
+ /** Insert a built row (inside the caller's transaction). */
+ async function insert(row) {
+ await st.insert.run(row);
+ return row;
+ }
+
+ /** build + insert, for a caller that has nothing else to commit with it. */
+ async function record(input) {
+ return await insert(await build(input));
+ }
+
+ async function latest(watch_id) {
+ return await st.latest.get(watch_id);
+ }
+
+ /** The observation immediately before `row` (the previous_value of a trigger fired on it). */
+ async function before(row) {
+ return await st.before.get(row.watch_id, row.rid);
+ }
+
+ /** Newest first; `before` is the rid of the last row of the previous page. */
+ async function list(watch_id, { before = null, limit = 50 } = {}) {
+ const b = Number.isSafeInteger(Number(before)) && before != null ? Number(before) : Number.MAX_SAFE_INTEGER;
+ return await st.page.all(watch_id, b, limit);
+ }
+
+ async function count(watch_id) {
+ return (await st.count.get(watch_id)).n;
+ }
+
+ /**
+ * When the current run of observations whose condition held began (ms), or null when the last
+ * observation's condition did not hold (or there is none). This is what `for_sec` measures: a
+ * condition that has held continuously for that long is met, not a condition seen once.
+ */
+ async function heldSince(watch_id) {
+ const rows = await st.trail.all(watch_id);
+ let since = null;
+ for (const r of rows) {
+ if (!bool(r.condition_met)) break;
+ since = r.observed_at;
+ }
+ return since;
+ }
+
+ /** Drop observations past their retention. Rows a check run still refers to are pruned too. */
+ async function prune(at = now()) {
+ return (await st.prune.run(at)).changes;
+ }
+
+ return { build, insert, record, latest, before, list, count, heldSince, prune, view };
+}
+
+module.exports = { createObservations, DAY_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,354 @@
@@ -0,0 +1,354 @@
+'use strict';
+/**
+ * The watch registry: validation, CRUD and the view.
+ *
+ * A Watch is a user-defined persistent observation (plan T18): where it watches (source), how often
+ * or what pushes to it (cadence / event pattern), what value it takes from the answer (extraction),
+ * how that value is compared with the last observation (comparison), the condition that fires
+ * (condition), the actions it wakes (action, executed from step 4), its budget and its retention.
+ *
+ * Validation is openvibe-contracts' own validator: every request body is checked against
+ * watch.watch-request@1 and every answer against watch.watch@1 (never a hand copy of the schema).
+ * The rules a schema cannot carry live here: a create needs source, extraction, condition and
+ * action; a source that polls (http, feed, api, node, run) needs a cadence with every_sec, and an
+ * event or webhook source never polls (cadence null).
+ *
+ * Ownership is the authority: every read and write is scoped to the acting subject (principal.sub,
+ * or the person a first-party service names with X-OV-Subject), and another owner's watch is not
+ * found — 404, never 403 (an id must not confirm that someone else's watch exists).
+ */
+const { validate, ids: contractIds } = require('openvibe-contracts');
+
+/** Source kinds that reach out on a cadence; event and webhook sources are pushed to instead. */
+const POLLING = new Set(['http', 'feed', 'api', 'node', 'run']);
+const PUSHED = new Set(['event', 'webhook']);
+const RUN_STATES = ['ok', 'not_modified', 'no_change', 'changed', 'condition_met', 'http_error', 'timeout', 'parse_error', 'rate_limited', 'budget_exceeded', 'skipped', 'disabled'];
+const STATUSES = ['active', 'paused', 'disabled', 'failed'];
+/** Headers the fetcher decides; a watch may never override them (SSRF and conditional-GET safety). */
+const FORBIDDEN_HEADERS = new Set(['host', 'cookie', 'connection', 'content-length', 'transfer-encoding', 'user-agent', 'accept-encoding', 'if-none-match', 'if-modified-since']);
+
+class RegistryError extends Error {
+ constructor(detail, code = 'watch.invalid') { super(detail); this.code = code; }
+}
+
+const parse = (v) => (v == null ? null : (typeof v === 'string' ? JSON.parse(v) : v));
+
+/** A name a watch can always carry, taken from the source it watches (watch.watch@1 requires one). */
+function defaultName(source) {
+ if (!source || typeof source !== 'object') return 'watch';
+ if (source.url) {
+ try {
+ const u = new URL(source.url);
+ return `${u.host}${u.pathname}`.slice(0, 120);
+ } catch { return String(source.url).slice(0, 120); }
+ }
+ if (source.pattern) return source.pattern.slice(0, 120);
+ if (source.endpoint) return `webhook ${source.endpoint}`.slice(0, 120);
+ if (source.node_id) return `node ${source.node_id}`.slice(0, 120);
+ if (source.runtime) return `run ${source.runtime}`.slice(0, 120);
+ return source.kind ? `${source.kind} watch` : 'watch';
+}
+
+/**
+ * Validate a watch-request body (openvibe-contracts' validator). `create` adds the rules of a new
+ * watch. Returns the body unchanged; throws RegistryError (watch.invalid) with a readable detail.
+ */
+function validateRequest(body, { create = false } = {}) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RegistryError('body must be a watch object');
+ const v = validate('watch.watch-request@1', body);
+ if (!v.valid) throw new RegistryError(`body does not match watch.watch-request@1: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ if (create) {
+ for (const field of ['source', 'extraction', 'condition', 'action']) {
+ if (body[field] === undefined || body[field] === null) throw new RegistryError(`a new watch requires ${field}`);
+ }
+ }
+ const cadence = body.cadence === undefined ? undefined : body.cadence;
+ if (body.source && body.source.headers) {
+ // A watch may add headers to its own request, but not the ones that decide who is asking or
+ // what was asked: the fetcher owns those (OpenVibe.Sources refuses the same set).
+ for (const name of Object.keys(body.source.headers)) {
+ if (FORBIDDEN_HEADERS.has(name.toLowerCase())) throw new RegistryError(`source.headers.${name} is set by the fetcher and cannot be overridden`);
+ }
+ }
+ if (body.source) {
+ const kind = body.source.kind;
+ if (POLLING.has(kind)) {
+ if (cadence === null || cadence === undefined) throw new RegistryError(`a ${kind} source needs a cadence (every_sec)`);
+ if (!Number.isInteger(cadence.every_sec)) throw new RegistryError('cadence.every_sec is required for a source that polls');
+ }
+ if (PUSHED.has(kind) && cadence) throw new RegistryError(`a ${kind} source is pushed to and never polls: cadence must be null`);
+ // A HEAD source states no body, so only a document/record extraction can read one: refuse
+ // the misconfiguration here instead of recording `parse_error` on every check.
+ if (body.source.method === 'HEAD' && !['json', 'jsonpath'].includes((body.extraction || {}).kind)) {
+ throw new RegistryError('a HEAD source states no body: its extraction must be json or jsonpath (status, etag, last_modified, content_type, content_length)');
+ }
+ }
+ return body;
+}
+
+/**
+ * When an active, polling watch is next due: every_sec from `at` plus its jitter, or — after
+ * failures — an exponential backoff (every_sec · 2^(failures-1), capped at the worker's maximum,
+ * the same shape OpenVibe.Sources uses for a failing source). 0 for a source that never polls.
+ */
+function nextDue(rec, at, { failures = 0, maxBackoffMs = Infinity } = {}) {
+ if (!rec || !rec.cadence || !rec.cadence.every_sec) return 0;
+ const every = rec.cadence.every_sec * 1000;
+ if (failures > 0) return at + Math.min(every * 2 ** Math.min(failures - 1, 10), Math.max(maxBackoffMs, every));
+ const jitter = Math.max(0, Number(rec.cadence.jitter_sec) || 0);
+ return at + every + (jitter ? Math.floor(Math.random() * jitter * 1000) : 0);
+}
+
+/** Validate an answer (openvibe-contracts' validator). A view this service produces must always fit. */
+function validateView(view) {
+ const v = validate('watch.watch@1', view);
+ if (!v.valid) throw new RegistryError(`watch view does not match watch.watch@1: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`, 'watch.internal');
+ return view;
+}
+
+function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox = null }) {
+ const st = {
+ get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
+ insert: db.prepare(`INSERT INTO watches (id, project_id, owner_sub, name, status, source, cadence, extraction, comparison, condition,
+ action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)
+ VALUES (@id, @project_id, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
+ @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),
+ };
+
+ /** Every column a patch may write, and nothing else. */
+ const COLUMNS = {
+ name: (v) => v,
+ labels: (v) => JSON.stringify(v || {}),
+ status: (v) => v,
+ source: (v) => JSON.stringify(v),
+ cadence: (v) => (v == null ? null : JSON.stringify(v)),
+ extraction: (v) => JSON.stringify(v),
+ comparison: (v) => (v == null ? null : JSON.stringify(v)),
+ condition: (v) => JSON.stringify(v),
+ action: (v) => JSON.stringify(v || []),
+ budget: (v) => JSON.stringify(v || {}),
+ retention: (v) => JSON.stringify(v || {}),
+ project_id: (v) => v,
+ };
+
+ /** The stored record, request-shaped (what a PATCH merges into). */
+ function fromRow(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ project_id: row.project_id,
+ owner: row.owner_sub,
+ name: row.name,
+ labels: parse(row.labels) || {},
+ status: row.status,
+ source: parse(row.source),
+ cadence: parse(row.cadence),
+ extraction: parse(row.extraction) || {},
+ comparison: parse(row.comparison),
+ condition: parse(row.condition) || {},
+ action: parse(row.action) || [],
+ budget: parse(row.budget) || {},
+ retention: parse(row.retention) || {},
+ };
+ }
+
+ /** What the operator sees about a watch's runtime, never invented: counts and times only. */
+ function health(row) {
+ const iso = (v) => (v == null ? null : new Date(v).toISOString());
+ const polls = row.cadence != null;
+ let status;
+ if (row.status === 'paused') status = 'paused';
+ else if (row.status === 'disabled') status = 'disabled';
+ else if (row.status === 'failed') status = 'failed';
+ else if (!polls) status = 'waiting';
+ else if (row.last_check_at == null) status = 'never_checked';
+ else if (row.consecutive_failures > 0) status = 'failing';
+ else status = 'healthy';
+ return {
+ status,
+ last_check_at: iso(row.last_check_at),
+ last_success_at: iso(row.last_success_at),
+ last_state: row.last_state,
+ consecutive_failures: row.consecutive_failures,
+ next_due_at: row.status === 'active' && polls ? iso(Math.max(row.next_due_at, row.not_before)) : null,
+ not_before: iso(row.not_before || null),
+ };
+ }
+
+ /** row → watch.watch@1, validated with the released schema. */
+ function view(row) {
+ const rec = fromRow(row);
+ return validateView({
+ id: rec.id,
+ project_id: rec.project_id,
+ owner: rec.owner,
+ name: rec.name,
+ labels: rec.labels,
+ status: rec.status,
+ source: rec.source,
+ cadence: rec.cadence,
+ extraction: rec.extraction,
+ comparison: rec.comparison,
+ condition: rec.condition,
+ action: rec.action,
+ budget: rec.budget,
+ retention: rec.retention,
+ health: health(row),
+ created_at: new Date(row.created_at).toISOString(),
+ updated_at: new Date(row.updated_at).toISOString(),
+ });
+ }
+
+ const ownerOf = (principal) => (principal && (principal.subject || principal.sub)) || null;
+
+ /** The next time an active, polling watch is due: every_sec from now, plus its jitter. */
+ function arm(rec, at) {
+ return nextDue(rec, at, {});
+ }
+
+ /** The row when it exists AND belongs to the acting subject; null otherwise (routes answer 404). */
+ async function get(id, principal) {
+ const row = await st.get.get(String(id || ''));
+ if (!row) return null;
+ const owner = ownerOf(principal);
+ if (owner && row.owner_sub !== owner) return null;
+ return row;
+ }
+
+ async function create(body, principal) {
+ const req = validateRequest(body, { create: true });
+ const owner = ownerOf(principal);
+ if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');
+ const t = now();
+ const rec = {
+ name: req.name || defaultName(req.source),
+ status: req.status || 'active',
+ source: req.source,
+ cadence: req.cadence == null ? null : req.cadence,
+ extraction: req.extraction,
+ comparison: req.comparison == null ? null : req.comparison,
+ condition: req.condition,
+ action: req.action,
+ budget: req.budget || {},
+ retention: req.retention || {},
+ labels: req.labels || {},
+ };
+ const row = {
+ id: `wch_${ids.ulid(t)}`,
+ project_id: null,
+ owner_sub: owner,
+ name: rec.name,
+ status: rec.status,
+ source: JSON.stringify(rec.source),
+ cadence: rec.cadence ? JSON.stringify(rec.cadence) : null,
+ extraction: JSON.stringify(rec.extraction),
+ comparison: rec.comparison ? JSON.stringify(rec.comparison) : null,
+ condition: JSON.stringify(rec.condition),
+ action: JSON.stringify(rec.action),
+ budget: JSON.stringify(rec.budget),
+ retention: JSON.stringify(rec.retention),
+ labels: JSON.stringify(rec.labels),
+ // A new active polling watch takes its first reading on the next scheduler tick (as Sources arms a new
+ // source), not one cadence later; resume/patch re-arm by cadence below.
+ next_due_at: rec.status === 'active' ? t : 0,
+ created_at: t,
+ updated_at: t,
+ updated_by: owner,
+ };
+ // The row and its event commit together: an event exists exactly when its effect does.
+ await db.tx(async () => {
+ await st.insert.run(row);
+ if (outbox) await outbox.enqueue(watchEvent('watch.watch.created', row, owner, { name: rec.name, status: rec.status }));
+ });
+ return await st.get.get(row.id);
+ }
+
+ /** Only the fields the patch names are written; the merged watch is validated first. */
+ async function patch(id, changes, principal) {
+ const row = await get(id, principal);
+ if (!row) return null;
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new RegistryError('body must be a watch object');
+ const current = fromRow(row);
+ const merged = { ...current, ...changes };
+ delete merged.id; // an id never changes
+ delete merged.owner; // ownership never changes (a watch is the subject's)
+ // project_id is not part of watch.watch-request@1: the row's own null is dropped, and a
+ // caller that sends one fails the schema check below rather than being silently ignored.
+ if (changes.project_id === undefined) delete merged.project_id;
+ validateRequest(merged);
+ const sets = [];
+ const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };
+ for (const [k, v] of Object.entries(changes)) {
+ if (!COLUMNS[k]) continue;
+ sets.push(`${k} = @${k}`);
+ args[k] = COLUMNS[k](v);
+ }
+ if (!sets.length) return await st.get.get(row.id);
+ // A changed watch gets a fresh cadence: re-arm from now, so editing never fires late work.
+ const rec = { ...current, ...changes };
+ args.next_due_at = rec.status === 'active' ? arm(rec, args.updated_at) : 0;
+ sets.push('next_due_at = @next_due_at');
+ sets.push('updated_at = @updated_at', 'updated_by = @updated_by');
+ const type = changes.status === 'paused' ? 'watch.watch.paused' : 'watch.watch.updated';
+ const touched = Object.keys(changes).filter((k) => k !== 'labels');
+ await db.tx(async () => {
+ await db.prepare(`UPDATE watches SET ${sets.join(', ')} WHERE id = @id AND deleted_at IS NULL`).run(args);
+ if (outbox && touched.length) {
+ await outbox.enqueue(watchEvent(type, row, row.owner_sub, { name: rec.name, status: rec.status, fields: touched.sort() }));
+ }
+ });
+ return await st.get.get(row.id);
+ }
+
+ /** Soft delete: the rows an observation refers to stay resolvable. */
+ async function remove(id, principal) {
+ const row = await get(id, principal);
+ if (!row) return null;
+ const t = now();
+ await db.tx(async () => {
+ await db.prepare('UPDATE watches SET deleted_at = ?, updated_at = ?, next_due_at = 0 WHERE id = ?').run(t, t, row.id);
+ if (outbox) await outbox.enqueue(watchEvent('watch.watch.removed', row, row.owner_sub, { name: row.name, status: row.status }));
+ });
+ return { id: row.id, deleted: true };
+ }
+
+ /**
+ * Watches, newest first, scoped to the acting subject. The keyset is `id`: a wch_ ULID is
+ * creation order, so `id < before` is exactly "older than the last row of the previous page",
+ * with no ties.
+ *
+ * A null principal means NO scoping and is the service's own view (the gauges and the readiness
+ * detail): every API route passes req.principal, which the capability guard always sets.
+ */
+ async function all(principal, { status = null, project_id = null, before = null, limit = 50 } = {}) {
+ const owner = principal == null ? null : ownerOf(principal);
+ const rows = await db.prepare(`SELECT * FROM watches WHERE deleted_at IS NULL
+ AND (@owner::text IS NULL OR owner_sub = @owner)
+ AND (@status::text IS NULL OR status = @status)
+ AND (@project_id::text IS NULL OR project_id = @project_id)
+ AND (@before::text IS NULL OR id < @before)
+ ORDER BY id DESC LIMIT @limit`).all({ owner, status, project_id, before, limit });
+ return rows;
+ }
+
+ /** Active watches whose turn has come, oldest due first (the scheduler's queue). */
+ async function due(limit) {
+ return await db.prepare(`SELECT id FROM watches WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL
+ AND next_due_at <= @now AND not_before <= @now ORDER BY next_due_at, id LIMIT @limit`).all({ now: now(), limit });
+ }
+
+ /** The envelope every watch-lifecycle event shares: the watch's own subject, internal. */
+ function watchEvent(event_type, row, owner, payload) {
+ return {
+ event_type,
+ subject: { type: 'watch', id: row.id },
+ payload: { watch_id: row.id, owner, ...payload },
+ visibility: 'internal',
+ priority: 'low',
+ };
+ }
+
+ return { validate: validateRequest, validateView, view, health, fromRow, create, get, all, patch, remove, due };
+}
+
+module.exports = { createRegistry, validateRequest, validateView, RegistryError, defaultName, nextDue, POLLING, PUSHED, RUN_STATES, STATUSES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * In-process scheduler: every WATCH_TICK_MS, start checks for active watches whose next_due_at and
+ * not_before have passed, oldest due first, up to WATCH_MAX_CONCURRENT checks at a time. The due
+ * query is the one the migration's partial index serves, and check.run() itself refuses a second
+ * check of a watch already in flight (so a manual check and a scheduled one never overlap).
+ *
+ * Copied from OpenVibe.Sources/server/scheduler.js with Watch's due query (watches, not sources;
+ * cadence IS NOT NULL, since a source pushed to by an event or webhook never polls).
+ */
+function createScheduler({ db, check, config, now = () => Date.now(), log = console }) {
+ const due = db.prepare(`SELECT id FROM watches WHERE status = 'active' AND deleted_at IS NULL AND cadence IS NOT NULL
+ AND next_due_at <= @now AND not_before <= @now ORDER BY next_due_at, id LIMIT @limit`);
+ let timer = null;
+ const running = new Set();
+
+ async function tick() {
+ const free = config.worker.maxConcurrent - running.size;
+ if (free <= 0) return [];
+ const ids = (await due.all({ now: now(), limit: free + running.size })).map(r => r.id).filter(id => !running.has(id)).slice(0, free);
+ for (const id of ids) {
+ running.add(id);
+ check.run(id, { trigger: 'schedule' })
+ .catch(err => log.error(`[scheduler] ${id}: ${err.stack || err}`))
+ .finally(() => running.delete(id));
+ }
+ return ids;
+ }
+
+ function start() {
+ if (timer) return;
+ timer = setInterval(async () => { try { await tick(); } catch (err) { log.error(`[scheduler] tick: ${err.message}`); } }, config.worker.tickMs);
+ timer.unref?.();
+ }
+
+ async function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ // let checks in flight finish (each is bounded by the fetch timeout)
+ const deadline = Date.now() + 15000;
+ while (running.size && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));
+ }
+
+ return { start, stop, tick, running: () => Boolean(timer), active: () => [...running] };
+}
+
+module.exports = { createScheduler };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * Per-host request spacing shared by robots.txt fetches and ingestion: before any request to a
+ * host, wait until max(SOURCES_HOST_MIN_INTERVAL_MS, the source's min interval, the host's robots
+ * Crawl-delay) has passed since the previous request to it. Requests to one host are serialized.
+ */
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+function createSpacer({ hostMinIntervalMs = 1000, now = () => Date.now() } = {}) {
+ const hosts = new Map(); // host → { last, crawlDelayMs, chain }
+
+ function state(host) {
+ let h = hosts.get(host);
+ if (!h) { h = { last: 0, crawlDelayMs: 0, chain: Promise.resolve() }; hosts.set(host, h); }
+ return h;
+ }
+
+ function space(host, minMs = 0) {
+ const h = state(host);
+ const turn = h.chain.then(async () => {
+ const gap = Math.max(hostMinIntervalMs, minMs || 0, h.crawlDelayMs);
+ const wait = h.last + gap - now();
+ if (wait > 0) await sleep(wait);
+ h.last = now();
+ });
+ h.chain = turn.catch(() => {});
+ return turn;
+ }
+
+ function setCrawlDelay(host, sec) {
+ if (sec == null) return;
+ state(host).crawlDelayMs = Math.min(sec * 1000, 3600 * 1000);
+ }
+
+ return { space, setCrawlDelay };
+}
+
+module.exports = { createSpacer };
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * Helpers the carriers and the extractor share, modelled on OpenVibe.Sources/server/adapters/util.js:
+ * a fetched body becomes a value and nothing else. A field the source did not state stays null —
+ * never guessed, defaulted or invented.
+ */
+const crypto = require('crypto');
+const { XMLParser } = require('fast-xml-parser');
+
+class ParseError extends Error {
+ constructor(detail) { super(detail); this.code = 'parse_error'; }
+}
+
+/** Body hash: SHA-256 hex, over the bytes as received. */
+function sha256(buf) {
+ return crypto.createHash('sha256').update(buf).digest('hex');
+}
+
+/** Stable JSON of a value: object keys sorted, so equal values have one hash. */
+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 === undefined ? null : value);
+}
+
+/** SHA-256 hex of the canonical value (observation.value_hash). */
+function hashValue(value) {
+ return sha256(Buffer.from(canonicalJson(value), 'utf8'));
+}
+
+/** Decode a body: charset from Content-Type, else the XML declaration / meta tag, else UTF-8. */
+function decodeBody(buf, contentType) {
+ let charset = /charset=["']?([\w-]+)/i.exec(String(contentType || ''))?.[1];
+ if (!charset) {
+ const head = buf.subarray(0, 1024).toString('latin1');
+ charset = /<\?xml[^>]*encoding=["']([\w-]+)["']/i.exec(head)?.[1] || /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1];
+ }
+ let label = String(charset || 'utf-8').toLowerCase();
+ if (label === 'iso-8859-1' || label === 'latin1') label = 'windows-1252';
+ try {
+ return new TextDecoder(label, { fatal: false }).decode(buf).replace(/^/, '');
+ } catch {
+ return new TextDecoder('utf-8').decode(buf).replace(/^/, '');
+ }
+}
+
+const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', hellip: '…', mdash: '—', ndash: '–', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };
+
+function decodeEntities(s) {
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
+ if (e[0] === '#') {
+ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : '';
+ }
+ return ENTITIES[e.toLowerCase()] ?? m;
+ });
+}
+
+/** HTML or text → plain text, whitespace collapsed, capped. Scripts and styles are dropped whole. */
+function toText(value, max = 1000) {
+ if (value == null) return null;
+ // Bounded input: only a short value survives anyway, and the regexes below stay linear.
+ let s = String(typeof value === 'object' ? (value['#text'] ?? value.__cdata ?? '') : value).slice(0, 100000);
+ s = decodeEntities(String(s))
+ .replace(/<(script|style|noscript)[\s\S]*?<\/\1>/gi, ' ')
+ .replace(/<br\s*\/?>|<\/p>|<\/li>|<\/h\d>/gi, ' ')
+ .replace(/<[^>]*>/g, ' ');
+ s = decodeEntities(s).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').replace(/\s+/g, ' ').trim();
+ if (!s) return null;
+ return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;
+}
+
+/** RFC 822 / RFC 3339 / ISO dates → ISO string; anything unparseable → null (never "now"). */
+function toIsoDate(value) {
+ if (value == null) return null;
+ const s = decodeEntities(String(typeof value === 'object' ? value['#text'] ?? '' : value)).trim();
+ if (!s || s.length > 64) return null;
+ // Refuse bare numbers: ambiguous epoch units are guesses.
+ if (/^\d+$/.test(s)) return null;
+ const t = Date.parse(s);
+ if (!Number.isFinite(t)) return null;
+ const year = new Date(t).getUTCFullYear();
+ if (year < 1990 || year > 2200) return null;
+ return new Date(t).toISOString();
+}
+
+const TRACKING = /^(utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid|_hsenc|_hsmi|mkt_tok)$/i;
+
+/** http(s) URL → canonical form (lowercase host, no fragment, no tracking params, no default port); else null. */
+function canonicalUrl(value, base) {
+ if (value == null) return null;
+ const raw = decodeEntities(String(value)).trim();
+ if (!raw || raw.length > 2048) return null;
+ let url;
+ try { url = base ? new URL(raw, base) : new URL(raw); } catch { return null; }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+ if (url.username || url.password) return null;
+ url.hash = '';
+ url.hostname = url.hostname.toLowerCase();
+ if ((url.protocol === 'https:' && url.port === '443') || (url.protocol === 'http:' && url.port === '80')) url.port = '';
+ for (const k of [...url.searchParams.keys()]) if (TRACKING.test(k)) url.searchParams.delete(k);
+ return url.toString();
+}
+
+/** A plain identifier string (guid, id…) or null. */
+function toId(value, max = 512) {
+ if (value == null) return null;
+ const s = decodeEntities(String(typeof value === 'object' ? value['#text'] ?? '' : value)).trim();
+ return s && s.length <= max ? s : null;
+}
+
+function asArray(v) {
+ if (v == null) return [];
+ return Array.isArray(v) ? v : [v];
+}
+
+/**
+ * Parse XML safely: a DOCTYPE with entity declarations is refused (entity expansion is how XML
+ * bombs work) and the parser expands no entities at all — the helpers above decode the predefined
+ * and numeric ones per value.
+ */
+function parseXml(text) {
+ if (/<!DOCTYPE[^>]*\[/i.test(text) || /<!ENTITY/i.test(text)) throw new ParseError('XML with entity declarations is refused');
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ removeNSPrefix: false,
+ processEntities: false,
+ trimValues: true,
+ parseTagValue: false,
+ parseAttributeValue: false,
+ cdataPropName: '__cdata',
+ });
+ try {
+ return parser.parse(text, true);
+ } catch (err) {
+ throw new ParseError(`not well-formed XML: ${String(err.message).split('\n')[0].slice(0, 200)}`);
+ }
+}
+
+/** First non-empty text value of the given keys on an XML node. */
+function pick(node, ...keys) {
+ for (const k of keys) {
+ const v = node && node[k];
+ if (v == null) continue;
+ const first = Array.isArray(v) ? v[0] : v;
+ if (first == null) continue;
+ if (typeof first === 'object') {
+ const t = first['#text'] ?? first.__cdata;
+ if (t != null && String(t).trim()) return t;
+ if (first['@_href']) return first;
+ continue;
+ }
+ if (String(first).trim()) return first;
+ }
+ return null;
+}
+
+module.exports = {
+ ParseError, sha256, canonicalJson, hashValue, decodeBody, decodeEntities, toText, toIsoDate, canonicalUrl,
+ toId, asArray, parseXml, pick,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+/**
+ * The API: one capability per route (service and person tokens), owner scoping (404, never 403),
+ * the released validator behind 422, the check/observations/checks reads, and per-actor limits.
+ */
+const assert = require('assert');
+const { validate } = require('openvibe-contracts');
+const { boot, request, serviceToken, userToken, site, suite, watchDef, newUser } = require('./helpers');
+
+const t = suite('api');
+let svc;
+let web;
+let price = '19.99';
+
+const READER = serviceToken('news', ['watch.watch.read']);
+const OBSERVER = serviceToken('news', ['watch.observation.read']);
+const RUNNER = serviceToken('news', ['watch.check.run']);
+const MANAGER = serviceToken('site', ['watch.watch.manage']);
+const ALL = serviceToken('site', ['watch.*']);
+const me = newUser();
+const other = newUser();
+const ME = userToken(me);
+const OTHER = userToken(other);
+
+const api = (method, p, opts = {}) => request(svc.base, method, p, opts);
+const mine = (overrides = {}) => watchDef({
+ source: { kind: 'http', url: `${web.origin}/price`, format: null },
+ name: 'my watch',
+ ...overrides,
+});
+
+t('boot', async () => {
+ web = await site({ '/price': () => ({ body: price }) });
+ svc = await boot();
+});
+
+t('every route needs a token, and the one capability it serves', async () => {
+ assert.strictEqual((await api('GET', '/api/v1/watches')).status, 401);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: 'not-a-token' })).status, 401);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.watch.manage']) })).status, 403);
+ assert.strictEqual((await api('POST', '/api/v1/watches', { token: READER, body: mine() })).status, 403);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.watch.read'], { aud: 'openvibe.search' }) })).status, 401, 'another audience');
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: serviceToken('news', ['watch.*']) })).status, 200, 'a family grant covers it');
+ // a person's bearer token is enough for their own watches
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME })).status, 200);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: userToken(me, { aud: 'openvibe.deals' }) })).status, 401);
+ // a watch id that does not exist is 404 on every route
+ const ghost = 'wch_01J0000000000000000000000';
+ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}`, { token: ME })).status, 404);
+ assert.strictEqual((await api('DELETE', `/api/v1/watches/${ghost}`, { token: ME })).status, 404);
+ assert.strictEqual((await api('POST', `/api/v1/watches/${ghost}/check`, { token: ME })).status, 404);
+ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/observations`, { token: OBSERVER })).status, 404);
+ assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);
+});
+
+t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {
+ const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });
+ assert.strictEqual(created.status, 201, created.text);
+ const watch = created.body.watch;
+ assert.match(watch.id, /^wch_/);
+ assert.strictEqual(watch.owner, me);
+ assert.deepStrictEqual(watch.labels, { room: 'kitchen' });
+ assert.ok(validate('watch.watch@1', watch).valid);
+ assert.ok(validate('watch.watch-result@1', created.body).valid);
+ const one = await api('GET', `/api/v1/watches/${watch.id}`, { token: ME });
+ assert.strictEqual(one.status, 200);
+ assert.deepStrictEqual(one.body.watch, watch);
+ const list = await api('GET', '/api/v1/watches', { token: ME });
+ assert.strictEqual(list.status, 200);
+ assert.deepStrictEqual(list.body.watches.map(w => w.id), [watch.id]);
+ assert.ok(validate('watch.watch-result@1', list.body).valid, 'exactly { watches }: the cursor is the last id');
+ // a service token with the read capability reads the same (it is the same subject)
+ const service = await api('GET', `/api/v1/watches/${watch.id}`, { token: serviceToken('site', ['watch.watch.read']) });
+ assert.strictEqual(service.status, 404, 'svc:site is not the person');
+});
+
+t('an invalid body is 422 with the validator\'s detail, and nothing is written', async () => {
+ const before = (await svc.db.prepare('SELECT COUNT(*) AS n FROM watches').get()).n;
+ const bad = [
+ [mine({ source: undefined }), /requires source/],
+ [mine({ cadence: null }), /needs a cadence/],
+ [mine({ condition: undefined }), /requires condition/],
+ [mine({ action: [] }), /watch.watch-request@1/],
+ [mine({ source: { kind: 'http', url: 'http://example.org/x', headers: { cookie: 'a=b' } } }), /cookie/],
+ [mine({ nope: true }), /watch.watch-request@1/],
+ ];
+ for (const [body, re] of bad) {
+ const res = await api('POST', '/api/v1/watches', { token: ME, body });
+ assert.strictEqual(res.status, 422, JSON.stringify(body));
+ assert.strictEqual(res.body.code, 'watch.invalid');
+ assert.match(res.body.detail, re);
+ }
+ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM watches').get()).n, before);
+ // malformed JSON is 400, and a body that is not an object is 422
+ const raw = await fetch(`${svc.base}/api/v1/watches`, { method: 'POST', headers: { Authorization: `Bearer ${ME}`, 'Content-Type': 'application/json' }, body: '{' });
+ assert.strictEqual(raw.status, 400);
+ assert.strictEqual((await api('POST', '/api/v1/watches', { token: ME, body: [1, 2] })).status, 422);
+});
+
+t('patch, pause, resume, delete — and another owner gets 404 for every one of them', async () => {
+ const { body: { watch } } = await api('POST', '/api/v1/watches', { token: ME, body: mine() });
+ const patched = await api('PATCH', `/api/v1/watches/${watch.id}`, { token: ME, body: { name: 'renamed', cadence: { every_sec: 120 } } });
+ assert.strictEqual(patched.status, 200);
+ assert.strictEqual(patched.body.watch.name, 'renamed');
+ assert.strictEqual(patched.body.watch.cadence.every_sec, 120);
+ assert.ok(validate('watch.watch-result@1', patched.body).valid);
+
+ const paused = await api('POST', `/api/v1/watches/${watch.id}/pause`, { token: ME });
+ assert.strictEqual(paused.status, 200);
+ assert.deepStrictEqual(paused.body, { id: watch.id, status: 'paused' });
+ assert.ok(validate('watch.watch-result@1', paused.body).valid);
+ const resumed = await api('POST', `/api/v1/watches/${watch.id}/resume`, { token: ME });
+ assert.deepStrictEqual(resumed.body, { id: watch.id, status: 'active' });
+
+ // another owner: 404, never 403, and the watch is untouched
+ for (const [method, path, body] of [['GET', '', undefined], ['PATCH', '', { name: 'stolen' }], ['POST', '/pause', undefined], ['DELETE', '', undefined]]) {
+ const res = await api(method, `/api/v1/watches/${watch.id}${path}`, { token: OTHER, body });
+ assert.strictEqual(res.status, 404, `${method} ${path}`);
+ assert.strictEqual(res.body.code, 'watch.not_found');
+ }
+ assert.deepStrictEqual((await api('GET', '/api/v1/watches', { token: OTHER })).body.watches, []);
+ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}`, { token: ME })).body.watch.name, 'renamed');
+
+ const gone = await api('DELETE', `/api/v1/watches/${watch.id}`, { token: ME });
+ assert.strictEqual(gone.status, 200);
+ assert.deepStrictEqual(gone.body, { id: watch.id, deleted: true });
+ assert.ok(validate('watch.watch-result@1', gone.body).valid);
+ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}`, { token: ME })).status, 404);
+});
+
+t('X-OV-Subject: a first-party service acts for a person; nobody else may name one', async () => {
+ const created = await api('POST', '/api/v1/watches', { token: MANAGER, body: mine(), headers: { 'X-OV-Subject': me } });
+ assert.strictEqual(created.status, 201);
+ assert.strictEqual(created.body.watch.owner, me);
+ // the person sees the service's watch, the service itself does not own it
+ assert.ok((await api('GET', '/api/v1/watches', { token: ME })).body.watches.some(w => w.id === created.body.watch.id));
+ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: serviceToken('site', ['watch.watch.read']) })).status, 404);
+ // a person may not delegate, and a subject must be a subject
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME, headers: { 'X-OV-Subject': other } })).status, 403);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ALL, headers: { 'X-OV-Subject': 'nobody' } })).status, 400);
+ // a service that does not own the watch and names nobody gets 404, not a peek
+ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: serviceToken('site', ['watch.watch.read']) })).status, 404);
+});
+
+t('a manual check runs one check and answers the released check-run; observations and checks read back', async () => {
+ const { body: { watch } } = await api('POST', '/api/v1/watches', { token: ME, body: mine({ condition: { op: 'lt', value: 10 } }) });
+ const run = await api('POST', `/api/v1/watches/${watch.id}/check`, { token: ME });
+ assert.strictEqual(run.status, 200, run.text);
+ assert.ok(validate('watch.check-run@1', run.body).valid, JSON.stringify(run.body));
+ assert.strictEqual(run.body.trigger, 'manual');
+ assert.strictEqual(run.body.carrier, 'http');
+ assert.strictEqual(run.body.state, 'changed');
+ assert.strictEqual(run.body.observations, 1);
+ assert.strictEqual(run.body.triggers, 0);
+
+ const observations = await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: ME });
+ assert.strictEqual(observations.status, 200, observations.text);
+ assert.strictEqual(observations.body.observations.length, 1);
+ assert.ok(validate('watch.observation@1', observations.body.observations[0]).valid);
+ assert.strictEqual(observations.body.observations[0].value, '19.99');
+ const checks = await api('GET', `/api/v1/watches/${watch.id}/checks`, { token: ME });
+ assert.deepStrictEqual(checks.body.checks.map(c => c.id), [run.body.id]);
+ assert.ok(validate('watch.check-run@1', checks.body.checks[0]).valid);
+ // a first-party service reads and runs them for the person it names (X-OV-Subject)
+ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: OBSERVER, headers: { 'X-OV-Subject': me } })).status, 200);
+ assert.strictEqual((await api('POST', `/api/v1/watches/${watch.id}/check`, { token: RUNNER, headers: { 'X-OV-Subject': me } })).status, 200);
+ // without the delegation the same service is a stranger: 404, never 403
+ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: OBSERVER })).status, 404);
+ assert.strictEqual((await api('POST', `/api/v1/watches/${watch.id}/check`, { token: RUNNER })).status, 404);
+ // and the wrong capability is 403 however it is scoped
+ assert.strictEqual((await api('GET', `/api/v1/watches/${watch.id}/observations`, { token: READER, headers: { 'X-OV-Subject': me } })).status, 403);
+ // a watch whose carrier is a later step answers 422 and never fetches
+ const later = await api('POST', '/api/v1/watches', {
+ token: ME,
+ body: mine({ source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: null, extraction: { kind: 'json' } }),
+ });
+ const refused = await api('POST', `/api/v1/watches/${later.body.watch.id}/check`, { token: ME });
+ assert.strictEqual(refused.status, 422);
+ assert.strictEqual(refused.body.code, 'watch.carrier_unavailable');
+ assert.match(refused.body.detail, /step 5/);
+ // a paused watch cannot be checked by hand either
+ await api('POST', `/api/v1/watches/${watch.id}/pause`, { token: ME });
+ const paused = await api('POST', `/api/v1/watches/${watch.id}/check`, { token: ME });
+ assert.strictEqual(paused.status, 409);
+ assert.strictEqual(paused.body.code, 'watch.disabled');
+});
+
+t('list filters and the page cursor', async () => {
+ const who = { sub: newUser() };
+ const TOKEN = userToken(who.sub);
+ const ids = [];
+ for (let i = 0; i < 3; i++) ids.push((await api('POST', '/api/v1/watches', { token: TOKEN, body: mine({ name: `w${i}` }) })).body.watch.id);
+ const page = await api('GET', '/api/v1/watches?limit=2', { token: TOKEN });
+ assert.deepStrictEqual(page.body.watches.map(w => w.id), [ids[2], ids[1]]);
+ const next = await api('GET', `/api/v1/watches?limit=2&before=${page.body.watches[1].id}`, { token: TOKEN });
+ assert.deepStrictEqual(next.body.watches.map(w => w.id), [ids[0]]);
+ await api('POST', `/api/v1/watches/${ids[0]}/pause`, { token: TOKEN });
+ assert.deepStrictEqual((await api('GET', '/api/v1/watches?status=paused', { token: TOKEN })).body.watches.map(w => w.id), [ids[0]]);
+ assert.strictEqual((await api('GET', '/api/v1/watches?status=nonsense', { token: TOKEN })).status, 400);
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ALL })).status, 200, 'watch.* covers read');
+});
+
+t('per-actor limits: the writes and the manual checks each have their own budget', async () => {
+ const who = newUser();
+ const TOKEN = userToken(who);
+ let refused = null;
+ for (let i = 0; i < 40 && !refused; i++) {
+ const res = await api('POST', '/api/v1/watches', { token: TOKEN, body: mine({ name: `limit ${i}` }) });
+ if (res.status === 429) refused = res;
+ else assert.strictEqual(res.status, 201);
+ }
+ assert.ok(refused, 'the write budget refused the 31st create');
+ assert.strictEqual(refused.body.code, 'rate_limited');
+ assert.ok(Number(refused.headers.get('retry-after')) > 0);
+ // another actor is unaffected
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: ME })).status, 200);
+});
+
+t('the API answers 404 for anything else, and health/readiness are honest', async () => {
+ assert.strictEqual((await api('GET', '/api/v1/nope', { token: ME })).status, 404);
+ assert.strictEqual((await api('GET', '/api/health')).status, 200);
+ const ready = await api('GET', '/api/ready');
+ assert.strictEqual(ready.status, 200);
+ assert.strictEqual(ready.body.checks.db.status, 'ok');
+ assert.strictEqual(ready.body.checks.checker.detail.enabled, false, 'the tests run with the worker off');
+ const home = await fetch(`${svc.base}/`, { headers: { Accept: 'text/plain' } });
+ assert.match(await home.text(), /\/api\/v1\/watches/);
+ const html = await fetch(`${svc.base}/`, { headers: { Accept: 'text/html' } });
+ assert.match(await html.text(), /OpenVibe\.Watch/);
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+'use strict';
+/**
+ * The carriers and the SSRF guard, against stub HTTP servers (nothing here touches the internet):
+ * conditional GET with ETag, the 304 path, the unchanged-body path, the HEAD-first path, feeds and
+ * APIs, credentials read by environment-variable name, and the refusals — a redirect to a private
+ * address, a private host, a port the guard does not allow.
+ */
+const assert = require('assert');
+const { boot, request, site, serviceToken, suite, rss } = require('./helpers');
+
+const t = suite('carriers');
+const OWNER = { sub: 'svc:carrier-test' };
+let svc;
+let web;
+
+const watch = async (overrides = {}) => (await svc.registry.create({
+ name: 'carrier watch',
+ source: { kind: 'http', url: `${web.origin}/page`, format: null },
+ cadence: { every_sec: 60 },
+ extraction: { kind: 'text' },
+ condition: { op: 'eq', value: 'never-matches' },
+ action: [{ kind: 'notification' }],
+ ...overrides,
+}, OWNER)).id;
+
+const check = async (id, trigger = 'manual') => await svc.check.run(id, { trigger });
+const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);
+
+t('boot with a stub site', async () => {
+ let version = 1;
+ web = await site({
+ '/page': (req) => {
+ if (req.headers['if-none-match'] === `"v${version}"`) return { status: 304, headers: { etag: `"v${version}"` } };
+ return { headers: { etag: `"v${version}"`, 'content-type': 'text/plain' }, body: `value ${version}` };
+ },
+ '/bump': () => { version += 1; return { body: 'ok' }; },
+ '/stable': () => ({ headers: { 'content-type': 'text/plain' }, body: 'unchanging' }),
+ '/redirect-private': () => ({ status: 302, headers: { location: 'http://127.0.0.2/elsewhere' } }),
+ '/redirect-loop': () => ({ status: 302, headers: { location: '/redirect-loop' } }),
+ '/feed.xml': () => ({ headers: { 'content-type': 'application/rss+xml' }, body: rss([
+ { guid: 'g2', title: 'Widget 2', link: 'https://example.org/2', description: 'the newer one' },
+ { guid: 'g1', title: 'Widget 1', link: 'https://example.org/1' },
+ ]) }),
+ '/api.json': () => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ products: [{ sku: 'A', price: 19.5 }, { sku: 'B', price: 4 }] }) }),
+ '/secret.json': (req) => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ saw: req.url }) }),
+ });
+ svc = await boot();
+});
+
+t('a conditional GET stores the validators, and the next check gets 304 → not_modified', async () => {
+ const id = await watch();
+ const first = await check(id);
+ // the first observation is a change from nothing: there was no value before it
+ assert.strictEqual(first.run.state, 'changed', first.run.detail || '');
+ const state = await svc.db.prepare('SELECT * FROM watch_endpoint_state WHERE watch_id = ?').get(id);
+ assert.strictEqual(state.etag, '"v1"');
+ assert.strictEqual(state.last_status, 200);
+ assert.strictEqual((await runs(id)).length, 1);
+
+ const second = await check(id);
+ assert.strictEqual(second.run.state, 'not_modified');
+ const got = web.hits('/page');
+ assert.strictEqual(got[got.length - 1].headers['if-none-match'], '"v1"', 'the second request asked conditionally');
+ assert.strictEqual((await runs(id)).length, 2, 'a 304 is still a recorded check run');
+ const observations = await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ?').all(id);
+ assert.strictEqual(observations.length, 1, 'a 304 records no observation: nothing was stated');
+});
+
+t('a 200 with an unchanged body is no_change, and a changed body is changed', async () => {
+ const id = await watch({ source: { kind: 'http', url: `${web.origin}/stable`, format: null } });
+ const first = await check(id);
+ assert.deepStrictEqual(first.value, 'unchanging');
+ const second = await check(id);
+ assert.strictEqual(second.run.state, 'no_change');
+ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?').get(id)).n, 1);
+
+ // a body that changes, with a condition that does not fire: the run says `changed`
+ const bump = await watch();
+ await check(bump);
+ await request(web.origin, 'GET', '/bump');
+ const after = await check(bump);
+ assert.strictEqual(after.run.state, 'changed');
+ assert.strictEqual(after.run.triggers, 0);
+ const obs = await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid').all(bump);
+ assert.deepStrictEqual(obs.map((o) => [o.changed, o.condition_met, o.previous_hash != null]), [[1, 0, false], [1, 0, true]]);
+});
+
+t('HEAD first when only change is needed: a 304 answers without a body', async () => {
+ const id = await watch({ condition: { op: 'changed' } });
+ await check(id); // stores the validators
+ const before = web.hits('/page').length;
+ const out = await check(id);
+ assert.strictEqual(out.run.state, 'not_modified');
+ const seen = web.hits('/page').slice(before);
+ assert.deepStrictEqual(seen.map((r) => r.method), ['HEAD'], 'a conditional HEAD, no GET');
+});
+
+t('a redirect to a private address is refused at the hop, the first request only', async () => {
+ const id = await watch({ source: { kind: 'http', url: `${web.origin}/redirect-private`, format: null } });
+ const before = web.hits('/redirect-private').length;
+ const out = await check(id);
+ assert.strictEqual(out.run.state, 'http_error');
+ assert.strictEqual(out.run.error_code, 'address_refused');
+ assert.match(out.run.detail, /redirect|not a public address/);
+ const elsewhere = web.requests.filter(r => r.path === '/elsewhere');
+ assert.strictEqual(elsewhere.length, 0, 'the redirect target was never fetched');
+ assert.strictEqual(web.hits('/redirect-private').length - before, 1);
+});
+
+t('a redirect loop stops at WATCH_MAX_REDIRECTS', async () => {
+ const id = await watch({ source: { kind: 'http', url: `${web.origin}/redirect-loop`, format: null } });
+ const out = await check(id);
+ assert.strictEqual(out.run.state, 'http_error');
+ assert.strictEqual(out.run.error_code, 'too_many_redirects');
+});
+
+t('a private host is refused before any request (nothing is even connected to)', async () => {
+ const aSite = await site({ '/page': () => ({ body: 'should never be fetched' }) });
+ // the stub's port is allowed, so only the ADDRESS rule can refuse this URL
+ const alone = await boot({ allowPrivate: false, env: { WATCH_ALLOWED_PORTS: `80,443,${new URL(aSite.origin).port}` } });
+ try {
+ const w = await alone.registry.create({
+ name: 'loopback watch',
+ source: { kind: 'http', url: `${aSite.origin}/page`, format: null },
+ cadence: { every_sec: 60 }, extraction: { kind: 'text' }, condition: { op: 'eq', value: 'x' },
+ action: [{ kind: 'notification' }],
+ }, OWNER);
+ const out = await alone.check.run(w.id, { trigger: 'manual' });
+ assert.strictEqual(out.run.state, 'http_error');
+ assert.strictEqual(out.run.error_code, 'address_refused');
+ assert.strictEqual(aSite.requests.length, 0, 'the guard refused before the socket');
+ await aSite.close();
+ } finally { await alone.stop(); }
+});
+
+t('the guard refuses ports outside WATCH_ALLOWED_PORTS and URL credentials', async () => {
+ const { createGuard } = require('../server/net/guard');
+ const guard = createGuard({ allowedPorts: [80, 443], allowPrivateHosts: [] });
+ assert.throws(() => guard.checkUrl('http://example.org:8080/x'), (e) => e.code === 'port_refused');
+ assert.throws(() => guard.checkUrl('http://user:[email protected]/x'), (e) => e.code === 'bad_url');
+ assert.throws(() => guard.checkUrl('file:///etc/passwd'), (e) => e.code === 'bad_url');
+ assert.throws(() => guard.checkUrl('http://10.0.0.5/x'), (e) => e.code === 'address_refused');
+ assert.throws(() => guard.checkUrl('http://[::1]/x'), (e) => e.code === 'address_refused');
+ assert.strictEqual(guard.checkUrl('https://example.org/x').hostname, 'example.org');
+});
+
+t('a credential is read from the environment by name, at fetch time, and never stored', async () => {
+ const id = await watch({
+ source: { kind: 'api', url: `${web.origin}/secret.json`, format: 'json', auth: { mode: 'query', env: 'WATCH_CRED_TEST', param: 'k' } },
+ extraction: { kind: 'json', value_path: 'saw' },
+ });
+ // not set: the check is recorded disabled and nothing is fetched
+ const before = web.hits('/secret.json').length;
+ const missing = await check(id);
+ assert.strictEqual(missing.run.state, 'disabled');
+ assert.strictEqual(missing.run.error_code, 'credential_missing');
+ assert.strictEqual(web.hits('/secret.json').length, before, 'no request was made');
+ // the stored watch and its view carry the NAME, never a value
+ const view = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: serviceToken('carrier-test', ['watch.watch.read']) });
+ assert.strictEqual(view.status, 200);
+ assert.deepStrictEqual(view.body.watch.source.auth, { mode: 'query', env: 'WATCH_CRED_TEST', param: 'k' });
+ assert.ok(!view.text.includes('s3cret-value'));
+ // set on the config's environment: the next fetch carries it, and it is not written anywhere
+ svc.config.secrets.WATCH_CRED_TEST = 's3cret-value';
+ const out = await check(id);
+ assert.strictEqual(out.run.state, 'changed');
+ const hit = web.hits('/secret.json').pop();
+ assert.strictEqual(hit.search, '?k=s3cret-value');
+ const stored = await svc.db.prepare('SELECT source FROM watches WHERE id = ?').get(id);
+ assert.ok(!JSON.stringify(stored).includes('s3cret-value'), 'the value is never stored');
+ delete svc.config.secrets.WATCH_CRED_TEST;
+});
+
+t('the feed carrier parses RSS and the extraction reads the latest entry', async () => {
+ const id = await watch({
+ source: { kind: 'feed', url: `${web.origin}/feed.xml`, format: 'rss' },
+ extraction: { kind: 'json', value_path: 'latest.title' },
+ condition: { op: 'eq', value: 'nothing' },
+ });
+ const out = await check(id);
+ assert.strictEqual(out.run.state, 'changed', out.run.detail || '');
+ assert.strictEqual(out.value, 'Widget 2');
+ const items = await watch({
+ source: { kind: 'feed', url: `${web.origin}/feed.xml`, format: null, fields: { name: 'title', url: 'link' } },
+ extraction: { kind: 'json', fields: { first: 'items.0.name', count: 'count' } },
+ });
+ const all = await check(items);
+ assert.deepStrictEqual(all.value, { first: 'Widget 2', count: 2 });
+ const broken = await watch({ source: { kind: 'feed', url: `${web.origin}/stable`, format: null } });
+ const bad = await check(broken);
+ assert.strictEqual(bad.run.state, 'parse_error');
+ assert.strictEqual(bad.run.error_code, 'unreadable');
+});
+
+t('the api carrier maps items_path + fields, and reads the body directly without a mapping', async () => {
+ const mapped = await watch({
+ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json', items_path: 'products', fields: { sku: 'sku', price: 'price' } },
+ extraction: { kind: 'json', value_path: 'latest.price' },
+ });
+ const out = await check(mapped);
+ assert.strictEqual(out.run.state, 'changed', out.run.detail || '');
+ assert.strictEqual(out.value, 19.5);
+
+ const direct = await watch({
+ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json' },
+ extraction: { kind: 'jsonpath', value_path: 'products.1.sku' },
+ });
+ assert.strictEqual((await check(direct)).value, 'B');
+
+ const wrongPath = await watch({
+ source: { kind: 'api', url: `${web.origin}/api.json`, format: 'json', items_path: 'nope' },
+ extraction: { kind: 'json' },
+ });
+ const bad = await check(wrongPath);
+ assert.strictEqual(bad.run.state, 'parse_error');
+ assert.strictEqual(bad.run.error_code, 'items_path');
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,310 @@
@@ -0,0 +1,310 @@
+'use strict';
+/**
+ * The check engine: a run writes its check_runs row and its observation and emits the released
+ * watch.* events; a condition that fires emits watch.watch.triggered; every failure is a recorded
+ * state and an event; a source whose carrier is a later step is never fetched; retention and the
+ * for_sec debounce behave. Every event payload is validated against the released contract.
+ */
+const assert = require('assert');
+const { validate } = require('openvibe-contracts');
+const { boot, site, suite, newUser, newAgent, rss } = require('./helpers');
+
+const t = suite('check');
+let svc;
+let web;
+let price = '19.99';
+
+const person = { sub: newUser() };
+const agent = { sub: newAgent() };
+const service = { sub: 'svc:check-test' };
+
+const def = (overrides = {}) => ({
+ name: 'price watch',
+ source: { kind: 'http', url: `${web.origin}/price`, format: null },
+ cadence: { every_sec: 60 },
+ extraction: { kind: 'text' },
+ condition: { op: 'lt', value: 10 },
+ action: [{ kind: 'notification' }],
+ ...overrides,
+});
+
+const create = async (principal, overrides = {}) => await svc.registry.create(def(overrides), principal);
+const check = async (id, trigger = 'schedule') => await svc.check.run(id, { trigger });
+const row = async (id) => await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(id);
+const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);
+const observations = async (id) => await svc.db.prepare('SELECT * FROM observations WHERE watch_id = ? ORDER BY rid').all(id);
+const payloads = async (type) => (await svc.outbox.all()).filter(e => e.event_type === type).map(e => e.payload);
+const envelopes = async (type) => (await svc.outbox.all()).filter(e => e.event_type === type);
+
+const valid = (ref, value) => {
+ const r = validate(ref, value);
+ assert.ok(r.valid, `${ref}: ${r.errors.map(e => `${e.path} ${e.message}`).join('; ')} — ${JSON.stringify(value)}`);
+};
+
+t('boot', async () => {
+ web = await site({
+ '/price': () => ({ headers: { 'content-type': 'text/plain' }, body: price }),
+ '/set': (req, _res, ctx) => { price = ctx.url.searchParams.get('v') || price; return { body: 'ok' }; },
+ '/rss': () => ({ headers: { 'content-type': 'application/rss+xml' }, body: rss([{ guid: 'g1', title: 'Widget', link: 'https://example.org/1' }]) }),
+ '/slow': () => ({ delayMs: 4000, body: 'late' }),
+ '/busy': () => ({ status: 429, headers: { 'retry-after': '120' }, body: 'slow down' }),
+ '/broken': () => ({ headers: { 'content-type': 'application/json' }, body: 'not json at all' }),
+ });
+ svc = await boot();
+});
+
+t('a check writes its run and its observation and emits watch.observation.recorded', async () => {
+ price = '19.99';
+ const w = await create(person);
+ const out = await check(w.id);
+ assert.strictEqual(out.run.state, 'changed');
+ const r = (await runs(w.id))[0];
+ assert.strictEqual(r.trigger, 'schedule');
+ assert.strictEqual(r.carrier, 'http');
+ assert.strictEqual(r.state, 'changed');
+ assert.strictEqual(Number(r.observations), 1);
+ assert.strictEqual(Number(r.triggers), 0);
+ assert.strictEqual(Number(r.http_status), 200);
+ assert.strictEqual(r.raw_body_hash.length, 64);
+ const o = (await observations(w.id))[0];
+ assert.strictEqual(o.value, '19.99');
+ assert.strictEqual(o.check_run_id, r.id);
+ assert.strictEqual(o.previous_hash, null);
+ assert.strictEqual(Number(o.changed), 1);
+ assert.strictEqual(Number(o.condition_met), 0, 'lt 10 does not hold at 19.99');
+ assert.strictEqual(o.snapshot, '19.99');
+ assert.strictEqual(o.value_hash.length, 64);
+ assert.ok(o.retained_until > o.observed_at, 'retention is set from the watch (default 90 days)');
+ assert.strictEqual(Math.round((o.retained_until - o.observed_at) / 86400000), 90);
+
+ const events = await envelopes('watch.observation.recorded');
+ assert.strictEqual(events.length, 1);
+ valid('watch.observation.recorded@1', events[0].payload);
+ assert.strictEqual(events[0].payload.watch_id, w.id);
+ assert.deepStrictEqual([events[0].payload.changed, events[0].payload.condition_met], [true, false]);
+ assert.strictEqual(events[0].source, 'watch');
+ assert.deepStrictEqual(events[0].subject, { type: 'watch', id: w.id });
+ assert.strictEqual(events[0].visibility, 'internal');
+ assert.strictEqual(events[0].priority, 'low');
+});
+
+t('a condition that fires emits watch.watch.triggered with the observation that fired it', async () => {
+ price = '19.99';
+ const w = await create(person, { condition: { op: 'lt', value: 10 } });
+ await check(w.id); // 19.99: no fire
+ assert.strictEqual((await runs(w.id))[0].state, 'changed');
+ await fetch(`${web.origin}/set?v=7.50`); // the price drops to 7.50
+ const out = await check(w.id);
+ assert.strictEqual(out.run.state, 'condition_met');
+ assert.strictEqual(out.run.triggers, 1);
+ assert.strictEqual(out.met, true);
+ assert.strictEqual(out.value, '7.50');
+
+ const [trig] = await envelopes('watch.watch.triggered');
+ assert.ok(trig, 'the trigger was emitted');
+ valid('watch.watch.triggered@1', trig.payload);
+ assert.deepStrictEqual(trig.payload.recipient, person.sub);
+ assert.strictEqual(trig.payload.watch_id, w.id);
+ assert.strictEqual(trig.payload.observation.value, '7.50');
+ assert.strictEqual(trig.payload.observation.previous_value, '19.99');
+ assert.match(trig.payload.observation.id, /^wco_/);
+ assert.deepStrictEqual(trig.payload.condition, { op: 'lt', value: 10 });
+ assert.strictEqual(trig.priority, 'important');
+
+ // the observation that fired it carries both changed and the predicate holding
+ const last = (await observations(w.id)).pop();
+ assert.deepStrictEqual([Number(last.changed), Number(last.condition_met)], [1, 1]);
+ valid('watch.observation@1', svc.observations.view(last));
+ valid('watch.check-run@1', svc.check.runView((await runs(w.id)).pop()));
+});
+
+t('a failing check is a recorded state and a watch.check.failed event, and it backs off', async () => {
+ const w = await create(person, { source: { kind: 'http', url: `${web.origin}/broken`, format: null }, extraction: { kind: 'json' } });
+ const first = await check(w.id);
+ assert.strictEqual(first.run.state, 'parse_error');
+ assert.strictEqual(first.run.error_code, 'unreadable');
+ const [fail] = await envelopes('watch.check.failed');
+ valid('watch.check.failed@1', fail.payload);
+ assert.deepStrictEqual([fail.payload.state, fail.payload.consecutive_failures], ['parse_error', 1]);
+ let r = await row(w.id);
+ assert.strictEqual(r.last_state, 'parse_error');
+ assert.strictEqual(Number(r.consecutive_failures), 1);
+ assert.strictEqual(r.next_due_at - r.last_check_at, 60000, 'first failure: every_sec · 2^0');
+
+ const second = await check(w.id);
+ assert.strictEqual(second.failures, 2);
+ r = await row(w.id);
+ assert.strictEqual(Number(r.consecutive_failures), 2);
+ assert.strictEqual(r.next_due_at - r.last_check_at, 120000, 'second failure: every_sec · 2^1');
+ assert.strictEqual((await payloads('watch.check.failed')).length, 2);
+
+ // a success clears the backoff and the counter (19.99 is above the watch's lt 10, so it is a
+ // change, not a fire)
+ await fetch(`${web.origin}/set?v=19.99`);
+ await svc.db.prepare('UPDATE watches SET source = ? WHERE id = ?').run(JSON.stringify({ kind: 'http', url: `${web.origin}/price`, format: null }), w.id);
+ await check(w.id);
+ r = await row(w.id);
+ assert.deepStrictEqual([Number(r.consecutive_failures), r.last_state], [0, 'changed']);
+ assert.ok(r.next_due_at - r.last_check_at >= 60000 && r.next_due_at - r.last_check_at <= 60100, 'back to the cadence');
+});
+
+t('a timeout is its own state, and a 429 waits as long as the site asked', async () => {
+ const slow = await create(person, { source: { kind: 'http', url: `${web.origin}/slow`, format: null } });
+ const out = await check(slow.id);
+ assert.strictEqual(out.run.state, 'timeout');
+ assert.strictEqual(out.run.error_code, 'timeout');
+
+ const busy = await create(person, { source: { kind: 'http', url: `${web.origin}/busy`, format: null } });
+ const limited = await check(busy.id);
+ assert.strictEqual(limited.run.state, 'rate_limited');
+ assert.strictEqual(limited.run.error_code, 'upstream_429');
+ const r = await row(busy.id);
+ assert.ok(r.not_before - r.last_check_at >= 119000, 'Retry-After: 120 s became not_before');
+});
+
+t('an event, webhook, node or run source is accepted but never fetched (steps 5-7)', async () => {
+ const cases = [
+ ['event', { kind: 'event', pattern: 'release.published' }, { op: 'exists' }],
+ ['webhook', { kind: 'webhook', endpoint: '/internal/hooks/abc' }, { op: 'exists' }],
+ ['node', { kind: 'node', node_id: 'nod_01J0000000000000000000000', probe: { path: '/tmp/x' } }, { op: 'exists' }],
+ ['run', { kind: 'run', runtime: 'browser', url: `${web.origin}/price`, steps: [{ goto: 'https://example.org' }] }, { op: 'exists' }],
+ ];
+ const before = web.requests.length;
+ for (const [kind, source, condition] of cases) {
+ // an event or a webhook is pushed to and never polls; node and run sources poll through
+ // OpenVibe.Node and OpenVibe.Run, so they carry a cadence — and still nothing is fetched
+ const cadence = (kind === 'event' || kind === 'webhook') ? null : { every_sec: 60 };
+ const w = await create(person, { source, cadence, extraction: { kind: 'text' }, condition });
+ assert.strictEqual((await row(w.id)).status, 'active');
+ const out = await check(w.id, 'manual');
+ assert.strictEqual(out.run.state, 'skipped', `${kind}: ${out.run.detail}`);
+ assert.strictEqual(out.run.error_code, 'watch.carrier_unavailable');
+ assert.strictEqual(out.run.carrier, kind);
+ assert.match(out.run.detail, /step [567]/);
+ assert.strictEqual(out.unavailable.code, 'watch.carrier_unavailable');
+ assert.strictEqual((await observations(w.id)).length, 0);
+ const [fail] = (await payloads('watch.check.failed')).slice(-1);
+ valid('watch.check.failed@1', fail);
+ assert.strictEqual(fail.carrier, kind);
+ }
+ assert.strictEqual(web.requests.length, before, 'no carrier ever fetched');
+ // a run source that does poll is armed, and the scheduler may ask for it — the carrier still refuses
+ const due = await svc.registry.due(10);
+ assert.ok(!due.some(d => d.id === undefined));
+});
+
+t('an ai extraction is not checked either: no fetch, a skipped run, and it explains why', async () => {
+ const w = await create(person, { extraction: { kind: 'ai', ai: { prompt: 'read the price' } } });
+ const before = web.requests.length;
+ const out = await check(w.id, 'manual');
+ assert.strictEqual(out.run.state, 'skipped');
+ assert.strictEqual(out.run.error_code, 'watch.extraction_unavailable');
+ assert.match(out.run.detail, /AI action/);
+ assert.strictEqual(web.requests.length, before, 'the page was never fetched for a reading this release cannot do');
+});
+
+t('a watch that is not active is recorded disabled and never fetched', async () => {
+ const w = await create(person);
+ await svc.registry.patch(w.id, { status: 'paused' }, person);
+ const before = web.requests.length;
+ const out = await check(w.id, 'manual');
+ assert.strictEqual(out.disabled, true);
+ assert.strictEqual(out.run.state, 'disabled');
+ assert.strictEqual(out.run.error_code, 'watch_paused');
+ assert.strictEqual(web.requests.length, before);
+ const [fail] = (await payloads('watch.check.failed')).slice(-1);
+ valid('watch.check.failed@1', fail);
+ assert.strictEqual(fail.state, 'disabled');
+});
+
+t('a watch is checked once at a time: a second run answers busy', async () => {
+ const w = await create(person);
+ const first = svc.check.run(w.id, { trigger: 'manual' });
+ const second = await svc.check.run(w.id, { trigger: 'manual' });
+ assert.deepStrictEqual(second, { busy: true });
+ await first;
+ assert.strictEqual((await runs(w.id)).length, 1, 'the busy call recorded nothing');
+});
+
+t('for_sec is measured from the observations, not from a timer in this process', async () => {
+ price = '7.50';
+ const w = await create(person, { source: { kind: 'http', url: `${web.origin}/price`, format: null }, condition: { op: 'lt', value: 100, for_sec: 120 } });
+ const first = await check(w.id);
+ assert.strictEqual(first.run.state, 'changed', 'the predicate holds but the debounce has not run');
+ assert.strictEqual(Number((await observations(w.id))[0].condition_met), 1, 'the observation records that it held');
+ // the trail says it has held since two minutes before this check
+ const since = Date.now() - 130000;
+ const firedBefore = (await envelopes('watch.watch.triggered')).length;
+ await svc.db.prepare('UPDATE observations SET observed_at = ? WHERE watch_id = ?').run(since, w.id);
+ const second = await check(w.id);
+ assert.strictEqual(second.run.state, 'condition_met', 'an unchanged body still fires once the debounce has run');
+ const fired = (await envelopes('watch.watch.triggered')).slice(firedBefore);
+ assert.strictEqual(fired.length, 1);
+ assert.strictEqual(fired[0].payload.watch_id, w.id);
+ assert.strictEqual(fired[0].payload.observation.value, '7.50', 'the trigger points at the observation that holds the value');
+ // once per run of holding: the same value and the same run fire nothing more
+ const again = await check(w.id);
+ assert.strictEqual(again.met, false);
+ assert.strictEqual((await envelopes('watch.watch.triggered')).length, firedBefore + 1);
+ // a value that stops holding resets the debounce
+ const other = await create(person, { source: { kind: 'http', url: `${web.origin}/price`, format: null }, condition: { op: 'gt', value: 100, for_sec: 60 } });
+ await check(other.id);
+ const held = await svc.db.prepare('SELECT observed_at FROM observations WHERE watch_id = ?').get(other.id);
+ await svc.db.prepare('UPDATE observations SET observed_at = ? WHERE watch_id = ?').run(held.observed_at - 600000, other.id);
+ const after = await check(other.id);
+ assert.strictEqual(after.met, false, 'gt 100 does not hold at 7.50, whatever the trail says');
+ assert.strictEqual(after.run.state, 'no_change', 'the body did not change; the condition simply did not hold');
+});
+
+t('retention: keep_snapshots false stores none, and observations_days sets retained_until', async () => {
+ const w = await create(person, { retention: { observations_days: 7, keep_snapshots: false } });
+ await check(w.id);
+ const o = (await observations(w.id))[0];
+ assert.strictEqual(o.snapshot, null);
+ assert.strictEqual(Math.round((o.retained_until - o.observed_at) / 86400000), 7);
+ const pruned = await svc.observations.prune(Date.now() + 8 * 86400000);
+ assert.strictEqual(pruned, 1);
+ assert.strictEqual((await observations(w.id)).length, 0);
+});
+
+t('an owned-by-a-service watch records everything and emits no person-facing event', async () => {
+ const w = await create(service, { condition: { op: 'lt', value: 100 } });
+ const out = await check(w.id);
+ assert.strictEqual(out.run.state, 'condition_met', 'the trigger is recorded on the run');
+ const run = (await runs(w.id)).pop();
+ assert.strictEqual(Number(run.triggers), 1);
+ assert.strictEqual((await observations(w.id)).length, 1);
+ const all = await svc.outbox.all();
+ const mine = all.filter(e => e.subject.id === w.id);
+ // the contract names usr_/agt_ owners for an observation and a usr_ recipient for a trigger, so
+ // a service-owned watch emits neither — it keeps the record in its own tables
+ assert.deepStrictEqual(mine.filter(e => e.event_type === 'watch.observation.recorded'), []);
+ assert.deepStrictEqual(mine.filter(e => e.event_type === 'watch.watch.triggered'), []);
+});
+
+t('an agent-owned watch emits observation.recorded but never a trigger (recipient is usr_ only)', async () => {
+ const w = await create(agent, { condition: { op: 'lt', value: 100 } });
+ await check(w.id);
+ const all = (await svc.outbox.all()).filter(e => e.subject.id === w.id);
+ const observed = all.filter(e => e.event_type === 'watch.observation.recorded');
+ assert.strictEqual(observed.length, 1);
+ valid('watch.observation.recorded@1', observed[0].payload);
+ assert.strictEqual(observed[0].payload.owner, agent.sub);
+ assert.deepStrictEqual(all.filter(e => e.event_type === 'watch.watch.triggered'), []);
+});
+
+t('the trail carries previous_hash, and an identical body records no second observation', async () => {
+ const w = await create(service, { source: { kind: 'http', url: `${web.origin}/rss`, format: null }, extraction: { kind: 'text' } });
+ await check(w.id);
+ const first = (await observations(w.id))[0];
+ assert.strictEqual(first.previous_hash, null);
+ assert.strictEqual(first.value_hash.length, 64);
+ // the same body again: no_change, no second observation, and the first row is untouched
+ const again = await check(w.id);
+ assert.strictEqual(again.run.state, 'no_change');
+ assert.strictEqual((await observations(w.id)).length, 1);
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/** Conditions: every op, tolerance/percent, and the for_sec debounce over the observation trail. */
+const assert = require('assert');
+const { suite } = require('./helpers');
+const { evaluate, asNumber, isAbsent } = require('../server/condition');
+
+const t = suite('condition');
+const prev = (value, { at = 0, met = false } = {}) => ({ value, observed_at: at, condition_met: met });
+
+t('changed: the first observation changed, an equal one did not, tolerance and percent', () => {
+ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'a' }).changed, true, 'nothing before it, so it changed');
+ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'a', previous: prev('a') }).changed, false);
+ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: 'b', previous: prev('a') }).changed, true);
+ // objects compare by canonical JSON, key order does not matter
+ assert.strictEqual(evaluate({ condition: { op: 'changed' } }, { value: { a: 1, b: [2, 3] }, previous: prev({ b: [2, 3], a: 1 }) }).changed, false);
+ // tolerance: a move smaller than it is not a change
+ const tolerance = { mode: 'threshold', tolerance: 0.5 };
+ assert.strictEqual(evaluate({ comparison: tolerance, condition: { op: 'changed' } }, { value: 10.2, previous: prev(10) }).changed, false);
+ assert.strictEqual(evaluate({ comparison: tolerance, condition: { op: 'changed' } }, { value: 10.6, previous: prev(10) }).changed, true);
+ // percent: 20.00 → 18.00 is a 10 % move
+ const percent = { mode: 'changed', percent: 5 };
+ assert.strictEqual(evaluate({ comparison: percent, condition: { op: 'changed' } }, { value: 19, previous: prev(20) }).changed, false);
+ assert.strictEqual(evaluate({ comparison: percent, condition: { op: 'changed' } }, { value: 18, previous: prev(20) }).changed, true);
+ // mode none never changes
+ assert.strictEqual(evaluate({ comparison: { mode: 'none' }, condition: { op: 'changed' } }, { value: 2, previous: prev(1) }).changed, false);
+});
+
+t('gt/gte/lt/lte: numbers, decimal strings, and no guessing', () => {
+ const at = (op, v, value) => evaluate({ condition: { op, value: v } }, { value }).holds;
+ assert.strictEqual(at('gt', 10, 11), true);
+ assert.strictEqual(at('gt', 10, 10), false);
+ assert.strictEqual(at('gte', 10, 10), true);
+ assert.strictEqual(at('lt', 10, 9.99), true);
+ assert.strictEqual(at('lte', 10, 10), true);
+ assert.strictEqual(at('lt', 10, '9.5'), true, 'a decimal string compares as the number it states');
+ assert.strictEqual(at('lt', 10, 'n/a'), false, 'a value that is not a number is never low');
+ assert.strictEqual(at('gt', 10, null), false);
+ assert.strictEqual(at('gt', 10, { price: 3 }), false);
+ assert.strictEqual(at('gt', 'm', 'n'), true, 'strings compare as strings when the condition states one');
+ // the target may come from the previous value instead of the condition
+ assert.strictEqual(evaluate({ condition: { op: 'lt' } }, { value: 9, previous: prev(10) }).holds, true);
+});
+
+t('eq / ne: numbers and everything else', () => {
+ const eq = (v, value) => evaluate({ condition: { op: 'eq', value: v } }, { value }).holds;
+ assert.strictEqual(eq(10, 10), true);
+ assert.strictEqual(eq(10, '10'), true, 'the same number stated as a string is the same number');
+ assert.strictEqual(eq(10, 11), false);
+ assert.strictEqual(eq('live', 'live'), true);
+ assert.strictEqual(eq({ a: 1 }, { a: 1 }), true);
+ assert.strictEqual(eq(null, null), true, 'eq null is "states nothing"');
+ assert.strictEqual(evaluate({ condition: { op: 'ne', value: 10 } }, { value: 11 }).holds, true);
+});
+
+t('contains / matches', () => {
+ const holds = (condition, value) => evaluate({ condition }, { value }).holds;
+ assert.strictEqual(holds({ op: 'contains', value: 'sold' }, 'now sold out'), true);
+ assert.strictEqual(holds({ op: 'contains', value: 'sold' }, 'in stock'), false);
+ assert.strictEqual(holds({ op: 'contains', value: 'b' }, ['a', 'b']), true);
+ assert.strictEqual(holds({ op: 'contains', value: 'price' }, { price: 3 }), true);
+ assert.strictEqual(holds({ op: 'matches', value: '^v\\d+\\.\\d+' }, 'v2.3 released'), true);
+ assert.strictEqual(holds({ op: 'matches', value: '^v\\d+' }, 'vX'), false);
+ assert.strictEqual(holds({ op: 'matches', value: '([' }, 'anything'), false, 'an invalid pattern never throws');
+ assert.strictEqual(holds({ op: 'matches', value: null }, 'anything'), false);
+});
+
+t('exists / absent', () => {
+ const holds = (op, value) => evaluate({ condition: { op } }, { value }).holds;
+ assert.strictEqual(holds('exists', 'x'), true);
+ assert.strictEqual(holds('exists', 0), true, 'zero is a value the source stated');
+ assert.strictEqual(holds('exists', false), true);
+ assert.strictEqual(holds('exists', null), false);
+ assert.strictEqual(holds('exists', ''), false);
+ assert.strictEqual(holds('exists', []), false);
+ assert.strictEqual(holds('absent', null), true);
+ assert.strictEqual(holds('absent', 'x'), false);
+ assert.strictEqual(isAbsent(undefined), true);
+ assert.strictEqual(isAbsent({ a: 1 }), false);
+});
+
+t('for_sec: a condition must hold for that long before it is met', () => {
+ const now = 1_000_000;
+ const opts = { value: 11, now };
+ const condition = { op: 'gt', value: 10, for_sec: 60 };
+ // first sighting: holds, but the debounce has not run
+ const first = evaluate({ condition }, { ...opts, previous: null, heldSince: null });
+ assert.deepStrictEqual([first.holds, first.met, first.since], [true, false, now]);
+ // the trail says it has held for 30 s: still not met
+ assert.strictEqual(evaluate({ condition }, { ...opts, heldSince: now - 30_000 }).met, false);
+ // 60 s: met
+ assert.strictEqual(evaluate({ condition }, { ...opts, heldSince: now - 60_000 }).met, true);
+ // a predicate that stopped holding never counts the earlier run
+ const broken = evaluate({ condition }, { value: 9, now, heldSince: now - 120_000 });
+ assert.deepStrictEqual([broken.holds, broken.met, broken.since], [false, false, null]);
+ // for_sec 0 (or absent) fires at once
+ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10, for_sec: 0 } }, opts).met, true);
+ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10 } }, opts).met, true);
+ // without a trail the debounce starts at this observation (never fires from nothing)
+ assert.strictEqual(evaluate({ condition: { op: 'gt', value: 10, for_sec: 1 } }, { ...opts, heldSince: null }).met, false);
+});
+
+t('asNumber only reads what is stated', () => {
+ assert.strictEqual(asNumber('12.50'), 12.5);
+ assert.strictEqual(asNumber('-3'), -3);
+ assert.strictEqual(asNumber('1e3'), 1000);
+ assert.strictEqual(asNumber(''), null);
+ assert.strictEqual(asNumber('free'), null);
+ assert.strictEqual(asNumber('12 items'), null);
+ assert.strictEqual(asNumber(true), null);
+ assert.strictEqual(asNumber(Infinity), null);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test boot (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * WATCH_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../server/db');
+
+const testDb = ({ store = process.env.WATCH_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'watch', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+/** Extraction: text, html, css (the small subset), json/jsonpath, regex, fields — and `ai` refusing. */
+const assert = require('assert');
+const { suite } = require('./helpers');
+const { extract, ExtractError, at, parseSelector } = require('../server/extract');
+
+const t = suite('extract');
+const body = (s) => Buffer.from(s, 'utf8');
+
+const PAGE = `<!doctype html>
+<html><head><title>Shop</title><style>.price { color: red }</style><script>var x = "<span>9.99</span>";</script></head>
+<body>
+ <div id="main" class="card wide">
+ <h1 class="title">Widget <em>Pro</em></h1>
+ <p class="price" data-currency="EUR">€ 19,<span class="cents">99</span></p>
+ <ul id="stock"><li>3 in stock</li><li>warehouse B</li></ul>
+ <a href="/buy" rel="nofollow">Buy now</a>
+ </div>
+</body></html>`;
+
+t('text: the whole body trimmed, capped, never markup-stripped', () => {
+ const out = extract({ kind: 'text' }, { body: body(' hello '), contentType: 'text/plain' });
+ assert.strictEqual(out.value, 'hello');
+ const html = extract({ kind: 'text' }, { body: body('<b>hi</b>') });
+ assert.strictEqual(html.value, '<b>hi</b>', 'text states what the source sent');
+ const long = extract({ kind: 'text' }, { body: body('x'.repeat(70000)) });
+ assert.strictEqual(long.value.length, 65536);
+ assert.ok(long.value.endsWith('…'));
+});
+
+t('html: markup stripped, or the text of the first element the selector matches', () => {
+ const all = extract({ kind: 'html' }, { body: body(PAGE), contentType: 'text/html' });
+ assert.match(all.value, /Widget Pro/);
+ assert.match(all.value, /3 in stock/);
+ assert.ok(!all.value.includes('var x'), 'scripts are dropped');
+ assert.ok(!all.value.includes('color: red'), 'styles are dropped');
+ const title = extract({ kind: 'html', selector: 'h1.title' }, { body: body(PAGE) });
+ assert.strictEqual(title.value, 'Widget Pro');
+ const price = extract({ kind: 'html', selector: '#main .price' }, { body: body(PAGE) });
+ assert.strictEqual(price.value, '€ 19,99');
+ const attr = extract({ kind: 'html', selector: 'a[rel="nofollow"]' }, { body: body(PAGE) });
+ assert.strictEqual(attr.value, 'Buy now');
+ const missing = extract({ kind: 'html', selector: '.nothing' }, { body: body(PAGE) });
+ assert.strictEqual(missing.value, null, 'a selector that matches nothing states nothing');
+});
+
+t('css: the small selector subset (tag, .class, #id, [attr], descendants, >)', () => {
+ const pick = (selector) => extract({ kind: 'css', selector }, { body: body(PAGE) }).value;
+ assert.strictEqual(pick('li'), '3 in stock', 'the first match, like regex');
+ assert.strictEqual(pick('#stock li'), '3 in stock');
+ assert.strictEqual(pick('ul#stock'), '3 in stock warehouse B', 'the whole matched element, in document order');
+ assert.strictEqual(pick('.card .title'), 'Widget Pro');
+ assert.strictEqual(pick('div#main > h1'), 'Widget Pro');
+ assert.strictEqual(pick('div#main > a'), 'Buy now');
+ assert.strictEqual(pick('ul[id=stock] li.cents'), null, 'no such element');
+ assert.strictEqual(pick('[data-currency]'), '€ 19,99');
+ assert.strictEqual(pick('h1 em'), 'Pro');
+ // the pseudo-classes and sibling combinators of real CSS are outside the subset, not silently ignored
+ assert.strictEqual(parseSelector('li:first-child'), null);
+ assert.strictEqual(parseSelector('h1 + p'), null);
+ assert.throws(() => extract({ kind: 'css' }, { body: body(PAGE) }), /needs a selector/);
+ assert.throws(() => extract({ kind: 'css', selector: 'li:first-child' }, { body: body(PAGE) }), (e) => e.code === 'unsupported');
+});
+
+t('json / jsonpath: the parsed body, a dotted path, array indexes and fields', () => {
+ const doc = JSON.stringify({ product: { price: { amount: 19.99, currency: 'EUR' }, tags: ['new', 'sale'] } });
+ assert.deepStrictEqual(Object.keys(extract({ kind: 'json' }, { body: body(doc) }).value), ['product']);
+ assert.strictEqual(extract({ kind: 'json', value_path: 'product.price.amount' }, { body: body(doc) }).value, 19.99);
+ assert.strictEqual(extract({ kind: 'jsonpath', value_path: 'product.tags.1' }, { body: body(doc) }).value, 'sale');
+ assert.strictEqual(extract({ kind: 'jsonpath', value_path: 'product.tags[0]' }, { body: body(doc) }).value, 'new');
+ assert.strictEqual(extract({ kind: 'json', value_path: 'product.nothing' }, { body: body(doc) }).value, null, 'a path that matches nothing extracts null, never undefined');
+ const fields = extract({ kind: 'json', fields: { price: 'product.price.amount', currency: 'product.price.currency', missing: 'product.gone' } }, { body: body(doc) }).value;
+ assert.deepStrictEqual(fields, { price: 19.99, currency: 'EUR', missing: null });
+ assert.throws(() => extract({ kind: 'json' }, { body: body('not json') }), (e) => e.code === 'parse_error');
+});
+
+t('json reads the document a carrier already parsed (a feed or api mapping)', () => {
+ const document = { items: [{ title: 'a', price: 3 }, { title: 'b', price: 5 }], latest: { title: 'a', price: 3 }, count: 2 };
+ assert.strictEqual(extract({ kind: 'json', value_path: 'latest.title' }, { body: body('{}'), document }).value, 'a');
+ assert.strictEqual(extract({ kind: 'json', value_path: 'items.1.price' }, { body: body('{}'), document }).value, 5);
+ assert.strictEqual(extract({ kind: 'json', fields: { latest: 'latest.title', n: 'count' } }, { body: null, document }).value.latest, 'a');
+});
+
+t('regex: the first match, a group when the pattern has one', () => {
+ assert.strictEqual(extract({ kind: 'regex', selector: '(\\d+\\.\\d+) EUR' }, { body: body('now 12.50 EUR, then 99.00 EUR') }).value, '12.50');
+ assert.strictEqual(extract({ kind: 'regex', selector: '\\d{4}-\\d{2}-\\d{2}' }, { body: body('on 2026-10-07 at noon') }).value, '2026-10-07');
+ assert.strictEqual(extract({ kind: 'regex', selector: '/(\\d+) items/i' }, { body: body('12 ITEMS left') }).value, '12');
+ assert.strictEqual(extract({ kind: 'regex', selector: 'nothing' }, { body: body('abc') }).value, null);
+ assert.throws(() => extract({ kind: 'regex', selector: '(' }, { body: body('x') }), (e) => e.code === 'parse_error');
+ assert.throws(() => extract({ kind: 'regex', selector: 'x'.repeat(300) }, { body: body('x') }), (e) => e.code === 'unsupported');
+});
+
+t('ai is not a check: it refuses, and the refusal is `unsupported`', () => {
+ assert.throws(() => extract({ kind: 'ai', ai: { prompt: 'read the price' } }, { body: body(PAGE) }), (err) => {
+ assert.ok(err instanceof ExtractError);
+ assert.strictEqual(err.code, 'unsupported');
+ assert.match(err.message, /AI action/);
+ return true;
+ });
+});
+
+t('the snapshot is a capped copy of the body the value came from', () => {
+ const out = extract({ kind: 'text' }, { body: body('hello world'), snapshotMax: 5 });
+ assert.strictEqual(out.value, 'hello world');
+ assert.strictEqual(out.snapshot, 'hell…');
+});
+
+t('at(): dotted paths, indexes, and nothing that is not a path', () => {
+ assert.strictEqual(at({ a: { b: [{ c: 1 }] } }, 'a.b.0.c'), 1);
+ assert.strictEqual(at({ a: { b: [{ c: 1 }] } }, 'a.b[0].c'), 1);
+ assert.strictEqual(at({ a: 1 }, 'a.b.c'), undefined);
+ assert.strictEqual(at({ a: 1 }, 'a; DROP TABLE'), undefined);
+ assert.deepStrictEqual(at({ a: 'x' }, ''), { a: 'x' }, 'an empty path is the document itself (items_path "")');
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * Shared test fixtures: a generated Network signing key, service and user token minting, a booted
+ * Watch service on a random port with a temp database, and stub HTTP servers standing in for the
+ * pages, feeds and APIs being watched. Nothing here touches the internet.
+ */
+const crypto = require('crypto');
+const nodeHttp = require('http');
+const { serviceAuth } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { start } = require('../server/index');
+
+const ISSUER = 'https://openvibe.network';
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+});
+const publicJwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
+
+const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
+
+/** A service principal's token (svc:…), the shape OpenVibe.Network signs for client credentials. */
+function serviceToken(slug, cap, { aud = 'openvibe.watch', exp = Math.floor(Date.now() / 1000) + 300, key = privateKey } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: ISSUER, sub: `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
+ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, key);
+}
+
+/** A signed-in person's (usr_…) or agent's (agt_…) bearer token, for audience openvibe.watch. */
+function userToken(sub, { aud = 'openvibe.watch', role = 'member', exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, extra = {} } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: ISSUER, sub, aud: [aud], iat: now, exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ role, ...(/^usr_/.test(sub) ? { subject_id: sub } : {}), ...extra,
+ }, key);
+}
+
+/** A fresh usr_… subject id (a person), for tests that need an owner. */
+const newUser = () => require('openvibe-contracts').ids.newId('user');
+/** A fresh agt_… subject id (an agent), for tests that need one. */
+const newAgent = () => require('openvibe-contracts').ids.newId('agent');
+
+/**
+ * Boot Watch; the scheduler is off unless worker: 'on'. Loopback is allowlisted for the stubs unless
+ * the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
+ * log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
+ */
+async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, log = silent, limitsNow = null, allowPrivate = true } = {}) {
+ // A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
+ // to fetch and verify against (no test touches the internet). Its URL is overridable via env.
+ const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
+ const config = load({
+ NODE_ENV: 'test',
+ PORT: '0',
+ OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,
+ WATCH_WORKER: worker,
+ WATCH_ALLOW_PRIVATE_HOSTS: allowPrivate ? '127.0.0.1' : '',
+ WATCH_HOST_MIN_INTERVAL_MS: '0',
+ WATCH_FETCH_TIMEOUT_MS: '1500',
+ WATCH_TICK_MS: '50',
+ ...env,
+ });
+ // One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
+ const testdb = await require('./db').testDb();
+ const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });
+ const base = `http://127.0.0.1:${h.server.address().port}`;
+ return { ...h, base, jwksSite, async stop() { await h.close(); await testdb.close(); await jwksSite.close(); } };
+}
+
+async function request(base, method, p, { token, body, headers = {} } = {}) {
+ 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: 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 stub website. routes: { '/path': (req, res, ctx) => void | { status, headers, body, delayMs } }.
+ * Every request is recorded with its method, headers and arrival time.
+ */
+async function site(routes = {}) {
+ const requests = [];
+ const server = nodeHttp.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://stub');
+ requests.push({ path: url.pathname, search: url.search, method: req.method, headers: req.headers, at: Date.now() });
+ const handler = routes[url.pathname] || routes['*'];
+ if (!handler) { res.statusCode = 404; res.end('not found'); return; }
+ const out = await handler(req, res, { url, count: requests.filter(r => r.path === url.pathname).length });
+ if (!out || res.writableEnded) return;
+ if (out.delayMs) await new Promise(r => setTimeout(r, out.delayMs));
+ if (res.destroyed) return;
+ res.writeHead(out.status || 200, out.headers || {});
+ res.end(out.body === undefined ? '' : out.body);
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ return {
+ origin,
+ requests,
+ hits: (p) => requests.filter(r => r.path === p),
+ routes,
+ close: () => new Promise(r => { server.closeAllConnections?.(); server.close(() => r()); }),
+ };
+}
+
+/** A valid http watch body pointing at a stub (every field the released contract requires). */
+function watchDef(overrides = {}) {
+ return {
+ name: 'Stub watch',
+ source: { kind: 'http', url: 'https://example.org/page', format: null },
+ cadence: { every_sec: 60, jitter_sec: 0 },
+ extraction: { kind: 'text' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ ...overrides,
+ };
+}
+
+function rss(items) {
+ return `<?xml version="1.0"?><rss version="2.0"><channel><title>Stub</title>${items.map(i =>
+ `<item><title>${i.title}</title><link>${i.link}</link><guid isPermaLink="false">${i.guid}</guid>${i.date ? `<pubDate>${i.date}</pubDate>` : ''}<description>${i.description || ''}</description></item>`).join('')}</channel></rss>`;
+}
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+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, privateKey, publicKey, silent, serviceToken, userToken, newUser, newAgent, boot, request, site,
+ watchDef, rss, sleep, suite,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
+'use strict';
+/**
+ * The registry: validation against the released contracts, due selection, arming and re-arming on
+ * the cadence, soft delete, owner scoping, the keyset page, and the view (which the service
+ * validates with openvibe-contracts' own validator).
+ */
+const assert = require('assert');
+const { validate } = require('openvibe-contracts');
+const { boot, suite, newUser, newAgent } = require('./helpers');
+
+const t = suite('registry');
+let svc;
+const me = { sub: newUser() };
+const someoneElse = { sub: newUser() };
+
+const def = (overrides = {}) => ({
+ name: 'a watch',
+ source: { kind: 'http', url: 'https://example.org/page', format: null },
+ cadence: { every_sec: 60 },
+ extraction: { kind: 'text' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification' }],
+ ...overrides,
+});
+const invalid = async (body, re, create = true) => {
+ await assert.rejects(
+ () => (create ? svc.registry.create(body, me) : svc.registry.patch('wch_01J0000000000000000000000', body, me)),
+ (err) => {
+ assert.strictEqual(err.code, 'watch.invalid', err.message);
+ assert.match(err.message, re);
+ return true;
+ },
+ );
+};
+
+t('boot (worker off)', async () => { svc = await boot(); });
+
+t('create: what a watch requires, enforced in code over the released schema', async () => {
+ for (const field of ['source', 'extraction', 'condition', 'action']) {
+ const body = def();
+ delete body[field];
+ await assert.rejects(() => svc.registry.create(body, me), (err) => {
+ assert.strictEqual(err.code, 'watch.invalid');
+ assert.match(err.message, new RegExp(`requires ${field}`));
+ return true;
+ });
+ }
+ // the schema itself refuses what the contract refuses
+ await invalid(def({ source: { kind: 'http' } }), /watch.watch-request@1/);
+ await invalid(def({ source: { kind: 'ftp', url: 'ftp://example.org/x' } }), /watch.watch-request@1/);
+ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', nope: 1 } }), /watch.watch-request@1/);
+ await invalid(def({ cadence: { every_sec: 5 } }), /watch.watch-request@1/, true);
+ await invalid(def({ summary: 'not a field' }), /watch.watch-request@1/);
+ await invalid(def({ action: [] }), /watch.watch-request@1/);
+ await invalid({ ...def(), owner: 'usr_01J0000000000000000000000' }, /watch.watch-request@1/);
+ // and the rules a schema cannot carry
+ await invalid(def({ cadence: null }), /needs a cadence/);
+ await invalid(def({ cadence: {} }), /every_sec/);
+ await invalid(def({ source: { kind: 'event', pattern: 'release.published' }, cadence: { every_sec: 60 } }), /never polls/);
+ await invalid(def({ source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: { every_sec: 60 } }), /never polls/);
+ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', headers: { Host: 'evil.example' } } }), /Host/);
+ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', headers: { 'if-none-match': 'x' } } }), /if-none-match/);
+ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', auth: { mode: 'header', env: 'SOMETHING_ELSE' } } }), /watch.watch-request@1/);
+ // a HEAD source states no body, so only a record extraction can read one
+ await invalid(def({ source: { kind: 'http', url: 'https://example.org/x', method: 'HEAD' } }), /HEAD source states no body/);
+ const head = await svc.registry.create(def({ source: { kind: 'http', url: 'https://example.org/x', method: 'HEAD' }, extraction: { kind: 'json', value_path: 'status' } }), me);
+ assert.strictEqual(head.source.method, 'HEAD');
+});
+
+t('create: an id, an owner, a name, the arm and the event', async () => {
+ const before = Date.now();
+ const row = await svc.registry.create(def({ name: undefined }), me);
+ assert.match(row.id, /^wch_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(row.owner_sub, me.sub);
+ assert.strictEqual(row.status, 'active');
+ assert.strictEqual(row.name, 'example.org/page', 'a watch without a name is named after its source');
+ assert.ok(row.next_due_at >= before && row.next_due_at <= before + 1000, 'a new watch is due on the next tick (its first reading)');
+ assert.strictEqual(row.updated_by, me.sub);
+ const events = (await svc.outbox.all()).filter(e => e.subject.id === row.id);
+ assert.deepStrictEqual(events.map(e => e.event_type), ['watch.watch.created']);
+ assert.strictEqual(events[0].payload.owner, me.sub);
+ const agentOwned = await svc.registry.create(def(), { sub: newAgent() });
+ assert.match(agentOwned.owner_sub, /^agt_/);
+});
+
+t('the view is validated against watch.watch@1, and the health tells the runtime truthfully', async () => {
+ const row = await svc.registry.create(def(), me);
+ const view = svc.registry.view(row);
+ assert.ok(validate('watch.watch@1', view).valid);
+ assert.deepStrictEqual(Object.keys(view).sort(), [
+ 'action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',
+ 'labels', 'name', 'owner', 'project_id', 'retention', 'source', 'status', 'updated_at',
+ ]);
+ assert.deepStrictEqual(view.health.status, 'never_checked');
+ assert.strictEqual(view.health.next_due_at != null, true);
+ assert.match(view.created_at, /^\d{4}-\d{2}-\d{2}T/);
+ // a paused watch is paused, and an event watch waits (it is never due)
+ const paused = svc.registry.view(await svc.registry.patch(row.id, { status: 'paused' }, me));
+ assert.strictEqual(paused.health.status, 'paused');
+ assert.strictEqual(paused.health.next_due_at, null);
+ const pushed = await svc.registry.create(def({ source: { kind: 'event', pattern: 'release.published' }, cadence: null, extraction: { kind: 'json', value_path: 'version' } }), me);
+ assert.strictEqual(svc.registry.view(pushed).health.status, 'waiting');
+ assert.strictEqual(svc.registry.view(pushed).cadence, null);
+});
+
+t('due() selects active, armed watches oldest first, and nothing else', async () => {
+ const a = await svc.registry.create(def(), me);
+ const b = await svc.registry.create(def(), me);
+ const paused = await svc.registry.create(def(), me);
+ const eventish = await svc.registry.create(def({ source: { kind: 'event', pattern: 'a.b' }, cadence: null }), me);
+ await svc.registry.patch(paused.id, { status: 'paused' }, me);
+ await svc.db.prepare('UPDATE watches SET next_due_at = 2000 WHERE id = ?').run(a.id);
+ await svc.db.prepare('UPDATE watches SET next_due_at = 1000 WHERE id = ?').run(b.id);
+ await svc.db.prepare('UPDATE watches SET next_due_at = 1 WHERE id = ?').run(paused.id);
+ await svc.db.prepare('UPDATE watches SET next_due_at = 1 WHERE id = ?').run(eventish.id);
+ const due = (await svc.registry.due(50)).map(d => d.id);
+ assert.deepStrictEqual(due.slice(0, 2), [b.id, a.id], 'oldest due first');
+ assert.ok(!due.includes(paused.id), 'a paused watch is not due');
+ assert.ok(!due.includes(eventish.id), 'a watch without a cadence is never due');
+ // not_before holds a watch back even when its turn has come
+ await svc.db.prepare('UPDATE watches SET not_before = ? WHERE id = ?').run(Date.now() + 3600_000, b.id);
+ assert.ok(!(await svc.registry.due(50)).map(d => d.id).includes(b.id));
+});
+
+t('patch re-arms the cadence, and pause/resume through status', async () => {
+ const w = await svc.registry.create(def(), me);
+ const before = Date.now();
+ const slowed = await svc.registry.patch(w.id, { cadence: { every_sec: 3600 } }, me);
+ assert.ok(slowed.next_due_at >= before + 3600_000, 're-armed on the new cadence');
+ assert.strictEqual(slowed.updated_by, me.sub);
+ const paused = await svc.registry.patch(w.id, { status: 'paused' }, me);
+ assert.strictEqual(paused.status, 'paused');
+ assert.strictEqual(paused.next_due_at, 0);
+ const resumed = await svc.registry.patch(w.id, { status: 'active' }, me);
+ assert.strictEqual(resumed.status, 'active');
+ assert.ok(resumed.next_due_at >= Date.now() + 3600_000 - 1000);
+ // only the named fields change
+ const renamed = await svc.registry.patch(w.id, { name: 'renamed' }, me);
+ assert.strictEqual(renamed.name, 'renamed');
+ assert.deepStrictEqual(renamed.source, w.source, 'jsonb rows come back parsed');
+ // a patch that would break a rule is refused, and the row is untouched
+ await assert.rejects(() => svc.registry.patch(w.id, { cadence: null }, me), (err) => err.code === 'watch.invalid');
+ const after = await svc.registry.get(w.id);
+ assert.deepStrictEqual(after.cadence, { every_sec: 3600 });
+ const events = (await svc.outbox.all()).filter(e => e.subject.id === w.id).map(e => e.event_type);
+ assert.deepStrictEqual(events, ['watch.watch.created', 'watch.watch.updated', 'watch.watch.paused', 'watch.watch.updated', 'watch.watch.updated']);
+});
+
+t('soft delete: gone from every read, still resolvable for its observations', async () => {
+ const w = await svc.registry.create(def(), me);
+ await svc.db.prepare('INSERT INTO observations (id, watch_id, check_run_id, observed_at, value, value_hash, changed, condition_met) VALUES (?,?,?,?,?,?,0,0)')
+ .run('wco_01J0000000000000000000000', w.id, 'ckr_01J0000000000000000000000', Date.now(), '"x"', 'a'.repeat(64));
+ const out = await svc.registry.remove(w.id, me);
+ assert.deepStrictEqual(out, { id: w.id, deleted: true });
+ assert.strictEqual(await svc.registry.get(w.id), null);
+ assert.ok(!(await svc.registry.all(me)).some(r => r.id === w.id));
+ assert.ok(!(await svc.registry.due(200)).some(d => d.id === w.id));
+ const raw = await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(w.id);
+ assert.ok(raw.deleted_at > 0, 'the row stays, with its observations');
+ assert.strictEqual((await svc.db.prepare('SELECT COUNT(*) AS n FROM observations WHERE watch_id = ?').get(w.id)).n, 1);
+ // a second delete is not found
+ assert.strictEqual(await svc.registry.remove(w.id, me), null);
+});
+
+t('owner scoping: another subject sees nothing, and every write is scoped too', async () => {
+ const w = await svc.registry.create(def(), me);
+ assert.strictEqual(await svc.registry.get(w.id, someoneElse), null, '404 for another owner, never 403');
+ assert.ok(!(await svc.registry.all(someoneElse)).some(r => r.id === w.id));
+ assert.strictEqual(await svc.registry.patch(w.id, { name: 'stolen' }, someoneElse), null);
+ assert.strictEqual(await svc.registry.remove(w.id, someoneElse), null);
+ assert.strictEqual((await svc.registry.get(w.id, me)).name, 'a watch');
+ // a service acting for a person is scoped to that person
+ const acted = await svc.registry.create(def(), { sub: 'svc:site', subject: me.sub });
+ assert.strictEqual(acted.owner_sub, me.sub);
+ assert.ok((await svc.registry.all(me)).some(r => r.id === acted.id));
+ assert.ok(!(await svc.registry.all({ sub: 'svc:site' })).some(r => r.id === acted.id));
+});
+
+t('all(): the keyset page is id order (a wch_ ULID is creation order)', async () => {
+ const other = { sub: newUser() };
+ const ids = [];
+ for (let i = 0; i < 5; i++) ids.push((await svc.registry.create(def(), other)).id);
+ const page1 = await svc.registry.all(other, { limit: 2 });
+ assert.deepStrictEqual(page1.map(r => r.id), [ids[4], ids[3]]);
+ const page2 = await svc.registry.all(other, { limit: 2, before: page1[1].id });
+ assert.deepStrictEqual(page2.map(r => r.id), [ids[2], ids[1]]);
+ const page3 = await svc.registry.all(other, { limit: 2, before: page2[1].id });
+ assert.deepStrictEqual(page3.map(r => r.id), [ids[0]]);
+ assert.strictEqual((await svc.registry.all(other, { limit: 10 })).length, 5);
+ // status filters
+ await svc.registry.patch(ids[0], { status: 'paused' }, other);
+ assert.deepStrictEqual((await svc.registry.all(other, { status: 'paused' })).map(r => r.id), [ids[0]]);
+ assert.strictEqual((await svc.registry.all(other, { status: 'active' })).length, 4);
+});
+
+t('done', async () => { await svc.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+#!/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 PGlite 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
+ * npm test -- --strict # a skipped test fails the run too
+ *
+ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
+ * ○ and not counted as passed (openvibe-shared/test-runner).
+ */
+'use strict';
+// 180 s per file: every test that boots the service stands up its own PGlite (a WASM PostgreSQL whose first
+// query is seconds of init), and on a loaded machine — the CI shards and the local check run side by side — a
+// file that takes ~30 s idle can pass 60 s. Same 180 s as Host's PGlite suite; a hung file still dies.
+require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 32, parallel: 1 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+/**
+ * The scheduler: due order (oldest due first), the concurrency cap, not_before/backoff holding a
+ * watch back, and the worker actually checking on its own tick.
+ */
+const assert = require('assert');
+const { boot, site, suite, sleep, newUser } = require('./helpers');
+
+const owner = { sub: newUser() };
+let svc;
+let web;
+
+const create = async (name, overrides = {}) => await svc.registry.create({
+ name,
+ source: { kind: 'http', url: `${web.origin}/page`, format: null },
+ cadence: { every_sec: 60 },
+ extraction: { kind: 'text' },
+ condition: { op: 'eq', value: 'nothing' },
+ action: [{ kind: 'notification' }],
+ ...overrides,
+}, owner);
+
+const dueAt = async (id, next, notBefore = 0) => {
+ await svc.db.prepare('UPDATE watches SET next_due_at = ?, not_before = ? WHERE id = ?').run(next, notBefore, id);
+};
+const quiet = async () => {
+ for (let i = 0; i < 200 && svc.check.inflight().length; i++) await sleep(25);
+ assert.deepStrictEqual(svc.check.inflight(), [], 'every check finished');
+};
+
+const t = suite('scheduler');
+
+t('boot', async () => {
+ web = await site({ '/page': () => ({ body: 'a page' }), '/slow': () => ({ delayMs: 300, body: 'slow' }) });
+ svc = await boot({ env: { WATCH_MAX_CONCURRENT: '2' } });
+});
+
+t('tick starts the watches whose turn has come, oldest due first', async () => {
+ const a = await create('a');
+ const b = await create('b');
+ const c = await create('c');
+ // all due, in an order the creation ids do not happen to share
+ await dueAt(c.id, 3000);
+ await dueAt(a.id, 1000);
+ await dueAt(b.id, 2000);
+ assert.deepStrictEqual(await svc.scheduler.tick(), [a.id, b.id], 'two, the cap, oldest first');
+ await quiet();
+ assert.deepStrictEqual(await svc.scheduler.tick(), [c.id], 'the next tick takes the next one');
+ await quiet();
+ assert.deepStrictEqual((await svc.registry.due(10)).map(d => d.id), [], 'nothing is left due');
+});
+
+t('the concurrency cap holds: no more than WATCH_MAX_CONCURRENT checks at a time', async () => {
+ const ids = [];
+ for (let i = 0; i < 4; i++) {
+ const w = await create(`slow ${i}`, { source: { kind: 'http', url: `${web.origin}/slow`, format: null }, condition: { op: 'eq', value: 'nothing' } });
+ await dueAt(w.id, 1000 + i);
+ ids.push(w.id);
+ }
+ const first = await svc.scheduler.tick();
+ assert.strictEqual(first.length, 2, 'two, the cap');
+ assert.deepStrictEqual(first, ids.slice(0, 2));
+ assert.strictEqual(svc.scheduler.active().length, 2);
+ assert.deepStrictEqual(await svc.scheduler.tick(), [], 'nothing is free while both are in flight');
+ assert.deepStrictEqual(svc.check.inflight().sort(), first.slice().sort());
+ await quiet();
+ const third = await svc.scheduler.tick();
+ assert.strictEqual(third.length, 2);
+ assert.ok(!first.includes(third[0]));
+ await quiet();
+ assert.deepStrictEqual(svc.scheduler.active(), []);
+});
+
+t('a watch that is not due, or is waiting out a Retry-After, is not started', async () => {
+ const later = await create('later');
+ const waiting = await create('waiting');
+ const paused = await create('paused');
+ await svc.registry.patch(paused.id, { status: 'paused' }, owner);
+ const gone = await create('gone');
+ await svc.registry.remove(gone.id, owner);
+ await dueAt(later.id, Date.now() + 3600_000);
+ await dueAt(waiting.id, 1000, Date.now() + 3600_000);
+ await dueAt(paused.id, 1000);
+ await dueAt(gone.id, 1000);
+ assert.deepStrictEqual(await svc.scheduler.tick(), [], 'nothing here is due');
+ const due = new Set((await svc.registry.due(50)).map(d => d.id));
+ for (const w of [later, waiting, paused, gone]) assert.ok(!due.has(w.id), `${w.id} is not in the queue`);
+});
+
+t('a failing watch backs off: the scheduler leaves it alone until its next due time', async () => {
+ const w = await create('broken', { source: { kind: 'http', url: `${web.origin}/nope`, format: null } });
+ await dueAt(w.id, 0);
+ const out = await svc.check.run(w.id, { trigger: 'schedule' });
+ assert.strictEqual(out.run.state, 'http_error');
+ const row = await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(w.id);
+ assert.strictEqual(row.next_due_at - row.last_check_at, 60000, 'first failure: every_sec · 2^0');
+ let due = new Set((await svc.registry.due(50)).map(d => d.id));
+ assert.ok(!due.has(w.id), 'not due until the backoff has run');
+ await dueAt(w.id, row.next_due_at - 120000);
+ due = new Set((await svc.registry.due(50)).map(d => d.id));
+ assert.ok(due.has(w.id));
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+
+t.run();
+
+// A second suite: with the worker on, the tick itself checks due watches.
+const t2 = suite('scheduler (worker on)');
+t2('the worker checks a due watch without anyone asking', async () => {
+ const web2 = await site({ '/page': () => ({ body: 'tick' }) });
+ const svc2 = await boot({ worker: 'on', env: { WATCH_TICK_MS: '40', WATCH_HOST_MIN_INTERVAL_MS: '0' } });
+ try {
+ const w = await svc2.registry.create({
+ name: 'auto', source: { kind: 'http', url: `${web2.origin}/page`, format: null },
+ cadence: { every_sec: 60 }, extraction: { kind: 'text' },
+ condition: { op: 'eq', value: 'nothing' }, action: [{ kind: 'notification' }],
+ }, { sub: newUser() });
+ // a new watch is due at once (its first reading), then armed by cadence after each check;
+ // bring its turn forward and let the worker find it on its own
+ await svc2.db.prepare('UPDATE watches SET next_due_at = 0 WHERE id = ?').run(w.id);
+ for (let i = 0; i < 100 && (await svc2.db.prepare('SELECT COUNT(*) AS n FROM check_runs WHERE watch_id = ?').get(w.id)).n === 0; i++) await sleep(50);
+ const runs = await svc2.db.prepare('SELECT * FROM check_runs WHERE watch_id = ?').all(w.id);
+ assert.strictEqual(runs.length, 1, 'the scheduler ran exactly one check (the cadence is a minute)');
+ assert.strictEqual(runs[0].trigger, 'schedule');
+ assert.strictEqual(runs[0].state, 'changed');
+
+ const ready = await (await fetch(`${svc2.base}/api/ready`)).json();
+ assert.strictEqual(ready.checks.db.status, 'ok');
+ assert.strictEqual(ready.checks.checker.status, 'ok', JSON.stringify(ready.checks.checker));
+ assert.strictEqual(ready.checks.checker.detail.running, true);
+ assert.strictEqual(ready.ready, true);
+
+ const metrics = await (await fetch(`${svc2.base}/metrics`)).text();
+ assert.match(metrics, /watch_watches\{status="healthy"\} 1/);
+ assert.match(metrics, /watch_last_check_timestamp_seconds/);
+ assert.match(metrics, /watch_observations\{state="changed"\} 1/);
+ } finally { await svc2.stop(); await web2.close(); }
+});
+t2.run();