openvibers/openvibe.watch README.md
README.md
| 1 | # OpenVibe.Watch |
| 2 | |
| 3 | > Persistent, user-defined observations and conditions over pages, feeds, APIs, webhooks and OpenVibe events: define what to |
| 4 | > watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or |
| 5 | > the site's own ETag will do. |
| 6 | |
| 7 | **Live site:** https://openvibe.watch · **API docs:** https://openvibe.services · **For AI:** https://openvibe.watch/llms.txt |
| 8 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 9 | |
| 10 | **Status:** alpha (plan T18). The registry, the check engine, the pull carriers (`http`, `feed`, `api`), the push |
| 11 | sources (OpenVibe events and signed webhooks), Link probes from the owner's own machine, the actions (a notification, |
| 12 | an event, a webhook, an Actor task or agent run, a Run job, a Codes task) and the public site (sign-in, seven starting |
| 13 | points, forms) exist. The Run rung (browser or code polling through OpenVibe.Run) and the `ai` extraction do not |
| 14 | yet. |
| 15 | **Domain:** `openvibe.watch` (a public, server-rendered site at [Public site](#public-site); the API |
| 16 | is loopback-only). Install steps: [Public host](#public-host). |
| 17 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete), |
| 18 | the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison, |
| 19 | condition, action, budget, retention". |
| 20 | **License:** AGPL-3.0. |
| 21 | |
| 22 | ## Purpose |
| 23 | |
| 24 | Sources ingests the sources a product chose; Watch is the place a **person or an agent** defines |
| 25 | their own observation: "this page's price", "this feed's latest release", "this API's stock", and |
| 26 | the condition on it. Sources performs permitted structured ingestion; **Watch owns user-defined |
| 27 | persistent observations and conditions**; Run executes expensive browser/code checks; Link observes |
| 28 | local/LAN things; Actor interprets and acts; Events transports the trigger. |
| 29 | |
| 30 | A Watch is a first-class OpenVibe resource (`watch.watch@1`), so products compose it instead of |
| 31 | each growing a watcher: Rent, Homes, Work, Deals, Reviews, price trackers, uptime, releases. |
| 32 | |
| 33 | ## Running it |
| 34 | |
| 35 | ```bash |
| 36 | npm install |
| 37 | cp .env.example .env |
| 38 | npm run dev # http://127.0.0.1:4730 |
| 39 | npm test # every test/*.test.js against local stub sites; no internet |
| 40 | npm run test:pg # the same through PostgreSQL + PgBouncer (the CI path) |
| 41 | ``` |
| 42 | |
| 43 | Node 22 in production. Production: `/opt/openvibe.watch`, env `/etc/openvibe/watch.env`, unit |
| 44 | [deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service), database `ov_watch` |
| 45 | on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh watch`), nginx |
| 46 | [deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf). |
| 47 | |
| 48 | `GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the |
| 49 | database fails; a Network key that has not loaded and a check worker that is off, stopped or behind |
| 50 | (a watch due for more than 15 minutes) degrade it. It reports watch counts by health status, checks |
| 51 | in flight, observations by state and the outbox backlog. `GET /metrics` (openvibe-shared/metrics) |
| 52 | answers direct loopback callers only: golden signals by route template, `watch_watches{status}`, |
| 53 | `watch_observations{state}`, the check queue (`watch_checks_due`, |
| 54 | `watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`), |
| 55 | `watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`. |
| 56 | |
| 57 | Request telemetry: every HTTP request is aggregated by route template, method and status class, never |
| 58 | by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and |
| 59 | event-loop-lag gauges, goes to this service's own `telemetry_samples` table (openvibe-sdk 0.46, |
| 60 | `platform.telemetry-sample@1`, migration 0011); rows older than 14 days are deleted daily. `/shared` |
| 61 | files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` mounts |
| 62 | openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` |
| 63 | at production start, runs the retention and flushes on stop. |
| 64 | |
| 65 | ## Public site |
| 66 | |
| 67 | `openvibe.watch` is also the place a person manages their own watches in a browser. Every page is |
| 68 | server-rendered and complete without JavaScript: the create and edit forms POST, pause/resume/ |
| 69 | delete/check-now are plain forms, and the fields a form shows are chosen by the query |
| 70 | (`/watches/new?template=price`), not by script. The visual layer is plain semantic HTML on the |
| 71 | classes openvibe-shared's frame and this repository's `public/css/app.css` already provide. |
| 72 | |
| 73 | **Sign-in** is OpenVibe.Network's OAuth 2 authorization code with PKCE (S256), as the OAuth client |
| 74 | `watch` (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`), redirecting to `BASE_URL/auth/callback`. |
| 75 | The Network token is verified once (issuer, audience `openvibe.network`, a `usr_…`/`agt_…` subject) |
| 76 | and then discarded; the browser holds an opaque random session token in an httpOnly, SameSite=Lax |
| 77 | cookie, and Watch's database stores only its SHA-256 hash (`web_sessions`, migration 0002). The |
| 78 | signed-in person's `usr_…` subject is the watch owner: the site calls the **registry directly, |
| 79 | in-process**, with exactly the ownership rules the API applies — another person's watch is 404 on |
| 80 | every page and every form, never 403. A signed-in write must also come from this site itself |
| 81 | (same-origin), so another site cannot make a visitor act here. |
| 82 | |
| 83 | | Page | What it shows | |
| 84 | |---|---| |
| 85 | | `/` | what Watch is in one paragraph; the seven starting points as cards; channels active on OpenVibe.Live lately (live first) with a "tell me when they go live" watch each; ready-made watches (Host plan prices, release and job feeds, each blog's new posts); how it checks in plain words; the API samples; the questions (FAQPage JSON-LD); your watches summary (counts by status) when signed in. The channels, plans and blogs are OpenVibe.Search's public documents, read anonymously and kept 10 minutes ([server/http/network-reads.js](server/http/network-reads.js), `WATCH_SEARCH_URL`, `https://search.openvibe.network` in production, off elsewhere); a section with nothing to show is left out | |
| 86 | | `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` | |
| 87 | | `/watches/new?template=page\|price\|feed\|json\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs; a link may fill it in (`url`, `name`, `value_path`, `op`, `value`, `event`, `event_filter`, `agent_id`), and the person still reviews and saves | |
| 88 | | `/watches/:id` | the watch in words, status with pause/resume/delete/check-now, the observation history (time, value, changed, condition met) and the check runs with their states in plain words, both paged | |
| 89 | | `/watches/:id/edit` | the same form, PATCH semantics | |
| 90 | | `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog | |
| 91 | | `/robots.txt`, `/sitemap.xml`, `/llms.txt(/-full)` | discovery: the home page and how-it-works are listed; `/watches` and `/auth` are disallowed | |
| 92 | |
| 93 | **Seven starting points**, each a page of the same form ([server/http/pages.js](server/http/pages.js) `TEMPLATES`): |
| 94 | |
| 95 | | Template | Source | Extraction | Condition | |
| 96 | |---|---|---|---| |
| 97 | | `page` | http | html, an optional CSS selector | `changed` | |
| 98 | | `price` | http | css selector or regex | `lt`/`lte`/`gt`/`gte` | |
| 99 | | `feed` | feed | the newest item (`latest`) | `changed` | |
| 100 | | `json` | api | json with an optional `value_path` | any of the ops | |
| 101 | | `event` | event (a channel goes live, a blog post, a wiki page published …) | the event | `changed` | |
| 102 | | `webhook` | webhook (the watch's own signed address) | json with an optional `value_path` | any of the ops | |
| 103 | | `lan` | node (a host:port on the owner's own network, checked by their own computer) | `status` | `eq` `down` | |
| 104 | |
| 105 | Besides **Notify me**, the form can run one of the person's OpenVibe.Actor agents (`actor-agent`, by its `agt_` id, |
| 106 | with an optional note) or start a coding task on OpenVibe.Codes in a repository they name (`codes-run`) when the watch |
| 107 | fires. |
| 108 | |
| 109 | The cadence is a select (15 minutes, 1 hour, 6 hours, 1 day); 15 minutes is the shortest a person may |
| 110 | choose here (the contract's floor is 30 seconds, for a service). A validation error re-renders the |
| 111 | form with the person's own input and the registry's own message next to the field it names. The |
| 112 | kind whose carrier does not exist yet (`run`) and the `ai` extraction are **not offered** — and a watch of such a kind |
| 113 | created through the API still answers `watch.carrier_unavailable` when checked from the site, fetching nothing. An |
| 114 | event watch is checked when its event arrives and a webhook watch when a signed delivery reaches its `/hooks/` address, |
| 115 | so Check now answers `watch.event_awaited` or `watch.webhook_awaited`, fetching nothing. |
| 116 | |
| 117 | Check-now from the page respects the API's limits and its refusals: a second check while one runs or |
| 118 | a check of a paused watch is the API's 409, said in words; a kind without a carrier is the API's 422; |
| 119 | and the manual-check and write budgets (10/120 and 30/600 per person, a minute/hour) are enforced |
| 120 | with a friendly page. "Notify me" stores the notification action: when the condition fires the owner gets one |
| 121 | notification on OpenVibe (through OpenVibe.Network), and the watch page lists what each firing did under "What it did |
| 122 | when it fired". |
| 123 | |
| 124 | ## The watch model |
| 125 | |
| 126 | A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`, |
| 127 | `action`, `budget`, `retention`, plus `owner`, `status`, `labels`. It is validated against |
| 128 | `watch.watch-request@1` on the way in and served as `watch.watch@1` on the way out — both with |
| 129 | openvibe-contracts' own validator, never a hand copy of the schema. Rules a schema cannot carry |
| 130 | ([server/registry.js](server/registry.js)): a create requires source, extraction, condition and |
| 131 | action; a source that polls (`http`, `feed`, `api`, `node`, `run`) needs `cadence.every_sec`; an |
| 132 | `event` or `webhook` source is pushed to and never polls (`cadence: null`); the headers the fetcher |
| 133 | owns (`Host`, `Cookie`, `User-Agent`, `If-None-Match`, …) cannot be overridden. |
| 134 | |
| 135 | **Ownership is the authority.** Every read and write is scoped to the acting subject — a person's |
| 136 | `usr_…`/`agt_…` bearer token, or a first-party service acting for a person with `X-OV-Subject` — |
| 137 | and another owner's watch is **404, never 403**: an id must not confirm that someone else's watch |
| 138 | exists. A service principal that does not name a person owns what it creates. |
| 139 | |
| 140 | ## Carrier preference (binding, plan T18) |
| 141 | |
| 142 | webhook/event → ETag/Last-Modified → feed → API → and only then browser polling through Run |
| 143 | |
| 144 | The rungs run from cheapest to most expensive, and `source.kind` fixes which one a watch uses. |
| 145 | This release carries the pull rungs and event sources: |
| 146 | |
| 147 | | Kind | Carrier | State | |
| 148 | |---|---|---| |
| 149 | | `http` | one URL, conditional | implemented | |
| 150 | | `feed` | RSS 2.0 / RSS 1.0 (RDF) / Atom 1.0 → `{ items, latest, count }` | implemented | |
| 151 | | `api` | JSON/XML with `items_path` + `fields`, or the body itself | implemented | |
| 152 | | `event` | delivered by OpenVibe.Events (see [Event sources](#event-sources)) | implemented | |
| 153 | | `webhook` | a signed POST to `/hooks/<watch id>` (see [Webhook sources](#webhook-sources)) | implemented | |
| 154 | | `run` | browser/code polling through OpenVibe.Run | step 6 | |
| 155 | | `node` | a probe from the owner's own machine through OpenVibe.Bot and OpenVibe Link (see below) | implemented (step 7) | |
| 156 | |
| 157 | **Link sources** (plan T18 step 7, contracts 0.167.0, [server/carriers/node.js](server/carriers/node.js)): `{ kind: 'node', node_id: 'dev_…', probe: { target, timeout_s } }`. The check runs from the owner's own machine: |
| 158 | - **The call**: Watch asks OpenVibe.Bot (`WATCH_BOT_URL`, default `http://127.0.0.1:4630`) with its own token (`bot.computer.control`) and the owner in `X-OV-Subject`. Bot answers only for that person's machine, only where they turned on "Let my watches check things from this computer", and Watch may send nothing but the read-only `probe`. |
| 159 | - **Targets**: the machine's OpenVibe Link reaches only its own network (private, loopback or link-local addresses). |
| 160 | - **http(s) targets**: the answer's body is extracted like a page, and a status other than 200/203 is an `http_error`. |
| 161 | - **tcp targets**: the document is `{ open, status: 'up' | 'down', ms }`. |
| 162 | - **Owners**: an agent-owned watch is never probed (`node_person_only`). |
| 163 | - **The form**: its "Something on your network goes down" template makes one: a machine id, a host:port, and the condition `status` eq `down`. |
| 164 | |
| 165 | A kind whose carrier does not exist yet is **accepted by the registry but never fetched**: the |
| 166 | check is recorded `skipped` with `watch.carrier_unavailable` and the reason names the step. That is |
| 167 | what keeps a half-built rung from silently turning into polling. |
| 168 | |
| 169 | ## Event sources |
| 170 | |
| 171 | A watch whose source is `{ "kind": "event", "pattern": "host.release.published", "filter": { "repo": "OpenVibe.Bot" } }` |
| 172 | (and `cadence: null`) is checked when a matching OpenVibe.Events event arrives, and never on a cadence, so it costs |
| 173 | nothing while nothing happens (`server/event-sources.js`, `server/carriers/event.js`). |
| 174 | |
| 175 | - **Matching.** The pattern matches the event type as Events matches topics: `*` stands for one or more whole |
| 176 | segments (`media.*.ready`). Every `filter` key must equal the event's value: `subject.id`, `subject.type`, or a |
| 177 | top-level `payload` field. |
| 178 | - **What the extraction reads.** The event is the check's document: `{ event_type, event_id, timestamp, subject, |
| 179 | payload }`, so `{ "kind": "json", "value_path": "payload.version" }` takes the released version. Comparison, |
| 180 | condition, observations and actions work exactly as for a fetched page. |
| 181 | - **Only public events.** Anyone may own a watch, and an internal or private event is not theirs to read, so only |
| 182 | events with `visibility: "public"` wake a watch. |
| 183 | - **Once per event.** Events delivers at least once; each `event_id` is handled once (`event_source_receipts`, kept |
| 184 | seven days), so a redelivery is answered `duplicate`. |
| 185 | - **A budget.** At most `WATCH_EVENT_MAX_PER_HOUR` (60) event checks per watch an hour. More are dropped and counted, |
| 186 | so a busy pattern cannot turn one watch into a write storm. |
| 187 | - **Subscriptions.** Watch keeps one Events subscription per distinct pattern of an active event watch, delivered to |
| 188 | the same signed loopback route as the account events (`POST /internal/events`, `WATCH_EVENTS_SECRET`). A sync at |
| 189 | boot, every `WATCH_EVENT_SYNC_MS` (10 minutes) and two seconds after an event watch changes creates the missing ones, |
| 190 | re-enables one a watch needs again, and disables the ones no watch needs. It never touches the account |
| 191 | subscriptions or another endpoint's. It is off without `EVENTS_URL`, the secret or the OAuth client secret. |
| 192 | - **Check now** on an event watch answers 422 `watch.event_awaited`: there is no event to read. |
| 193 | |
| 194 | ## Webhook sources |
| 195 | |
| 196 | A watch whose source is `{ "kind": "webhook" }` (and `cadence: null`) is woken by a POST to |
| 197 | `https://openvibe.watch/hooks/<watch id>` from anything that can call a webhook: GitHub, a CI, a store, another OpenVibe |
| 198 | product (`server/webhook-sources.js`, `server/carriers/webhook.js`). |
| 199 | |
| 200 | - **The address is the watch's own.** Its stored endpoint is `/internal/hooks/<watch id>` (the form `watch.watch@1` |
| 201 | names), whatever a caller sent, so a watch can never point at another watch's address. |
| 202 | - **The secret is shown once.** Creating the watch answers `{ watch, webhook: { url, secret } }`. The secret |
| 203 | (`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at |
| 204 | once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch: |
| 205 | deleting the watch or the account removes it. |
| 206 | - **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form), |
| 207 | `Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token` |
| 208 | (give the secret as `setWebhook`'s `secret_token`). Compared in constant time. |
| 209 | - **A sender's own secret (agent plan A3).** Slack, Stripe and Discord sign with a secret or key they make. Paste it on the watch's |
| 210 | page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with |
| 211 | `{ "provider": "slack" | "stripe" | "discord", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept |
| 212 | (never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of |
| 213 | Watch's own (the same call with no body) replaces it. |
| 214 | - `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within |
| 215 | five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is |
| 216 | acknowledged without checking twice. |
| 217 | - `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time |
| 218 | within five minutes. |
| 219 | - `discord`: `X-Signature-Ed25519` over `X-Signature-Timestamp` + body, checked with the application's public key |
| 220 | (64 hex characters from the Developer Portal). Paste the address as the Interactions Endpoint URL. Discord wants |
| 221 | its reply within three seconds: its ping gets `{ "type": 1 }`, and any other interaction a quiet `{ "type": 4 }` |
| 222 | ("Got it") at once, with the check run right after on the interaction (`value_path: "data.name"` reads a slash |
| 223 | command's name). A bad signature answers 401, as Discord checks. |
| 224 | - Cases: `test/webhook-providers.test.js`. |
| 225 | - **Email in (agent plan A3).** A webhook watch can have an email address of its own instead of a secret: "Wake it by |
| 226 | email?" on its page, or `POST /api/v1/watches/:id/webhook-secret` with `{ "provider": "email" }`, answers |
| 227 | `{ address: "in-<26 characters>@openvibe.watch" }`. The address is the key (anyone who has it can wake the watch); |
| 228 | asking again makes a new one and retires the old, and a webhook secret replaces it. |
| 229 | - Cloudflare Email Routing receives the domain's mail (SPF, DKIM, DMARC checked there) and its catch-all rule hands |
| 230 | every message to the mail Worker ([edge/mail-worker.mjs](edge/mail-worker.mjs)). The Worker posts |
| 231 | `{ to, from, raw (base64, at most 180 KB), truncated, size }` to `POST /hooks/email` with |
| 232 | `X-OpenVibe-Mail-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (`WATCH_MAIL_INGRESS_SECRET`, within |
| 233 | five minutes). |
| 234 | - Watch finds the watch by the address, reads the message ([server/mail-parse.js](server/mail-parse.js): headers, |
| 235 | encoded words, multipart, base64 and quoted-printable, HTML-only mail as text) and runs the check on |
| 236 | `{ from, from_name, to, subject, date, message_id, text, text_truncated, attachments }` (`value_path: "subject"`). |
| 237 | `from` is the envelope sender Email Routing checked; `header_from` is what the message says. |
| 238 | - An unknown address answers 404 and a paused watch 409, and the Worker refuses the mail, so its sender gets a bounce. |
| 239 | Over the hour's budget the mail is taken and not checked; a busy watch is tried once more after 1.5 s. |
| 240 | - Deploy: `ssh host "sudo sed -n 's/^WATCH_MAIL_INGRESS_SECRET=//p' /etc/openvibe/watch.env" | node deploy/mail-worker.js --secret-stdin` |
| 241 | uploads the Worker, turns Email Routing on for the zone and points the catch-all at the Worker. |
| 242 | - Cases: `test/email-in.test.js`. |
| 243 | - **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any |
| 244 | other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`. |
| 245 | - **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for an |
| 246 | unknown address or a watch that is not a webhook watch. 409 for a paused watch. 413 over 256 KB. 429 with |
| 247 | `Retry-After` over `WATCH_WEBHOOK_MAX_PER_HOUR` (60) deliveries an hour. 503 with `Retry-After: 1` while the watch's |
| 248 | previous check still runs. |
| 249 | - **Check now** on a webhook watch answers 422 `watch.webhook_awaited`: there is no delivery to read. |
| 250 | |
| 251 | ## Provider-rate evidence (`WATCH_PROVIDER_RATES`, off) |
| 252 | |
| 253 | PLAN P6 gives Watch/Sources the permitted, provenance-backed market data and observed rate changes; the supply |
| 254 | rate card itself stays with OpenVibe.AI. This is that observation feed, behind `WATCH_PROVIDER_RATES=1` (off by |
| 255 | default): two official, machine-readable provider price documents become supply rate observations, a difference |
| 256 | raises a change, and a person approves it. **The approval is evidence; nothing here is billable and no rate card is |
| 257 | written.** AI's catalog operator applies an approved change to the supply rate card through AI's own path. |
| 258 | |
| 259 | **Why Watch and not Sources.** Sources' own README scopes it to the material the publication products ingest and |
| 260 | publish ("Products read items with provenance and decide — under their own review and indexability rules — what, if |
| 261 | anything, to publish"), and `sources.source@1`'s `type` is a released enum. A provider price that may become a |
| 262 | billable supply rate is not publication content; the observation, the change, the condition and the human decision |
| 263 | are Watch's model, and PLAN B assigns observed rate/model changes to Watch/Sources jointly. So the source kind |
| 264 | (`provider-rate`) lives here, and the one ingestion registry stays Sources for product content. |
| 265 | |
| 266 | **The two providers** (both in OpenVibe.AI `docs/provider-matrix.md`, both keyless and JSON — no page is scraped): |
| 267 | |
| 268 | | Source kind | Provider | Official document | Access in the matrix | |
| 269 | |---|---|---|---| |
| 270 | | `provider-rate` | DeepInfra | `https://api.deepinfra.com/models/list` (documented in its OpenAPI) | `RESTRICTED` for resale; evidence only | |
| 271 | | `provider-rate` | Featherless AI | `https://api.featherless.ai/v1/models` (OpenAI-compatible) | `AUTHORIZED_WHOLESALE` (Scale only); terms §3 permit resale, subject to an agreement | |
| 272 | |
| 273 | They are the first two because they publish a price per model in a document a program can read without an account |
| 274 | or an HTML parse, and they bracket the matrix: Featherless is the one provider whose terms permit resale, so its |
| 275 | supply price is the one the exchange will most want to know; DeepInfra is an existing OpenAI-compatible adapter |
| 276 | target with an exact keyless list. Each source's row records its terms and robots notes; **it starts disabled**, and a |
| 277 | person enables it after reading them even when the flag is on. |
| 278 | |
| 279 | **What an observation is** ([server/provider-rates.js](server/provider-rates.js), migration |
| 280 | `0018_provider_rates.sql`): `provider`, `model`, `token_class` (`input`, `output`, and `cached_read` where the |
| 281 | document states it), `nano_usd_per_m` as an **integer string** (D1: never a float), `currency`, `region`, the |
| 282 | `effective_date` (the first day the value was observed; an unchanged value keeps its date, so the rows show effective |
| 283 | periods), `source_url` (the URL actually fetched) and `fetched_at`. |
| 284 | |
| 285 | - **Units.** DeepInfra states cents per token: `× 1e13` is nano-USD per million. Its `rate_per_input_token_cached` |
| 286 | is a multiplier (the pricing page renders input × it), not a price, and `discount` is applied while active |
| 287 | (`price × (1 − discount)`). Cache-write is not recorded: the document states it only as a retention-dependent |
| 288 | multiplier (5m/1h), so no single cache_write rate is invented. |
| 289 | Featherless states USD per token as decimal strings: `× 1e15` is nano-USD per million; its per-million display |
| 290 | numbers are a fallback only. `region` is `global`: neither document scopes its price by region, so no region is invented. |
| 291 | - **Changes.** A successful parse writes one immutable observation per `(model, token_class)` and updates the |
| 292 | source's validators. When the value (or currency/region) differs from the last observation, the same transaction |
| 293 | writes a `provider_rate_changes` row in state `pending` and a `watch.rate.changed` event with |
| 294 | `approval_required: true`. An unchanged value is not written again (its first observation already records it and |
| 295 | keeps the effective date); the fetch row's `stated` and `observations` counts say what the document said versus what |
| 296 | was new, and no change is raised. A new model is recorded but raises no change (there is no previous value to differ |
| 297 | from). |
| 298 | - **Failures.** A malformed document (invalid JSON, a price that is not a finite non-negative decimal, no priced |
| 299 | model) records a `parse_error` fetch and **no observation at all** — a missing price is never zero. A 304 is |
| 300 | `not_modified`; an HTTP error is `http_error`/`rate_limited`; stale means the last success is older than |
| 301 | `stale_after_sec`. The health (`healthy | stale | failing | never_fetched | disabled`) is computed, never guessed, |
| 302 | and the last good observations stay visible with their labels. |
| 303 | - **Approval.** `POST /api/v1/provider-rates/changes/:id/approve` (or `/reject`) requires a **person** (`usr_…`), |
| 304 | not a service or an agent, and records who and when; the answer says `applied: false` and the change's `effect` is |
| 305 | `evidence`. A second decision is `409`. A `watch.rate.approved`/`watch.rate.rejected` event carries the decision, |
| 306 | so AI's operator can apply it, but Watch itself has no rate-card table and no writer for one. |
| 307 | |
| 308 | API (mounted only with the flag on; `watch.watch.read` to read, `watch.watch.manage` to enable/approve, |
| 309 | `watch.check.run` to refresh): `GET /api/v1/provider-rates`, `GET .../sources[/:key]`, |
| 310 | `GET .../observations?latest=1&source=&model=&token_class=`, `GET .../changes[?state=pending]`, |
| 311 | `GET .../changes/:id`, `PATCH .../sources/:key` `{enabled}`, `POST .../sources/:key/refresh`, |
| 312 | `POST .../changes/:id/approve|reject` `{note?}`. The worker fetches enabled sources on |
| 313 | `WATCH_PROVIDER_RATES_TICK_MS` (60 s) every `poll_interval_sec` (6 h); with the flag off the module is not created, |
| 314 | no route exists and nothing is seeded. |
| 315 | |
| 316 | ## A check |
| 317 | |
| 318 | An in-process scheduler ([server/scheduler.js](server/scheduler.js)) starts checks for active |
| 319 | watches whose `next_due_at` and `not_before` have passed, oldest first, at most |
| 320 | `WATCH_MAX_CONCURRENT` at a time; `check.run()` refuses a second check of the same watch, so a |
| 321 | manual `POST …/check` never overlaps a scheduled one. One check |
| 322 | ([server/check.js](server/check.js)): |
| 323 | |
| 324 | 1. **Carrier** chosen by `source.kind` (never for event/webhook/node/run — see above). |
| 325 | 2. **Fetch** through the one way out: the SSRF guard, per-host spacing, conditional GET. |
| 326 | - `If-None-Match` / `If-Modified-Since` from the stored validators; a **304 → `not_modified`** |
| 327 | (no body, no observation). |
| 328 | - When the watch only needs to know *whether* it changed (`condition.op: changed`), a **HEAD** |
| 329 | is tried first while a validator is stored: two headers instead of a body. |
| 330 | - A 200 whose body hashes to the last body → **`no_change`** (no observation). |
| 331 | - 429/503 `Retry-After` → `not_before`, so Watch waits as long as the site asked. |
| 332 | 3. **Extract** the value (table below) from the body, or from the records a feed/api carrier mapped. |
| 333 | 4. **Compare and evaluate**: `changed` (with `tolerance`/`percent`), the predicate, and the |
| 334 | `for_sec` debounce measured from the observation trail — an unchanged body still evaluates the |
| 335 | condition, so "this has been true for five minutes" can fire without the page changing. |
| 336 | 5. **Record**, in ONE transaction: the `check_runs` row, the `observations` row (value, hashes, |
| 337 | changed, condition_met, capped snapshot, `retained_until`) and the events. |
| 338 | 6. **Arm** `next_due_at` (the cadence plus jitter, or an exponential backoff after failures, capped |
| 339 | at `WATCH_MAX_BACKOFF_MS`). |
| 340 | |
| 341 | A condition that fires is recorded on the check run (`state: condition_met`, `triggers: 1`) and |
| 342 | emits `watch.watch.triggered`; it fires again only for a value that is new or for a new run of |
| 343 | holding, never once per check while nothing moves (the last firing is compared by when it finished: the holding trail |
| 344 | starts at the observation the firing run recorded). |
| 345 | |
| 346 | ## Actions |
| 347 | |
| 348 | A fired condition queues the watch's actions in the same transaction as the check run that fired |
| 349 | (`server/actions.js`, table `action_runs`): each action once per trigger. The action worker (every |
| 350 | `WATCH_ACTIONS_TICK_MS`, up to `WATCH_ACTIONS_BATCH` at a time, off with `WATCH_ACTIONS=off`) delivers them: |
| 351 | |
| 352 | | kind | what happens | |
| 353 | |---|---| |
| 354 | | `notification` | the owner gets one OpenVibe notification through OpenVibe.Network (`openvibe-sdk/notifications`, type `WATCH_TRIGGERED`): the watch's name, the value (and the value before), and a link to the watch page. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then `failed`; an owner Network does not know (a deleted account) is `skipped`; a person who switched the category off is Network's decision and counts as `sent`. A watch owned by an agent or a service has no one to notify (`skipped`). Needs the Network OAuth client secret and the grants `identity.subject.resolve` + `network.notifications.push` | |
| 355 | | `event` | `watch.watch.triggered` is emitted with every firing in the run's own commit, so that action is recorded `sent` as it is queued (the event names a person: an agent- or service-owned watch is `skipped`). Any other event type is not declared by Watch: `unavailable` | |
| 356 | | `webhook` | a POST of the firing (`type`, `watch {id, name, url}`, `check_run_id`, `condition`, `observation {observed_at, value, previous_value}`) to the action's https URL, through the same SSRF guard as every fetch, never following a redirect ([server/net/post.js](server/net/post.js)). It is signed with the key `secret_env` names exactly as OpenVibe.Events signs a delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`; check it with openvibe-sdk/events `verifyDeliveryV2`). `X-OpenVibe-Delivery-Id` (`<check run>.<action index>`) is the same on every attempt, so a receiver can drop a repeat. 2xx is `sent`. 410 or a URL the guard refuses is `failed` at once. Anything else, or a lost connection, is retried on the backoff, then `failed`. The key must be granted to the owner (`WATCH_SECRET_GRANTS`, below) and set, 32 characters or more: otherwise `unavailable` | |
| 357 | | `actor-task` | a task for the owner on OpenVibe.Actor ([server/actor.js](server/actor.js)): the action's `brief`, followed by the firing as context (the watch, the value and what it was, its page), at most `budget_usd`. Watch names the owner in `X-OV-Subject`, so the task runs under **the owner's** allowance and appears in their task list, and Actor records Watch as `via`. The idempotency key `watch.<check run>.<action index>` makes a retried delivery the same task. Created is `sent`, with the task id recorded. A request Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once. Anything else, including a day's allowance spent, is retried on the backoff. An agent- or service-owned watch is `skipped`. Needs `actor.task.create` for `openvibe.actor` (Network grants it) and `WATCH_ACTOR_URL` | |
| 358 | | `actor-agent` | runs one of the person's OpenVibe.Actor agents by `agent_id`. Its optional `note` precedes the firing context (watch name and link, value and previous value, observation time); input is capped at 8000 characters without cutting the note. Watch uses `X-OV-Subject` for the owner. A created run is `sent` with its task id; a missing agent, limit or free-tier refusal is `failed` for that firing; service errors retry on the usual backoff. Only person-owned watches may use it. Needs the `actor.agent.run` grant for `openvibe.actor` and `WATCH_ACTOR_URL` | |
| 359 | | `run-job` | one OpenVibe.Run job for the owner in the watch's project ([server/run-jobs.js](server/run-jobs.js)): the action's `run.job-create-request@1` fields (`class`, `artifact`, `args`, `limits`, `egress`, `ttl_ms`), with the firing (watch, check run, value, previous value, time) under `args[firing_arg]` when the action names one. Watch submits as itself with the owner in `X-OV-Subject` and the project in `X-OV-Project` (project delegation, ADR-003 amendment 2026-10-12): Run bills the project, checks the owner is still an admin or owner of it, makes the owner the requester and records Watch as `via`. Key `watch.<check run>.<action index>`, so a retry is the same job. Created is `sent` with the job id; Run's final refusals (400, 403, 404, 409, 413, 422) are `failed` at once; anything else retries on the backoff. `WATCH_RUN_URL` is Run's internal address (default `http://127.0.0.1:4920`). | |
| 360 | | `codes-run` | a coding task on OpenVibe.Codes for the owner, run on the owner's own machine ([server/codes-tasks.js](server/codes-tasks.js), Contracts 0.162 `codes.task.start`): the action's `brief` with the firing as context (at most 8000 characters), in the `repo` it names. Watch starts it as itself with the owner in `X-OV-Subject`; Codes runs it in the project of the owner's latest openvibe.codes/run task and lists it on their /run page. Key `watch.<check run>.<action index>`, so a retry is the same task. Created is `sent` with the task's page; a refusal a retry cannot change (400, 403, 404, 409 — `codes.no_project` when the owner has never started a task on /run — 413, 422) is `failed` at once; anything else retries on the backoff. A person must own the watch: otherwise `skipped`. The create and edit forms offer it ("Start a coding task on my own machine"). `WATCH_CODES_URL` is Codes' internal address (default `http://127.0.0.1:4900`). | |
| 361 | |
| 362 | **Into a team room.** An OpenVibe.Chat workspace room's hook (room settings → "Alerts into this room") takes a |
| 363 | `webhook` action as it is: its address is the action's `url`, and its `whsec_` secret, saved as a project secret granted |
| 364 | to `watch`, is the action's `secret_ref`. Each firing then arrives in the room as a message from Vibe, with the watch's |
| 365 | link, and can queue a task for the hook's maker. |
| 366 | |
| 367 | A watch with a `run-job` action names a **project** (`project_id` on `watch.watch-request@1`, required for it: |
| 368 | `422 watch.project_required`). Only a person names one, and only a project OpenVibe.Network says they are an admin or |
| 369 | owner of and that is not archived (`GET /internal/projects/:project/members/:subject`, `network.project.member.read`), |
| 370 | checked when the project is named or changed: `403 project.not_delegated` otherwise, `503 |
| 371 | watch.project_check_unavailable` when Network cannot answer. Run checks again before every job. The run-job action |
| 372 | needs `class` and `limits` (and `artifact` for `function` and `code`); the contract draft's `runtime`, `command` and |
| 373 | `workload` are refused. |
| 374 | |
| 375 | Rows are claimed with a short lease (FOR UPDATE SKIP LOCKED), so two processes never deliver one row at once. |
| 376 | Network keeps no idempotency record for a push, so a lost answer can repeat a notification: delivery is at least once. |
| 377 | `/api/ready` reports the queue (`actions: { enabled, queued, failed, sent }`). |
| 378 | |
| 379 | Nothing is invented: a value the source did not state is `null`, a failed check is a recorded state, |
| 380 | and the validators/body hash are stored only after a body was read successfully, so an unreadable |
| 381 | body is fetched and parsed again next time. |
| 382 | |
| 383 | `check_runs.state` is one of `ok | not_modified | no_change | changed | condition_met | http_error | |
| 384 | timeout | parse_error | rate_limited | budget_exceeded | skipped | disabled`; every check re-arms the |
| 385 | watch on every terminal path. |
| 386 | |
| 387 | ### Extraction (`watch.watch@1` `$defs/extraction`) |
| 388 | |
| 389 | | Kind | Reads | |
| 390 | |---|---| |
| 391 | | `text` | the whole body as text, trimmed (capped at 64 KiB) | |
| 392 | | `html` | the body's text, or the text of the first element `selector` matches | |
| 393 | | `css` | the same, with the small selector subset: `tag`, `.class`, `#id`, `[attr]`, `[attr=value]`, descendants and `>` | |
| 394 | | `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 | |
| 395 | | `regex` | the first match of `selector` (its first group when it has one), e.g. `(\d+\.\d+) EUR` | |
| 396 | | `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 | |
| 397 | |
| 398 | A path that matches nothing and a pattern that does not match extract `null` — which `absent` then |
| 399 | answers truthfully. |
| 400 | |
| 401 | ### Conditions (`watch.watch@1` `$defs/condition`) |
| 402 | |
| 403 | `changed` (the default), `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `contains`, `matches`, `exists`, |
| 404 | `absent`; `for_sec` debounces a condition that must hold that long. Comparison refuses to guess: a |
| 405 | value that is not a number (or a plain decimal string) is never "greater than" anything, and an |
| 406 | unknown value is never low — the same rule Deals' watches use. `comparison.tolerance` (absolute) |
| 407 | and `comparison.percent` decide whether a move counts as a change at all. |
| 408 | |
| 409 | ### Observations |
| 410 | |
| 411 | Immutable rows (`watch.observation@1`): the extracted `value`, its SHA-256 `value_hash` over the |
| 412 | canonical JSON, `previous_hash`, `changed`, `condition_met`, a capped `snapshot` of the body it came |
| 413 | from, and `retained_until` (the watch's `retention.observations_days`, `WATCH_RETENTION_DEFAULT_DAYS` |
| 414 | = 90 by default; `keep_snapshots: false` stores no snapshot). An hourly prune drops what is past its |
| 415 | retention. |
| 416 | |
| 417 | ## API (bearer tokens, one capability per route) |
| 418 | |
| 419 | Callers present an OpenVibe.Network token: a service's client-credentials token for audience |
| 420 | `openvibe.watch` (judged by its `cap` claim with the contracts grant rule), or a signed-in person's |
| 421 | or agent's token — either a token minted for `openvibe.watch` or the Network session token the |
| 422 | signed-in site holds (`aud: openvibe.network`); a watch is theirs, owned by the canonical |
| 423 | `usr_…`/`agt_…` `subject_id`, and ownership scoping is the authority. A first-party service |
| 424 | (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person; an app or module may not. |
| 425 | |
| 426 | | Capability | Routes | |
| 427 | |---|---| |
| 428 | | `watch.watch.read` | `GET /api/v1/watches?status=&project_id=&before=&limit=`, `GET /api/v1/watches/:id` | |
| 429 | | `watch.watch.manage` | `POST /api/v1/watches`, `PATCH /api/v1/watches/:id`, `POST /api/v1/watches/:id/pause`, `POST /api/v1/watches/:id/resume`, `POST /api/v1/watches/:id/webhook-secret`, `DELETE /api/v1/watches/:id` | |
| 430 | | `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` | |
| 431 | | `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`, or 422 `watch.event_awaited` for an event source) | |
| 432 | | `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) | |
| 433 | | `watch.resource.control` | `POST /api/v1/resources/control` (first-party service token only; ADR-048, [server/api/resource-control.js](server/api/resource-control.js)): a `common.resource-control-request@1` for a watch from OpenVibe.Services acting for its owner (`on_behalf_of`) — `suspend` (pause), `resume`, `delete`; other actions refused `control.unsupported_action`; only the owner's watch in the OVRN's project (otherwise `resources.unknown_resource`); `dry_run` applies nothing; the first answer per (caller, project, `idempotency_key`) is kept (`watch_control_log`, migration 0009) and a key reused for another request is 409. Loopback only: the public vhost 404s `/api/v1/resources*`. | |
| 434 | |
| 435 | Provider-rate evidence (`WATCH_PROVIDER_RATES=1` only; off by default) adds |
| 436 | `GET /api/v1/provider-rates`, `.../sources[/:key]`, `.../observations`, |
| 437 | `.../changes[?state=]` and `.../changes/:id` on `watch.watch.read`; |
| 438 | `PATCH .../sources/:key` and `POST .../changes/:id/approve|reject` on `watch.watch.manage`; and |
| 439 | `POST .../sources/:key/refresh` on `watch.check.run`. Approve and reject require a person (`usr_…`) |
| 440 | and answer `applied: false`; no route writes a rate card. With the flag off none of these paths exists. |
| 441 | |
| 442 | The loopback-only authority index lists non-deleted `watch.watch` resources as |
| 443 | `common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, and |
| 444 | includes an OVRN only for a watch with a valid project id. |
| 445 | |
| 446 | The list answer is exactly `watch.watch-result@1` (`{ watches }`): page with |
| 447 | `before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view, |
| 448 | observation view and check-run view is validated against its released contract before it is sent. |
| 449 | Errors are problem+json (`watch.not_found` 404, `watch.invalid` 422 with the validator's detail). |
| 450 | |
| 451 | ### Per-actor limits |
| 452 | |
| 453 | Every route also limits the principal that passed its capability guard, before any work: |
| 454 | [server/api/actor-limits.js](server/api/actor-limits.js), openvibe-sdk/limits. Past a limit: `429` |
| 455 | problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and |
| 456 | `watch_rate_limited_total{limit,window}`. |
| 457 | |
| 458 | | Routes | Per principal, a minute / an hour | |
| 459 | |---|---| |
| 460 | | Reads by an app or module (`app:…`, `mod:…`) | `WATCH_LIMITS_MINUTE` / `WATCH_LIMITS_HOUR` (120 / 3000) | |
| 461 | | `POST`/`PATCH`/`DELETE /api/v1/watches[/:id]`, pause, resume | 30 / 600 | |
| 462 | | `POST /api/v1/watches/:id/check` | 10 / 120 | |
| 463 | |
| 464 | A first-party service (`svc:…`) is not counted on reads: the public site and agent runtimes read for |
| 465 | many people, and one budget for the whole service would refuse real people's work. Watch's own |
| 466 | worker (the scheduler, the carriers, the outbox relay) runs in-process and never passes through |
| 467 | HTTP. Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the home page. |
| 468 | |
| 469 | ## Events (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set) |
| 470 | |
| 471 | - `watch.observation.recorded` (low) — a check recorded an observation, with the value as the source |
| 472 | stated it and its hash, never the raw body. |
| 473 | - `watch.watch.triggered` (important) — a condition fired, with the observation that fired it. This |
| 474 | is what OpenVibe.Network turns into a `WATCH_TRIGGERED` notification for `payload.recipient`. |
| 475 | - `watch.check.failed` (important) — a check ended in a failure state, with `consecutive_failures` |
| 476 | so a consumer can tell a blip from a watch that is down. |
| 477 | |
| 478 | All `visibility: internal`, subject `{ type: watch, id: <watch_id> }`, actor `service:watch`, |
| 479 | envelopes validated as `events.event-envelope@1` and payloads against their released contracts. The |
| 480 | watch-lifecycle events (`watch.watch.created`, `watch.watch.updated`, `watch.watch.paused`, |
| 481 | `watch.watch.removed`) are emitted on the registry's writes; they have no payload contract yet. |
| 482 | |
| 483 | `openvibe-sdk/events` owns the transactional outbox and relay. Pending rows live in `service_outbox` |
| 484 | (migration 0004); the migration copies pending rows from `event_outbox`, which remains through the |
| 485 | rollback window. When `EVENTS_URL` or the OAuth client secret is unset, rows wait for the relay. |
| 486 | |
| 487 | A watch owned by a service (`svc:…`) still records every observation and trigger in its own tables, |
| 488 | but the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), so |
| 489 | those two events are logged as not emitted rather than sent with a payload the contract refuses. |
| 490 | |
| 491 | ## Account export and deletion (ADR-033) |
| 492 | |
| 493 | `network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. It is |
| 494 | loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with |
| 495 | `WATCH_EVENTS_SECRET`. They are answered by `server/account-data.js` over `openvibe-sdk/account-data`. |
| 496 | |
| 497 | - **Export:** the person's watch definitions, with how many check runs and observations each has. |
| 498 | - **Deletion:** their watches go with every check run, observation and conditional-GET state under them, and so do |
| 499 | their sign-in sessions (never exported). A staff edit's `updated_by` on someone else's watch becomes NULL. |
| 500 | - **Receipts:** `account_data_events` (migration 0003) keeps one per export and deletion, so a redelivery changes |
| 501 | nothing. |
| 502 | - **Subscriptions:** the two are created at boot when missing. |
| 503 | |
| 504 | ## Security |
| 505 | |
| 506 | Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps: |
| 507 | |
| 508 | - **Auth.** Every `/api/v1` route needs a token for audience `openvibe.watch` with that route's ONE |
| 509 | capability (`svc:…` by capability grant, `usr_…`/`agt_…` by identity), then passes the per-actor |
| 510 | limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*` |
| 511 | loopback-only. |
| 512 | - **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers |
| 513 | 404 on every route — and on every page and form of the public site, which acts on the same |
| 514 | registry in-process. |
| 515 | - **Site sessions.** The browser holds an opaque random token in an httpOnly, SameSite=Lax cookie; |
| 516 | the database keeps only its SHA-256 hash, and the Network access token is verified once and |
| 517 | discarded. A signed-in write must be same-origin, and the page forms escape every value they show |
| 518 | (the `html` tagged template, `server/render/html.js`). |
| 519 | - **Egress.** A watch fetches outside URLs by design, only through the guard: |
| 520 | [server/net/guard.js](server/net/guard.js), http(s) on `WATCH_ALLOWED_PORTS` (80/443), every |
| 521 | resolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one |
| 522 | deadline and a byte cap per response. Exactly one path reaches the network — the carriers, through |
| 523 | the fetcher — and only for a kind whose carrier exists. `test/carriers.test.js`. |
| 524 | - **Secrets.** A source's credential is the **name** of a `WATCH_CRED_*` variable, and a webhook |
| 525 | action's signing key the name of a `WATCH_HOOK_*` one. Both are read at use time, never stored or |
| 526 | logged. A name is **not a capability**: the operator grants each one in `WATCH_SECRET_GRANTS` |
| 527 | ([server/secret-grants.js](server/secret-grants.js)). A credential may be used only by the owners |
| 528 | it is granted to, and sent only to the origins it is granted for. A webhook key may be used only by |
| 529 | its owners. Otherwise anyone could name an operator's credential with a URL of their own, or have |
| 530 | deliveries signed with another receiver's key. A name without a grant is usable by nobody. A save |
| 531 | that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrier |
| 532 | and the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grants |
| 533 | live in `/etc/openvibe/watch.env` (0600). |
| 534 | - **Project secrets (ADR-056).** A watch that names a project may use that project's own secrets instead: |
| 535 | `source.auth.secret_ref` for its credential, and `secret_ref` on a webhook action for its signing key, |
| 536 | looked up in the watch's `project_env` (production by default). OpenVibe.Network keeps them and the project |
| 537 | grants each to `watch` (on openvibe.services, the project's Secrets page). A credential is **revealed** by |
| 538 | Network for the source URL's origin only, at fetch time, and used for that one request. A webhook is |
| 539 | **signed** by Network over `"<t>.<raw body>"` in the usual headers, so Watch never holds that key. A secret |
| 540 | Network refuses leaves the credential missing (the check says so), or makes the action unavailable. |
| 541 | Network unreachable is retried. Watch holds `network.secret.use` (Network's default grant), and naming a |
| 542 | project secret needs `project_id` (422 `watch.project_required`). |
| 543 | - **Data.** An observation records what the source stated, never a guess; snapshots are capped and |
| 544 | pruned by retention; a failed check never records an observation. |
| 545 | |
| 546 | ## Owns |
| 547 | |
| 548 | - the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` / |
| 549 | `watch.watch-request@1` / `watch.watch-result@1` shapes it serves |
| 550 | - checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff |
| 551 | - the public site's sign-in sessions (`web_sessions`) and its pages |
| 552 | - `watch.*` events |
| 553 | - provider-rate evidence (plan P6, `WATCH_PROVIDER_RATES` off): the DeepInfra and Featherless official price |
| 554 | documents as immutable observations, the pending change when a rate differs, and a person's dated approval — |
| 555 | never a rate card |
| 556 | |
| 557 | ## Does not own |
| 558 | |
| 559 | - structured source ingestion for publication (OpenVibe.Sources' registry and adapters): Watch |
| 560 | observes what one person or agent asked for, not what a product publishes |
| 561 | - expensive browser/code execution (OpenVibe.Run, step 6) and local/LAN probes (OpenVibe Link, |
| 562 | step 7) — Watch asks them, they run |
| 563 | - interpreting a trigger and acting on it (OpenVibe.Actor, OpenVibe.Codes, actions = step 4) |
| 564 | - delivery of a notification (OpenVibe.Network consumes `watch.watch.triggered`) |
| 565 | - the supply rate card (OpenVibe.AI's catalog): an approved provider-rate change is evidence AI's operator applies |
| 566 | through AI's own path, so Watch is never a second authority for a billable price |
| 567 | |
| 568 | ## Depends on |
| 569 | |
| 570 | - PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through |
| 571 | `openvibe-sdk/db` |
| 572 | - OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids, |
| 573 | the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`) |
| 574 | - OpenVibe.Network (signing key; service principal `watch`; grants `network.account.export.contribute` and |
| 575 | `network.account.deletion.confirm` for account export and deletion, given last, once this release is live) |
| 576 | - OpenVibe.Events (outbound events; the two account subscriptions, created at boot with `events.subscription.manage`) |
| 577 | - OpenVibe.Billing (one `check-operation` usage reading per completed check, `POST /api/v1/usage` with `billing.usage.record`; off by default, `WATCH_BILLING_INTERVAL_MS`) |
| 578 | |
| 579 | ## Capabilities |
| 580 | |
| 581 | Implemented here (the service manifest's `capabilities`; routes in [API](#api-bearer-tokens-one-capability-per-route)): |
| 582 | `watch.watch.read`, `watch.watch.manage`, `watch.observation.read` and `watch.check.run`, each |
| 583 | checked by [server/auth.js](server/auth.js) with the contracts grant rule for audience |
| 584 | `openvibe.watch`. |
| 585 | |
| 586 | Called elsewhere, as the service principal `watch` (client credentials from OpenVibe.Network): |
| 587 | |
| 588 | | Service | Grant | Why | |
| 589 | |---|---|---| |
| 590 | | OpenVibe.Events | `events.event.publish` (audience `openvibe.events`) | the outbox relays `watch.observation.recorded`, `watch.watch.triggered`, `watch.check.failed` and the lifecycle events | |
| 591 | | 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 | |
| 592 | |
| 593 | ## Acceptance (tests) |
| 594 | |
| 595 | - the release contract on both sides: bodies validated against `watch.watch-request@1`, every watch, |
| 596 | observation and check-run view against `watch.watch@1` / `watch.observation@1` / |
| 597 | `watch.check-run@1`, and every emitted payload against its released event contract |
| 598 | (`test/api.test.js`, `test/registry.test.js`, `test/check.test.js`) |
| 599 | - conditional GET: validators stored, 304 → `not_modified`, identical body → `no_change`, a HEAD |
| 600 | answers `changed` without a body (`test/carriers.test.js`) |
| 601 | - the guard refuses a private host before any socket, a redirect into a private address, a port |
| 602 | outside `WATCH_ALLOWED_PORTS`, URL credentials (`test/carriers.test.js`) |
| 603 | - credentials by variable name, read at fetch time, never stored, missing → `disabled` with no |
| 604 | request (`test/carriers.test.js`) |
| 605 | - feeds and mapped APIs, `items_path`/`fields`, and the parse errors they can raise |
| 606 | (`test/carriers.test.js`) |
| 607 | - every condition op, tolerance and percent, `for_sec` debounce (`test/condition.test.js`); every |
| 608 | extraction kind, the selector subset, and `ai` refusing (`test/extract.test.js`) |
| 609 | - a check writes its run and its observation and emits `watch.observation.recorded`; a fired |
| 610 | condition emits `watch.watch.triggered`; failures emit `watch.check.failed` and back off; |
| 611 | event/webhook/node/run sources and an `ai` extraction are never fetched; retention prune |
| 612 | (`test/check.test.js`) |
| 613 | - due order, the concurrency cap, `not_before`, backoff, and the worker checking on its own tick |
| 614 | (`test/scheduler.test.js`) |
| 615 | - the API: one capability per route, owner scoping 404, 422 from the validator, pause/resume/delete, |
| 616 | X-OV-Subject delegation, the check and observation reads, per-actor 429 (`test/api.test.js`) |
| 617 | - the registry: required fields, cadence rules, forbidden headers, due selection, re-arm, soft |
| 618 | delete, the keyset page (`test/registry.test.js`) |
| 619 | - the public site (`test/site.test.js`): sign-in through the stand-in Network (PKCE, a session, |
| 620 | state mismatch refused, `/auth/me`, sign-out), the signed-out pages, each create template against |
| 621 | what the API would store, validation errors that keep the input, another person's watch 404 on |
| 622 | every page and form, pause/resume/delete/check-now against a stub site (with the API's 409s and |
| 623 | 422), cross-site writes refused, and hostile names and values escaped on every page |
| 624 | |
| 625 | Demonstrated on production (2026-10-15): a check against a real site from the deployed service. |
| 626 | - A feed watch on openvibe.work's `/jobs.atom` was made from Work's Get alerts link and submitted through the |
| 627 | prefilled form. |
| 628 | - One check answered HTTP 200 with one observation; the condition was met and the notification fired. |
| 629 | - The proof watch was deleted. |
| 630 | |
| 631 | Not yet demonstrated: the Run rung (step 6), which answers `watch.carrier_unavailable` and fetches nothing |
| 632 | today (node sources are covered by `test/node-carrier.test.js` against a stand-in Bot) — and the action kinds beyond notifications. Event sources are covered end to end by |
| 633 | `test/event-sources.test.js` (matching, visibility, once per event, the budget, the signed route and the subscription |
| 634 | sync against a stand-in Events), and webhook sources by `test/webhook-sources.test.js` (the secret shown once, both |
| 635 | signing forms, every refusal, the budget, rotation, text and JSON bodies, and removal). |
| 636 | |
| 637 | ## Deploy |
| 638 | |
| 639 | `sudo ovhost deploy watch` on the host: git checkout at `/opt/openvibe.watch`, unit |
| 640 | [deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service) as |
| 641 | `openvibe-watch.service` on `127.0.0.1:4730`, env `/etc/openvibe/watch.env` (0600) from |
| 642 | [.env.example](.env.example), database `ov_watch` on the host's data role (ADR-035, |
| 643 | `sudo /opt/openvibe.host/roles/data/add-service.sh watch`). nginx serves `openvibe.watch` from |
| 644 | [deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf); the pages, `/auth/*` and the |
| 645 | discovery files are public, `/api/v1/*` and `/internal/*` stay loopback-only. Rollback: ovhost puts |
| 646 | the previous sha back by itself when `/api/ready` does not answer 2xx after the restart; afterwards |
| 647 | `sudo ovhost rollback watch --to <sha>`. Nothing is deployed yet: there is no host install, and |
| 648 | openvibe.watch has no DNS or vhost. |
| 649 | |
| 650 | ## Public host |
| 651 | |
| 652 | `openvibe.watch` serves the site at the edge: the pages, `/auth/*`, `/css/*` and `/shared/*`, the |
| 653 | discovery files, and `/api/health`, `/api/ready`, `/release.json`. It keeps `/api/v1/*` |
| 654 | loopback-only, exactly as before, so the service API is reached only from the host. Sign-in and form |
| 655 | POSTs have their own per-address limit zones (nginx refuses `limit_req` inside `limit_except`, so a |
| 656 | `map` on `$request_method` gives GET/HEAD an empty key). Install: copy the unit and the vhost, write |
| 657 | `/etc/openvibe/watch.env` (0600) from `.env.example`, run |
| 658 | `sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then |
| 659 | `sudo ovhost deploy watch`. |
| 660 | |
| 661 | <!-- versions:start --> |
| 662 | - openvibe-contracts: v0.419.0 |
| 663 | - openvibe-sdk: v0.161.0 |
| 664 | - openvibe-shared: v4.241.0 |
| 665 | <!-- versions:end --> |
| 666 |