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)

OP OpenVibers committed 7 Oct 2026 · ff317b6c5a · 46 files, +7555 −0

Everything is built and green. Final report:

## What's in the checkout

`OpenVibe.Watch` on branch `agent/plan-w1-watch-10071025`, modelled file-for-file on `OpenVibe.Sources` (origin/main), pinned to **openvibe-contracts v0.107.0, openvibe-sdk v0.34.0, openvibe-shared v2.11.0**, PORT **4730**, `WATCH_RUN_URL` default **http://127.0.0.1:4920**. Nothing committed, nothing pushed.

### File list

**Root**
- `package.json` — dossier skeleton, corrected pins; deps via codeload tag tarballs
- `package-lock.json` — generated with `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` (through `node` child_process); resolved 0.107.0 / 0.34.0 / 2.11.0
- `.env.example` — every `WATCH_*`/`OV_*` key from the dossier (+ `WATCH_ALLOWED_PORTS`, `WATCH_ALLOW_PRIVATE_HOSTS`, `WATCH_OBSERVATIONS_MAX_PER_CHECK`, `WATCH_RETENTION_DEFAULT_DAYS`, `WATCH_RUN_URL=…4920`)
- `LICENSE` — AGPL-3.0, copied from Sources
- `README.md` — purpose, model, binding carrier order, a check step by step, extraction/condition tables, API + limits, events, security, owns/does-not-own, acceptance
- `SECURITY.md` — copied from Sources
- `STATUS.json` — stage alpha, port 4730, contracts v0.107.0, `deployed: false`
- `.gitignore` — copied from Sources
- `.github/workflows/ci.yml` — Sources' shared test/security jobs with `contracts-service: watch`
- `migrations/0001_initial.sql` — the dossier's whole schema verbatim (watches, watch_endpoint_state, check_runs, observations, counters, event_outbox)

**server/**
- `config.js` — Sources' config with watch's port/service/audience/limits/worker/fetch, `run` and `retention` blocks; `secrets: env` for `WATCH_CRED_*` by name
- `db.js` — Sources', `service: 'watch'`/`'watch-migrate'`, PGlite in `data/pglite`, `nextSeq` kept
- `auth.js` — one capability per route; `svc:` via the JWKS client + contracts' `verifyServiceToken`, `usr_`/`agt_` via `openvibe-sdk/auth` `verifyUserToken`; sets `req.principal = { sub, kind, role, cap, jti, subject }`; `X-OV-Subject` only from a first-party `svc:`
- `registry.js` — request/view validation with openvibe-contracts' validator, CRUD with owner scoping, `nextDue` arming/backoff, soft delete, ULID keyset page, `due()`
- `observations.js` — build/insert (one transaction with the run), latest/before, `heldSince` (the for_sec trail), list, retention prune, `watch.observation@1` view
- `condition.js` — `changed` with tolerance/percent, every predicate op, `for_sec` debounce, no guessing on non-numbers
- `extract.js` — text/html/css subset (own tokenizer + selector matcher)/json/jsonpath/regex; `ai` → `unsupported`; `fields`
- `util.js` — sha256, canonical JSON, hashes, body decoding, text/date/URL helpers (Sources' adapters/util.js)
- `check.js` — the engine: inflight, carrier choice, fetch, extract, evaluate, observation+run+events in one transaction, arming, `watch.observation.recorded` / `watch.watch.triggered` / `watch.check.failed`

Changes

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