openvibers/openvibe.watch Commits
Watch: the public site — sign in with OpenVibe and manage your watches (plan T18 step 8) (#7)
Changes
@@@ -10,6 +10,8 @@ PORT=4730HOST=127.0.0.1NODE_ENV=development#BASE_URL=https://openvibe.watch++# The public site sits behind nginx (and Cloudflare): trust one proxy hop for the real client address.++#TRUST_PROXY=1# 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@@@ -27,10 +29,21 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000#WATCH_LIMITS_MINUTE=120#WATCH_LIMITS_HOUR=3000--# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish).++# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish) — and++# the public site's sign-in client. Keep OV_OAUTH_CLIENT_ID=watch and set the secret in++# /etc/openvibe/watch.env (0600): the site exchanges a person's sign-in code for a Network token with it.OV_OAUTH_CLIENT_ID=watchOV_OAUTH_CLIENT_SECRET=++# The site's sign-in callback (default BASE_URL + /auth/callback; register this exact URI on the++# Network client), and the scope and session audience a person's Network token is verified for.++#OV_OAUTH_REDIRECT_URI=https://openvibe.watch/auth/callback++#OV_OAUTH_SCOPE=profile++#OV_SESSION_AUDIENCE=openvibe.network++++# The site's session cookie is Secure in production (https) by default; set false only in development.++#COOKIE_SECURE=true++# 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.nnew file mode 100644@@@ -0,0 +1,38 @@++# Changelog++++What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names++the step it landed.++++## 2026-10-08 — the public site (T18 step 8)++++- **Sign in with OpenVibe** at `/auth/login`: OAuth 2 authorization code with PKCE (S256) as the++ Network client `watch`, a session in Watch's own database (only the hash of the cookie token is++ stored), and the signed-in person's `usr_…` subject as the owner.++- **A site for watches**: the home page explains Watch and starts one of the four kinds; `/watches`++ lists yours; `/watches/:id` states a watch in plain words with its observation history and every++ check run; pause, resume, delete and check-now are plain forms. Every page works without++ JavaScript.++- **One create form, four starting points** (`/watches/new?template=page|price|feed|json`): a page++ that changes, a price below a value, a new item in a feed, a JSON value — the server picks the++ fields the kind needs.++- **Honest by construction**: the kinds that arrive later (event, webhook, Run, Node) and an AI++ reading are not offered, because Watch will not pretend to check what it cannot. Notifications go++ through OpenVibe.Network exactly as before.++- **Crawl artifacts** (robots.txt, sitemap.xml, llms.txt) and a public vhost that serves the site++ while `/api/v1/*` stays loopback-only.++++## 2026-10-08 — the check engine and the pull carriers (T18 steps 2–3)++++- **The watch registry**: `watch.watch@1` in and out, validated with the released contracts; a++ create needs a source, an extraction, a condition and an action; ownership is the authority and++ another owner's watch is 404.++- **The check engine**: conditional GET (ETag/Last-Modified, a 304 costs no body), HEAD-first when++ only "did it change?" matters, an unchanged body recorded as `no_change`, and a recorded state for++ every terminal path with backoff and `Retry-After`.++- **Carriers**: `http`, `feed` (RSS 2.0 / RSS 1.0 / Atom 1.0) and `api` (JSON/XML, mapped), all++ through the one SSRF-guarded fetcher with per-host spacing.++- **Extraction and conditions**: text, html, a small CSS subset, json/jsonpath and regex; `changed`,++ the numeric and string predicates, `for_sec` debounce, tolerance and percent.++- **Observations and check runs**: immutable rows with value hashes, capped snapshots and retention.++- **The API** (bearer tokens, one capability per route), the `watch.*` events through the++ transactional outbox, and per-actor limits.@@@ -4,10 +4,11 @@> 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).++**Status:** alpha (plan T18 steps 2–3 and step 8). The registry, the check engine, the pull carriers++and the public site (sign-in, pages, forms) exist; the event, webhook, Node and Run rungs and the++actions do not yet.++**Domain:** `openvibe.watch` (a public, server-rendered site at [Public site](#public-site); 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".@@@ -48,6 +49,57 @@ answers direct loopback callers only: golden signals by route template, `watch_w`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.++## Public site++++`openvibe.watch` is also the place a person manages their own watches in a browser. Every page is++server-rendered and complete without JavaScript: the create and edit forms POST, pause/resume/++delete/check-now are plain forms, and the fields a form shows are chosen by the query++(`/watches/new?template=price`), not by script. The visual layer is plain semantic HTML on the++classes openvibe-shared's frame and this repository's `public/css/app.css` already provide.++++**Sign-in** is OpenVibe.Network's OAuth 2 authorization code with PKCE (S256), as the OAuth client++`watch` (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`), redirecting to `BASE_URL/auth/callback`.++The Network token is verified once (issuer, audience `openvibe.network`, a `usr_…`/`agt_…` subject)++and then discarded; the browser holds an opaque random session token in an httpOnly, SameSite=Lax++cookie, and Watch's database stores only its SHA-256 hash (`web_sessions`, migration 0002). The++signed-in person's `usr_…` subject is the watch owner: the site calls the **registry directly,++in-process**, with exactly the ownership rules the API applies — another person's watch is 404 on++every page and every form, never 403. A signed-in write must also come from this site itself++(same-origin), so another site cannot make a visitor act here.++++| Page | What it shows |++|---|---|++| `/` | what Watch is in one paragraph; four starting points as cards; how it checks in plain words; your watches summary (counts by status) when signed in |++| `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` |++| `/watches/new?template=page\|price\|feed\|json` | the one create form, showing only the fields the chosen kind needs |++| `/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 |++| `/watches/:id/edit` | the same form, PATCH semantics |++| `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog |++| `/robots.txt`, `/sitemap.xml`, `/llms.txt(/-full)` | discovery: the home page and how-it-works are listed; `/watches` and `/auth` are disallowed |++++**Four starting points**, each a page of the same form:++++| Template | Source | Extraction | Condition |++|---|---|---|---|++| `page` | http | html, an optional CSS selector | `changed` |++| `price` | http | css selector or regex | `lt`/`lte`/`gt`/`gte` |++| `feed` | feed | the newest item (`latest`) | `changed` |++| `json` | api | json with an optional `value_path` | any of the ops |++++The cadence is a select (15 minutes, 1 hour, 6 hours, 1 day); 15 minutes is the shortest a person may++choose here (the contract's floor is 30 seconds, for a service). A validation error re-renders the++form with the person's own input and the registry's own message next to the field it names. The++kinds whose carrier does not exist yet (event, webhook, run, node) and the `ai` extraction are **not++offered** — and a watch of such a kind created through the API still answers `watch.carrier_unavailable`++when checked from the site, fetching nothing.++++Check-now from the page respects the API's limits and its refusals: a second check while one runs or++a check of a paused watch is the API's 409, said in words; a kind without a carrier is the API's 422;++and the manual-check and write budgets (10/120 and 30/600 per person, a minute/hour) are enforced++with a friendly page. "Notify me" stores the notification action; a fired condition still emits++`watch.watch.triggered` through OpenVibe.Network as today (actions are step 4 and are not dispatched++yet, which the form says on the page).++## The watch modelA Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,@@@ -218,7 +270,12 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps: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.++ 404 on every route — and on every page and form of the public site, which acts on the same++ registry in-process.++- **Site sessions.** The browser holds an opaque random token in an httpOnly, SameSite=Lax cookie;++ the database keeps only its SHA-256 hash, and the Network access token is verified once and++ discarded. A signed-in write must be same-origin, and the page forms escape every value they show++ (the `html` tagged template, `server/render/html.js`).- **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), everyresolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one@@@ -235,6 +292,7 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:- 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++- the public site's sign-in sessions (`web_sessions`) and its pages- `watch.*` events## Does not own@@@ -295,6 +353,11 @@ Called elsewhere, as the service principal `watch` (client credentials from OpenX-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, softdelete, the keyset page (`test/registry.test.js`)++- the public site (`test/site.test.js`): sign-in through the stand-in Network (PKCE, a session,++ state mismatch refused, `/auth/me`, sign-out), the signed-out pages, each create template against++ what the API would store, validation errors that keep the input, another person's watch 404 on++ every page and form, pause/resume/delete/check-now against a stub site (with the API's 409s and++ 422), cross-site writes refused, and hostile names and values escaped on every pageNot yet demonstrated: a check against a real site from a deployed service (nothing is deployedyet), the event/webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the@@@ -302,9 +365,11 @@ actions (step 4) — each answers `watch.carrier_unavailable` and fetches nothin## 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++`openvibe.watch` serves the site at the edge: the pages, `/auth/*`, `/css/*` and `/shared/*`, the++discovery files, and `/api/health`, `/api/ready`, `/release.json`. It keeps `/api/v1/*`++loopback-only, exactly as before, so the service API is reached only from the host. Sign-in and form++POSTs have their own per-address limit zones (nginx refuses `limit_req` inside `limit_except`, so a++`map` on `$request_method` gives GET/HEAD an empty key). 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`.@@@ -3,10 +3,10 @@"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",++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026) — T18 Watch complete, steps 2-3 (registry, engine, pull carriers) and step 8 (the public site)","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\")",++ "domain": "openvibe.watch (a public server-rendered site + a loopback-only API; vhost in deploy/nginx; README \"Public site\" and \"Public host\")","contracts": "openvibe-contracts v0.112.0","sdk": "openvibe-sdk v0.35.0","shared": "openvibe-shared v2.13.2",@@@ -35,7 +35,26 @@],"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.",++ "deployment": "Not deployed. Plan T18 steps 2-3 and step 8 are implemented and tested in the repository; nothing runs on the host yet, and openvibe.watch has no DNS or vhost installed.",++ "works": [++ "the watch registry with owner scoping (404 for another owner) validated against the released contracts",++ "the check engine: conditional GET (ETag/Last-Modified, HEAD-first for change-only), 304/unchanged-body short circuits, recorded states, backoff and Retry-After",++ "SSRF-guarded fetching (ports, private hosts, every redirect hop, byte cap, one deadline) and per-host spacing",++ "the http, feed (RSS/Atom) and api carriers",++ "extraction (text, html, css subset, json/jsonpath, regex) and conditions (changed/threshold/string, for_sec)",++ "observations with value hashes, capped snapshots and retention; check runs with the full state set",++ "the loopback-only API (one capability per route), watch.* events through the transactional outbox, per-actor limits",++ "the public site: sign-in with OpenVibe.Network (OAuth2 + PKCE S256, sessions in Watch's database), the home page and four starting points, /watches (paged), the create and edit forms (server-side fieldsets, validation errors that keep the input), one watch with pause/resume/delete/check-now, the observation history and check runs, /how-it-works, /updates, and robots/sitemap/llms.txt",++ "the site calls the registry in-process with the API's ownership rules (another person's watch is 404 on every page and form) and refuses cross-site writes"++ ],++ "notYet": [++ "not deployed: no host install, DNS or vhost on openvibe.watch yet, so nothing is reachable in production",++ "no check has run against a real external site from a deployed service (the tests use stub sites only)",++ "the event and webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the actions (step 4): a watch of such a kind is accepted but never fetched",++ "an ai extraction is not a check (step 10); such a watch is recorded skipped and nothing is fetched",++ "the site offers only page/price/feed/json watches: the kinds and the AI reading above are not in the form",++ "notifications are the watch.watch.triggered event OpenVibe.Network turns into WATCH_TRIGGERED; no other action is dispatched"++ ],"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",@@@ -48,7 +67,8 @@"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)"++ "per-actor limits (openvibe-sdk/limits)",++ "the public site: Network sign-in (PKCE S256, web_sessions with only the token hash), server-rendered pages complete without JavaScript, the four create templates, watch management, observation history and check runs, how-it-works and the changelog, robots/sitemap/llms.txt"],"updated": "2026-10-08"}@@@ -4,20 +4,35 @@## 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:++## Watch now has a public site, server-rendered by the Node app (port 4730, systemd unit++## openvibe-watch). The vhost serves:++## /, /watches, /watches/:id, /how-it-works, /updates the pages++## /auth/* sign-in with OpenVibe.Network++## /css/*, /shared/* the stylesheet and the Frame assets++## /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt crawl artifacts++## /api/health, /api/ready, /release.json health and release metadata++## and keeps the service API 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++##++## Sign-in and form writes are limited per client address. nginx refuses limit_req inside++## limit_except, so a read is exempted with a map on $request_method to an empty key: nginx++## does not count an empty key, and a GET/HEAD page keeps the ordinary page budget.## ═══════════════════════════════════════════════════════════════++limit_req_zone $binary_remote_addr zone=watch_pages:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=watch_auth:10m rate=10r/m;++++map $request_method $watch_form_key {++ GET "";++ HEAD "";++ default $binary_remote_addr;++}++limit_req_zone $watch_form_key zone=watch_forms:10m rate=1r/s;++# ── HTTP → HTTPS redirect ──────────────────────────────────server {listen 80;@@@ -45,10 +60,17 @@ server {client_max_body_size 256k;++ # The app decides robots per page (the person pages are noindex; the home page is not).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;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;# 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.@@@ -60,6 +82,7 @@ server {proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;++ # ── Health, readiness and release metadata ──────────────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; }@@@ -67,14 +90,42 @@ server {# Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics).location = /metrics { return 404; }-- location = / {-- limit_except GET { deny all; }++ # ── Sign-in: the network's own budget. /auth/me is the navbar's session probe and gets the++ # page budget instead, because it runs on every page view. ──++ location = /auth/me {++ limit_req zone=watch_pages burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4730;++ }++ location ^~ /auth/ {++ limit_req zone=watch_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4730;++ }++++ # ── The site's own assets (content-hashed ?v=) and the OpenVibe Frame ──++ location ^~ /css/ { proxy_pass http://127.0.0.1:4730; }++ location ^~ /shared/ { proxy_pass http://127.0.0.1:4730; }++++ # ── Crawl artifacts ─────────────────────────────────────++ location = /robots.txt { proxy_pass http://127.0.0.1:4730; }++ location = /sitemap.xml { proxy_pass http://127.0.0.1:4730; }++ location = /llms.txt { proxy_pass http://127.0.0.1:4730; }++ location = /llms-full.txt { proxy_pass http://127.0.0.1:4730; }++++ # ── The watches pages: reads keep the page budget, writes (a POST) the strict form zone. The++ # map above gives GET/HEAD an empty key, which nginx does not count. ──++ location ^~ /watches {++ limit_req zone=watch_forms burst=10 nodelay;++ limit_req_status 429;proxy_pass http://127.0.0.1:4730;}-- location = /robots.txt {-- default_type text/plain;-- return 200 "User-agent: *\nDisallow: /\n";++ # ── Pages ───────────────────────────────────────────────++ location / {++ limit_req zone=watch_pages burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4730;}# ── Service API: loopback only (see header) ─────────────@@@ -85,9 +136,5 @@ server {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"}';-- }++ location ~ /\.(?!well-known) { deny all; }}nnew file mode 100644@@@ -0,0 +1,22 @@++-- phase: expand++-- The public site's sign-in sessions (plan T18 step 8). Additive only; never edited after it runs.++--++-- A signed-in person's browser holds an opaque random token in an httpOnly cookie; only its SHA-256++-- hash is stored here, so a database dump does not hand out live sessions. The person's Network++-- access token is NOT stored: after the OAuth code exchange we verify it once (issuer, audience,++-- subject) and keep only the canonical subject the watches belong to. The subject is what the++-- registry scopes ownership by (usr_…/agt_…), the same authority the API applies.++++CREATE TABLE web_sessions (++ id text COLLATE "C" PRIMARY KEY, -- sha256 hex of the cookie token++ subject text COLLATE "C" NOT NULL, -- usr_… / agt_…++ username text COLLATE "C",++ display_name text COLLATE "C",++ role text COLLATE "C",++ created_at bigint NOT NULL,++ last_seen_at bigint NOT NULL,++ expires_at bigint NOT NULL,++ revoked_at bigint++);++CREATE INDEX idx_web_sessions_expiry ON web_sessions (expires_at);++CREATE INDEX idx_web_sessions_subject ON web_sessions (subject, created_at DESC);@@@ -12,9 +12,9 @@"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.112.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},"devDependencies": {@@@ -769,9 +769,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.112.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",-- "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",++ "version": "0.116.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",++ "integrity": "sha512-mDqQJ6m8YSJf3UV+kVpqeMrgNDMMzBQdYmj/3o5JqGegj52ymiDH1NerVW6c+0ebsV3zX7RbRlknIy3vr634yw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -785,9 +785,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==","license": "MIT","engines": {"node": ">=20"@@@ -814,9 +814,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.13.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",-- "integrity": "sha512-rvLq6uKd0j84qxZXwGX+gE6+e8SJaP8bBilWu9Eosjgfc70Z4bPJOUJUiLNRte7fU5hW/ea97/JoCLdiwXmPQA==",++ "version": "2.15.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",++ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -24,9 +24,9 @@"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.112.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,85 @@++/* OpenVibe.Watch — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */++:root { color-scheme: dark; }++* { box-sizing: border-box; }++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--accent-light, #60a5fa); }++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }++.account-bar { max-width: 1080px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.page { max-width: 1080px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }++h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }++h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }++.muted { color: var(--text-muted, #7386a3); }++.small { font-size: .85rem; }++.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }++code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }++code { overflow-wrap: anywhere; }++pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }++pre code { overflow-wrap: normal; }++.table-wrap { overflow-x: auto; }++table { border-collapse: collapse; width: 100%; font-size: .92rem; }++th, td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border, #1f2d47); }++th { color: var(--text-secondary, #96a7c2); font-weight: 600; }++.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; line-height: 1.6; }++.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }++.badge.info { border-color: var(--info, #38bdf8); color: var(--info, #38bdf8); }++.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }++.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }++.notice.ok { border-left-color: var(--success, #22c55e); }++.notice.warn { border-left-color: var(--warning, #f59e0b); }++.notice.bad { border-left-color: var(--error, #ef4444); }++button { font: inherit; cursor: pointer; }++form.inline { display: inline; }++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }++++/* ── Buttons ────────────────────────────────────────────── */++.btn { display: inline-block; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }++.btn:hover { border-color: var(--accent, #3b82f6); }++.btn.primary { background: var(--accent, #3b82f6); border-color: var(--accent, #3b82f6); color: #fff; }++.btn.danger { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }++.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 14px 0; }++.pager { display: flex; gap: 12px; align-items: center; margin: 14px 0; }++++/* ── The home page ──────────────────────────────────────── */++.lede { font-size: 1.08rem; max-width: 70ch; }++.cards { list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; padding: 0; margin: 12px 0; }++.card { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 14px 16px; }++.card h3 { margin: 0 0 6px; }++.card p { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .93rem; }++.facts { margin: 12px 0; max-width: 80ch; }++.facts li { margin: 0 0 8px; }++++/* ── The forms ──────────────────────────────────────────── */++.watch-form { max-width: 80ch; }++.watch-form fieldset, .saved-form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; margin: 12px 0; }++.watch-form legend, .saved-form legend { padding: 0 6px; color: var(--text-secondary, #96a7c2); }++.field { display: flex; flex-direction: column; gap: 3px; margin: 0 0 12px; }++.field label { font-size: .9rem; color: var(--text-secondary, #96a7c2); }++.field input[type="text"], .field input[type="url"], .field input[type="number"], .field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }++.field.checkbox { flex-direction: column; }++.field.has-error input, .field.has-error select { border-color: var(--error, #ef4444); }++.field-error { color: var(--error, #ef4444); font-size: .88rem; }++.hint { font-size: .82rem; }++.fixed { color: var(--text-primary, #e6edf7); }++.template-chooser { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; font-size: .92rem; }++.template-chooser select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }++.form-actions { margin: 16px 0 8px; display: flex; gap: 12px; align-items: center; }++++/* ── One watch ──────────────────────────────────────────── */++.watch-detail .facts, dl.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }++dl.facts dt { color: var(--text-secondary, #96a7c2); }++dl.facts dd { margin: 0; overflow-wrap: anywhere; }++.in-words { font-size: 1.05rem; max-width: 80ch; }++.changelog { max-width: 80ch; }++.changelog h2 { margin-top: 1.6rem; }++.changelog ul { margin: .4rem 0 1rem; }++++/* The home page's showcase sections sit inside .page, which has its own gutter. */++.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }++.watch-later { max-width: 1080px; margin: 10px auto 0; }++ul.facts { list-style: none; margin: 12px 0; padding: 0; max-width: none; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }++ul.facts li { margin: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }++ul.facts li strong { display: block; margin-bottom: 4px; color: var(--text-primary, #e6edf7); font-size: 1rem; }@@@ -1,16 +1,35 @@'use strict';--/** Express app: request context, the v1 API, health/readiness, metrics. */++/**++ * Express app: the public site (pages, sign-in, discovery), the v1 API, health/readiness and metrics.++ *++ * /, /watches, /watches/:id, /how-it-works, /updates the pages (http/pages.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/v1/* the API (api/watches.js, api/checks.js), loopback-only at the vhost++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ *++ * The site is server-rendered and complete without JavaScript; it acts on the registry in-process as++ * the signed-in person's subject, with exactly the API's ownership rules. The API keeps working++ * unchanged: bearer tokens, one capability per route, problem+json.++ */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 cache = require('openvibe-shared/cache-policy');const { createWatchReadiness, registerWatchGauges } = require('./observability');++const { createSso } = require('./auth/sso');++const { createPageRoutes } = require('./http/pages');const { watchesRouter } = require('./api/watches');const { checksRouter } = require('./api/checks');const { createActorLimits } = require('./api/actor-limits');++const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');++const { html } = require('./render/html');const pkg = require('../package.json');++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++++/** The text index non-HTML clients get for `/`; the site itself is at the same address for browsers. */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),',@@@ -18,9 +37,8 @@ const TEXT_INDEX = ['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.',++ 'Pages (HTML, server-rendered): /, /watches, /how-it-works, /updates. Sign in at /auth/login.',++ 'This text index is what a non-browser client gets here.','','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)',@@@ -32,46 +50,19 @@ const TEXT_INDEX = ['',].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>--`;++/** Content-Security-Policy for the site's HTML: the OpenVibe Frame (navbar, footer) is loaded from Network. */++const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com; "++ + "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com; "++ + "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https://openvibe.network https://openvibe.media; "++ + "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com; "++ + "frame-src 'self' https://openvibe.network; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https://openvibe.network";--function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null }) {++function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch }) {const app = express();app.disable('x-powered-by');-- app.set('trust proxy', 'loopback');++ app.set('trust proxy', config.trustProxy != null ? config.trustProxy : 'loopback');const release = createRelease({ service: 'watch', root: path.join(__dirname, '..') });++ setRelease(release.release);// 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 });@@@ -81,7 +72,9 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,require('openvibe-shared/trace').install(app);app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');-- res.setHeader('Cache-Control', 'no-store');++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ // The API and sign-in are never cached; a page decides for itself.++ if (req.path.startsWith('/api') || req.path.startsWith('/auth')) res.setHeader('Cache-Control', 'no-store');next();});app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));@@@ -97,29 +90,50 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,// 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).++ // ── Who is signed in (the session cookie; never a Network token) ──++ const sso = createSso({ config, keys: keyStore, sessions, fetchImpl, now: now || (() => Date.now()), log });++ app.use(sso.middleware());++++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──++ app.use('/auth', sso.routes());++++ // ── Static assets (content-hashed ?v= → immutable) ──++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── The API (unchanged: bearer tokens, one capability per route, problem+json) ──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 }));++ app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));-- // 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) => {++ // ── Pages ───────────────────────────────────────────────────────────────────────────────++ // `/` answers the text index to a client that does not ask for HTML (the service's own index,++ // kept from before the site existed); a browser falls through to the home page below.++ app.get('/', (req, res, next) => {++ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) return next();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, next) => {++ // A page is HTML; the CSP is the site's, and every page is complete without script.++ if (!req.path.startsWith('/api') && !req.path.startsWith('/auth')) res.setHeader('Content-Security-Policy', CSP);++ next();++ });++ app.use(createPageRoutes({ config, db, registry, check, observations, sessions, siteLimits, log }));-- app.use((req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));++ app.use((req, res) => send(res, 404, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',++ body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/watches">your watches</a>.</p>`,++ }));// eslint-disable-next-line no-unused-varsapp.use((err, req, res, _next) => {@@@ -127,11 +141,13 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,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 });++ if (req.path.startsWith('/api')) return http.sendProblem(res, 500, 'watch.internal', { detail: 'internal error', ctx: req.ov });++ return res.status(500).type('text/plain').send(`Something went wrong on ${SITE_NAME}. ${TAGLINE}`);});app.locals.metrics = metrics;++ app.locals.sso = sso;return app;}--module.exports = { createApp };++module.exports = { createApp, TEXT_INDEX, CSP };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++/**++ * The Network's RS256 signing key, fetched once from the configured JWKS URL and kept fresh by++ * openvibe-sdk/auth's JWKS client. The site needs it to verify the person's Network session token++ * during the OAuth code exchange (server/auth/sso.js): a token that does not verify for++ * `openvibe.network` with a real subject never becomes a session.++ *++ * The SDK keeps one client per URL (last good keys through an outage, backoff, rotations, unref'd++ * timer). This is a thin shim so the SSO middleware can call `verifyUser`, and nothing the SDK says++ * about a failed key fetch (it names the internal JWKS URL) leaves the process: a key that has not++ * loaded is answered as 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++const jwksUrl = (config) => (config.jwksUrl || `${config.networkInternalUrl || config.networkUrl}/api/.well-known/jwks`);++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++++ // The SDK's `now` is a NUMBER of milliseconds; a function disables every expiry check.++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));++++ /** Fetch (or wait out the backoff window) once at boot. */++ async function ensure() {++ try { await client.keys(); } catch (err) { log.warn(`[watch] JWKS unavailable: ${err && err.message || err}`); }++ }++ function loaded() { return client.status().ready === true; }++++ /**++ * Verify a person's (session) token: { ok, claims } or { ok: false, code, reason, expired }.++ * `reason` is one of two fixed public strings; the SDK's own text (the JWKS URL, the fetch error)++ * is logged, never returned.++ */++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });++ return { ok: true, claims };++ } catch (err) {++ const code = err && err.code;++ log.warn(`[watch] user token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };++ }++ }++++ return { ensure, loaded, verifyUser, client, stop: () => client.stop() };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++/**++ * Sign in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `watch`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge_method=S256 (?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier),++ * the token verified for openvibe.network, then a session row in Watch's++ * own database and an httpOnly cookie naming it++ * GET /auth/logout → clear the cookie, revoke the session row++ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest++ *++ * The browser never holds a Network token. The cookie carries an opaque random session token; the++ * database stores only its hash (server/sessions.js). The Network access token is verified once, at++ * the callback, and discarded: the site acts on the watch registry in-process as the person's++ * canonical subject (usr_…), never as the person over Network's API.++ *++ * The OAuth half rides openvibe-sdk/auth's own helpers (startAuthorization / readCallback /++ * exchangeCode), so the PKCE, state and token-endpoint rules are the SDK's, not a hand copy.++ */++const express = require('express');++const sdk = require('openvibe-sdk/auth');++const cache = require('openvibe-shared/cache-policy');++++const SESSION_COOKIE = 'watch_session';++const FLOW_COOKIE = 'watch_oauth';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_TTL_MS = 10 * 60 * 1000;++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next) {++ // Browsers drop tab/newline and read a backslash as "/": "/<TAB>/evil.com" would leave the site.++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++/** Parse a Cookie header into a plain object (express gives no parser and this is all we need). */++function parseCookies(header) {++ const out = {};++ for (const part of String(header || '').split(';')) {++ const i = part.indexOf('=');++ if (i < 0) continue;++ const k = part.slice(0, i).trim();++ if (!k) continue;++ let v = part.slice(i + 1).trim();++ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);++ // express's res.cookie percent-encodes the value; undo that so JSON flow cookies round-trip.++ try { v = decodeURIComponent(v); } catch { /* keep the raw value */ }++ out[k] = v;++ }++ return out;++}++++function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSessionCookie(res, token) {++ res.cookie(SESSION_COOKIE, token, { ...cookieBase(), maxAge: sessions.DEFAULT_TTL_MS });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });++ }++ function clearSessionCookie(res) {++ res.clearCookie(SESSION_COOKIE, cookieBase());++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });++ }++++ /**++ * Verify the token the Network just minted: a session token for openvibe.network, carrying the++ * canonical subject. Anything else (a service token, a typed token, another audience) never++ * becomes a session. Returns the claims or throws.++ */++ async function verifySessionToken(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ if (!v.ok) throw Object.assign(new Error(v.reason || 'the Network token did not verify'), { status: 401, code: v.code });++ const claims = v.claims || {};++ if (typeof claims.subject_id !== 'string' || !/^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/.test(claims.subject_id)) {++ throw Object.assign(new Error('this account has no canonical subject yet; sign in again'), { status: 401, code: 'identity.no_subject' });++ }++ return claims;++ }++++ /** req.viewer for every page: the signed-in person named by the session cookie, else anonymous. */++ function middleware() {++ return async (req, _res, next) => {++ req.cookies = parseCookies(req.headers.cookie);++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies[SESSION_COOKIE];++ if (!token) return next();++ try {++ const row = await sessions.resolve(token);++ if (row) req.viewer = sessions.viewer(row);++ } catch (err) {++ log.warn(`[sso] session lookup failed: ${(err && err.message) || err}`);++ }++ return next();++ };++ }++++ function routes() {++ const r = express.Router();++ const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });++++ r.get('/login', async (req, res) => {++ const next = sanitizeNext(req.query.next);++ const started = await sdk.startAuthorization({++ network: config.networkUrl,++ clientId: config.oauth.clientId,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state: sdk.createState(),++ });++ res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.codeVerifier, next }), flowCookie());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ return res.redirect(started.url);++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next) : '/';++ const query = new URLSearchParams();++ for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));++ let code;++ try {++ ({ code } = sdk.readCallback(`/auth/callback?${query.toString()}`, { expectedState: flow && flow.state }));++ } catch (err) {++ return res.status(400).type('text/plain').send('Sign-in was not completed. Please try again.');++ }++ if (!flow || typeof flow.verifier !== 'string') {++ return res.status(400).type('text/plain').send('Sign-in was not started in this browser. Please try again.');++ }++ let token;++ try {++ const data = await sdk.exchangeCode({++ code,++ redirectUri: config.oauth.redirectUri,++ codeVerifier: flow.verifier,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret || undefined,++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ network: config.networkUrl,++ fetch: fetchImpl,++ });++ token = data.access_token;++ } catch (err) {++ log.warn(`[sso] code exchange failed: ${(err && err.code) || (err && err.status) || err}`);++ return res.status(502).type('text/plain').send('Sign-in failed: OpenVibe.Network did not accept the sign-in code. Please try again.');++ }++ try {++ const claims = await verifySessionToken(token);++ const session = await sessions.create({++ subject: claims.subject_id,++ username: typeof claims.username === 'string' ? claims.username : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name : null,++ role: typeof claims.role === 'string' ? claims.role : null,++ });++ setSessionCookie(res, session);++ return res.redirect(next);++ } catch (err) {++ log.warn(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || err}`);++ return res.status(401).type('text/plain').send('Sign-in failed: that account cannot be signed in here.');++ }++ });++++ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site++ // <img src=/auth/logout>). A same-origin navigation or form, or the address bar (Sec-Fetch-Site++ // none), signs out directly; anything else gets a confirm button (POST).++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}` || origin === new URL(config.baseUrl).origin;++ return req.method === 'GET';++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';++ return res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Watch?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const token = req.cookies && req.cookies[SESSION_COOKIE];++ if (token) { try { await sessions.revoke(token); } catch { /* the cookie is cleared anyway */ } }++ clearSessionCookie(res);++ return res.redirect(303, sanitizeNext(req.query.next));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ // No cookie at all is a guest, not an error: the shared navbar asks on every page view.++ const c = req.cookies || {};++ if (!c[SESSION_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ return res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });++ });++ return r;++ }++++ return { middleware, routes, verifySessionToken, SESSION_COOKIE, FLOW_COOKIE };++}++++module.exports = { createSso, sanitizeNext, parseCookies, SESSION_COOKIE, FLOW_COOKIE, HINT_COOKIE };@@@ -29,18 +29,24 @@ function load(env = process.env) {// 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`);++ const baseUrl = strip(env.BASE_URL || (isProduction ? 'https://openvibe.watch' : `http://localhost:${port}`));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}`)),++ baseUrl,++ // The public site is behind nginx (and Cloudflare): trust the proxy's forwarded address, as the++ // API's own limits and same-origin checks need the real client. 0 disables it (tests).++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,networkUrl,networkInternalUrl,jwksUrl,issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),++ // The issuer a person's Network session token carries (sign-in); the same Network as the API's.++ networkIssuer: 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.@@@ -51,7 +57,15 @@ function load(env = process.env) {oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'watch',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ // The site's redirect (BASE_URL + /auth/callback) and the scope and audience a person's++ // Network session token is verified for. The scope is 'profile': the site asks for nothing++ // beyond who the person is, because it acts on its own registry, not on Network.++ redirectUri: strip(env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`),++ scope: env.OV_OAUTH_SCOPE || 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',},++ // The site's session cookie: Secure in production (https), off in development and tests.++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * Crawl artifacts for openvibe.watch: robots.txt, sitemap.xml, llms.txt and llms-full.txt, plus the++ * home page's JSON-LD, all built with openvibe-shared/seo.++ *++ * The site's own two public pages — the home page and how it checks — are for crawlers. A person's++ * watches and their sign-in are not: /watches and /auth are disallowed, and the sitemap lists only++ * the two pages that say what Watch is.++ */++const fs = require('fs');++const path = require('path');++const express = require('express');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++++const SITE_NAME = 'OpenVibe.Watch';++const DESCRIPTION = 'OpenVibe.Watch — get told when a page, a feed or an API changes. Define what to watch and the condition that fires; Watch checks each source on the cadence you pick and records every check.';++const DISALLOW = ['/auth/', '/watches'];++++const PAGE_TEXT = {++ '/': ['OpenVibe.Watch home', 'Define a watch over a page, a feed or an API, choose the condition that fires, and sign in to see what each check found. Watch prefers the site\'s own ETag before it re-reads a body, and never checks more often than the cadence you choose.'],++ '/how-it-works': ['How it checks', 'The carrier order (conditional GET first, feeds and APIs before pages), the polite per-host spacing, the cadence floor, and what every check state means.'],++};++++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++function siteUpdated() {++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }++}++++function homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'UtilitiesApplication', keywords: 'openvibe, watch, monitoring, price watch, feed' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++const publicPages = () => [++ { path: '/', changefreq: 'weekly', priority: 1.0 },++ { path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },++];++++function createDiscoveryRoutes({ config }) {++ const r = express.Router();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# openvibe.watch: the two pages that say what Watch is are for crawlers. A person\'s watches and sign-in are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: DESCRIPTION,++ details: 'Every page is server-rendered and complete without JavaScript. A person signs in with OpenVibe.Network to define and manage their own watches; nobody else can see them.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Watch', url: abs('/'), note: 'What Watch is and the four ways to start a watch.' },++ { title: 'How it checks', url: abs('/how-it-works'), note: 'The carrier order, the cadence floor and what each state means.' },++ ] },++ { title: 'For machines', links: [++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'The watch API (for services)', url: abs('/how-it-works'), note: 'Bearer tokens, one capability per route; /api/v1/* stays loopback-only.' },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Watch, one paragraph each.',++ base: site,++ maxBytes: 64 * 1024,++ sections: [{ title: 'Pages', pages }],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME, DISALLOW, PAGE_TEXT };nnew file mode 100644@@@ -0,0 +1,759 @@++'use strict';++/**++ * The public site: the home page, a person's watches, one watch, the create and edit forms, how it++ * checks and the update log, plus the crawl artifacts (server/http/discovery.js).++ *++ * Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:++ * the create and edit forms POST, pause/resume/delete/check-now are plain POST buttons, and the++ * template a form shows is chosen by the query (`/watches/new?template=price`), never by script.++ *++ * The site is the same authority as the API, in-process: it calls the watch registry directly with++ * the signed-in person's canonical subject, so exactly the API's ownership rules apply — another++ * person's watch is 404 on every page and every form, never 403. A signed-in write must come from++ * this site (same-origin), so another site cannot make a visitor act here; the session cookie is++ * SameSite=Lax as well.++ *++ * Kinds whose carrier does not exist yet (event, webhook, run, node) and the `ai` extraction are not++ * offered: the four starting points are the three pull carriers — a page, a feed, an API.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { RegistryError } = require('../registry');++const { html, raw, table, notice, time, badge } = require('../render/html');++const { send } = require('../render/layout');++const W = require('../render/watch');++const changelog = require('../render/changelog');++const { createDiscoveryRoutes, homeJsonLd, DESCRIPTION } = require('./discovery');++++/** The four starting points. Each fixes the source kind, the extraction and the condition's ops. */++const TEMPLATES = {++ page: {++ card: 'A page changes',++ cardBlurb: 'Give a URL and, if you like, the CSS selector of the part you care about. Watch tells you when its text changes.',++ title: 'Watch a page for changes',++ kind: 'http',++ kindWords: 'a web page',++ ops: ['changed'],++ extraction: 'html',++ },++ price: {++ card: 'Price drops below',++ cardBlurb: 'Read one number off a page — a CSS selector or a regular expression — and fire when it drops below (or rises above) a value.',++ title: 'Watch a price',++ kind: 'http',++ kindWords: 'a web page',++ ops: ['lt', 'lte', 'gt', 'gte'],++ defaultOp: 'lt',++ extraction: 'css',++ },++ feed: {++ card: 'New item in a feed',++ cardBlurb: 'Give an RSS or Atom feed — a GitHub releases .atom URL, a blog’s RSS — and fire when a new item appears.',++ title: 'Watch a feed for new items',++ kind: 'feed',++ kindWords: 'an RSS or Atom feed',++ ops: ['changed'],++ extraction: 'json',++ valuePath: 'latest',++ },++ json: {++ card: 'A JSON value',++ cardBlurb: 'Give a JSON API and a path to the value inside it, and fire on the condition you choose.',++ title: 'Watch a JSON value',++ kind: 'api',++ kindWords: 'a JSON API',++ ops: ['changed', 'gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'contains', 'matches', 'exists', 'absent'],++ defaultOp: 'lt',++ extraction: 'json',++ },++};++const TEMPLATE_NAMES = Object.keys(TEMPLATES);++const OPS = {++ changed: 'changes', gt: 'goes above', gte: 'reaches or is more than', lt: 'drops below', lte: 'is less than or equal to',++ eq: 'equals', ne: 'is not', contains: 'contains', matches: 'matches the pattern', exists: 'is present', absent: 'is absent',++};++const NEEDS_VALUE = new Set(['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'contains', 'matches']);++const NOTIFY_ACTION = [{ kind: 'notification', category: 'service', priority: 'normal' }];++// With notifications off the watch still needs an action (the contract requires one); actions are++// step 4 of the plan and none is dispatched yet, so this records the person's intent without changing++// what happens today (see how-it-works and the note on the form).++const QUIET_ACTION = [{ kind: 'event', event_type: 'watch.watch.triggered' }];++++const INT = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };++++/** Same-site writes only: another site must not make a signed-in visitor act here. */++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (!origin) return false;++ if (origin === `${req.protocol}://${req.get('host')}`) return true;++ try { return origin === new URL(baseUrl).origin; } catch { return false; }++}++++function createPageRoutes(ctx) {++ const { config, db, registry, check, observations, sessions, log = console } = ctx;++ const r = express.Router();++ const formBody = express.urlencoded({ extended: false, limit: '32kb' });++++ const viewer = (req) => req.viewer || { kind: 'anonymous' };++ const signedIn = (req) => viewer(req).kind === 'user' && Boolean(viewer(req).subject);++ const principalOf = (req) => ({ sub: viewer(req).subject, subject: null });++ const subjectOf = (req) => viewer(req).subject;++ const page = (req, res, o, status = 200) => send(res, status, { viewer: viewer(req), config, path: req.originalUrl, ...o });++ const fail = (req, res, status, title, body) => page(req, res, { title, body }, status);++++ // ── The form model ───────────────────────────────────────────────────────────────────────++++ /** The values a form starts from: a template's defaults, or an existing watch (the edit form). */++ function blankFields(template) {++ const t = TEMPLATES[template] || TEMPLATES.page;++ return {++ template, name: '', url: '', kind: t.kind,++ extraction: t.extraction, selector: '', value_path: t.valuePath || '',++ op: t.defaultOp || t.ops[0], value: '', for_sec: '',++ cadence: '3600', notify: 'on',++ };++ }++++ /**++ * The fields a submitted form means. A checkbox posts nothing when it is unchecked, so `notify`++ * is read straight from the body — never inherited from the form's own default.++ */++ function postedFields(template, body) {++ const fields = { ...blankFields(template), ...(body || {}) };++ fields.notify = body && body.notify === 'on' ? 'on' : '';++ return fields;++ }++++ /** Which template an existing watch most resembles, so the edit form shows its fields. */++ function templateOf(watch) {++ const kind = watch.source && watch.source.kind;++ if (kind === 'feed') return 'feed';++ if (kind === 'api') return 'json';++ const e = watch.extraction || {};++ const op = (watch.condition || {}).op;++ if (e.kind === 'regex' || ['lt', 'lte', 'gt', 'gte'].includes(op)) return 'price';++ return 'page';++ }++++ function fieldsOfWatch(watch) {++ const t = templateOf(watch);++ const e = watch.extraction || {};++ return {++ template: t,++ name: watch.name || '',++ url: (watch.source && watch.source.url) || '',++ kind: (watch.source && watch.source.kind) || TEMPLATES[t].kind,++ extraction: e.kind === 'regex' ? 'regex' : (e.kind || TEMPLATES[t].extraction),++ selector: e.selector || '',++ value_path: e.value_path || '',++ op: (watch.condition || {}).op || TEMPLATES[t].ops[0],++ value: (watch.condition && watch.condition.value != null) ? String(watch.condition.value) : '',++ for_sec: (watch.condition && Number.isInteger(watch.condition.for_sec)) ? String(watch.condition.for_sec) : '',++ cadence: String((watch.cadence && watch.cadence.every_sec) || 3600),++ notify: notifyOn(watch.action) ? 'on' : '',++ };++ }++ const notifyOn = (action) => Array.isArray(action) && action.some((a) => a && a.kind === 'notification');++++ /** The condition a form says, checked against the template's ops. Returns { condition } or { error }. */++ function conditionFrom(fields, template) {++ const t = TEMPLATES[template];++ const op = String(fields.op || t.defaultOp || t.ops[0]);++ if (!t.ops.includes(op)) return { error: `The condition must be one of: ${t.ops.join(', ')}.` };++ const condition = { op };++ if (NEEDS_VALUE.has(op)) {++ const raw = String(fields.value == null ? '' : fields.value).trim();++ if (!raw) return { error: `“${OPS[op]}” needs a value to compare against.`, field: 'value' };++ // A plain decimal is stored as a number; anything else as the text the person typed.++ condition.value = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;++ }++ if (template === 'price' || template === 'json') {++ const secs = String(fields.for_sec == null ? '' : fields.for_sec).trim();++ if (secs) {++ const n = INT(secs, NaN);++ if (!Number.isFinite(n) || n < 0) return { error: '“Hold for” must be a whole number of seconds, or blank.', field: 'for_sec' };++ if (n > 0) condition.for_sec = n;++ }++ }++ return { condition };++ }++++ /**++ * The watch request body a submitted form means — the same shape a caller would POST to++ * /api/v1/watches. Every rule the registry enforces still applies; this only assembles the JSON.++ * Returns { body } or { errors: { field: message } }.++ */++ function buildBody(fields, template) {++ const t = TEMPLATES[template];++ const errors = {};++ const url = String(fields.url == null ? '' : fields.url).trim();++ if (!url) errors.url = 'Give the address of the page, feed or API to watch.';++ else if (!/^https?:\/\/\S+$/i.test(url)) errors.url = 'The address must start with http:// or https://.';++ else { try { new URL(url); } catch { errors.url = 'That is not a valid address.'; } }++++ const cadenceSec = INT(fields.cadence, 3600);++ if (cadenceSec < W.MIN_CADENCE_SEC) errors.cadence = 'The shortest cadence here is 15 minutes.';++ if (cadenceSec > 2592000) errors.cadence = 'The longest cadence is 30 days.';++++ const name = String(fields.name == null ? '' : fields.name).trim();++ if (name.length > 120) errors.name = 'A name can be at most 120 characters.';++++ const source = { kind: t.kind, url, format: null };++ let extraction;++ if (template === 'page') {++ extraction = { kind: 'html', selector: String(fields.selector || '').trim() || null };++ } else if (template === 'price') {++ const how = fields.extraction === 'regex' ? 'regex' : 'css';++ const selector = String(fields.selector || '').trim();++ if (!selector) errors.selector = how === 'regex' ? 'Give the regular expression that reads the price.' : 'Give the CSS selector of the element that holds the price.';++ extraction = { kind: how, selector };++ } else if (template === 'feed') {++ extraction = { kind: 'json', value_path: 'latest' };++ } else {++ extraction = { kind: 'json', value_path: String(fields.value_path || '').trim() || null };++ }++++ const c = conditionFrom(fields, template);++ if (c.error) errors[c.field || 'op'] = c.error;++++ if (Object.keys(errors).length) return { errors };++ const body = {++ source,++ cadence: { every_sec: cadenceSec, jitter_sec: 0 },++ extraction,++ condition: c.condition,++ action: fields.notify === 'on' ? NOTIFY_ACTION : QUIET_ACTION,++ };++ if (name) body.name = name; // omitted, not undefined: the registry names it from the source++ return { body };++ }++++ /**++ * Map a registry error to the form field it names, so the message can sit next to the input.++ * Only a field this form renders is returned; anything else (a oneOf failure that names no++ * single field) is answered as null and shown as a form-level notice instead of being lost.++ */++ function registryField(message) {++ const m = String(message);++ const named = m.match(/\/(?:source|extraction|condition|cadence|name|action)\/(url|selector|value_path|value|for_sec|every_sec)\b/);++ if (named) return named[1] === 'every_sec' ? 'cadence' : named[1];++ if (/\/name\b/.test(m)) return 'name';++ if (/\/condition\b/.test(m)) return 'op';++ if (/\/cadence\b/.test(m)) return 'cadence';++ if (/\/extraction\b/.test(m)) return 'selector';++ if (/\burl\b/i.test(m)) return 'url';++ return null;++ }++++ // ── The form, rendered ───────────────────────────────────────────────────────────────────++++ const idOf = (f) => `f-${f}`;++ function field(label, control, { name, error, hint = null, cls = '' } = {}) {++ const id = idOf(name);++ return html`<p class="field ${cls}${error ? ' has-error' : ''}">++<label for="${id}">${label}</label>++${control}++${hint ? html`<span class="hint muted small" id="${id}-hint">${hint}</span>` : ''}++${error ? html`<span class="field-error" role="alert">${error}</span>` : ''}++</p>`;++ }++++ function checkbox(label, { name, checked, hint = null, error = null }) {++ const id = idOf(name);++ return html`<p class="field checkbox${error ? ' has-error' : ''}">++<label for="${id}"><input type="checkbox" id="${id}" name="${name}" value="on"${checked ? raw(' checked') : ''}> ${label}</label>++${hint ? html`<span class="hint muted small">${hint}</span>` : ''}++${error ? html`<span class="field-error" role="alert">${error}</span>` : ''}++</p>`;++ }++++ const select = (name, options, value) => html`<select id="${idOf(name)}" name="${name}">${options.map((o) => html`<option value="${o.value}"${String(o.value) === String(value) ? raw(' selected') : ''}>${o.label}</option>`)}</select>`;++++ const CADENCE_CHOICES = W.CADENCES.map((c) => ({ value: c.sec, label: c.label }));++++ /**++ * The one form for all four kinds: the template picks the fieldset (server-side), so the page++ * needs no script. action is where it POSTs; submit is the button's words.++ */++ function watchForm({ template, fields, errors, action, submit }) {++ const t = TEMPLATES[template];++ const cadence = select('cadence', CADENCE_CHOICES, fields.cadence);++ const opChoices = t.ops.map((op) => ({ value: op, label: OPS[op] }));++ const opControl = t.ops.length > 1++ ? select('op', opChoices, fields.op)++ : html`<input type="hidden" name="op" value="${t.ops[0]}"><span class="fixed">fires when it ${OPS[t.ops[0]]}</span>`;++++ const needsValue = t.ops.some((op) => NEEDS_VALUE.has(op));++ const conditionBlock = html`<fieldset><legend>When should it fire?</legend>++${field('Condition', opControl, { name: 'op', error: errors.op })}++${needsValue ? field('Value', html`<input type="text" id="${idOf('value')}" name="value" value="${fields.value}" maxlength="200">`, { name: 'value', error: errors.value, hint: 'The value to compare the reading against. Numbers are compared as numbers.' }) : ''}++${(template === 'price' || template === 'json') ? field('Hold for (seconds)', html`<input type="number" id="${idOf('for_sec')}" name="for_sec" value="${fields.for_sec}" min="0" max="2592000" inputmode="numeric">`, { name: 'for_sec', error: errors.for_sec, hint: 'Blank fires as soon as the condition is met; a number makes it wait that long and stay true.' }) : ''}++</fieldset>`;++++ let extractionBlock;++ if (template === 'page') {++ extractionBlock = html`<fieldset><legend>What should it read?</legend>++${field('CSS selector', html`<input type="text" id="${idOf('selector')}" name="selector" value="${fields.selector}" maxlength="300" placeholder="e.g. .price (blank: the whole page text)">`, { name: 'selector', error: errors.selector, hint: 'The text of the first element matching this selector. Leave it blank to read the whole page text.' })}++</fieldset>`;++ } else if (template === 'price') {++ extractionBlock = html`<fieldset><legend>How should it read the price?</legend>++${field('Read it with', select('extraction', [{ value: 'css', label: 'a CSS selector' }, { value: 'regex', label: 'a regular expression' }], fields.extraction), { name: 'extraction', error: errors.extraction, hint: 'A CSS selector reads one element; a regular expression reads the first match (its first group when it has one).' })}++${field('Selector or pattern', html`<input type="text" id="${idOf('selector')}" name="selector" value="${fields.selector}" maxlength="300" placeholder=".price or (\\d+\\.\\d+) EUR">`, { name: 'selector', error: errors.selector })}++</fieldset>`;++ } else if (template === 'feed') {++ extractionBlock = html`<fieldset><legend>What should it read?</legend>++<p class="muted small">Watch reads the feed’s newest item (title, link, published time — all of it). The feed is read before the page is ever fetched and only when the feed says it changed.</p>++</fieldset>`;++ } else {++ extractionBlock = html`<fieldset><legend>What should it read?</legend>++${field('Value path', html`<input type="text" id="${idOf('value_path')}" name="value_path" value="${fields.value_path}" maxlength="200" placeholder="e.g. price, data.0.value">`, { name: 'value_path', error: errors.value_path, hint: 'A dot path into the JSON — `items.0.price`. Leave blank to watch the whole body.' })}++</fieldset>`;++ }++++ return html`<form class="watch-form" method="post" action="${action}">++<input type="hidden" name="template" value="${template}">++<input type="hidden" name="kind" value="${t.kind}">++<fieldset><legend>What to watch</legend>++${field('Name', html`<input type="text" id="${idOf('name')}" name="name" value="${fields.name}" maxlength="120" placeholder="e.g. Laptop price at the usual shop">`, { name: 'name', error: errors.name, hint: 'Optional. Without one, Watch names it after the address.' })}++${field('Address', html`<input type="url" id="${idOf('url')}" name="url" value="${fields.url}" maxlength="2000" required placeholder="https://…">`, { name: 'url', error: errors.url, hint: html`This is ${t.kindWords}, fetched with the site’s own ETag/Last-Modified when it gives one.` })}++</fieldset>++${extractionBlock}++${conditionBlock}++<fieldset><legend>How often, and how to tell you</legend>++${field('Cadence', cadence, { name: 'cadence', error: errors.cadence, hint: 'Watch never checks more often than this, and can wait longer when the site asks it to.' })}++${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'A fired condition becomes a WATCH_TRIGGERED notification through OpenVibe.Network, as today.' })}++</fieldset>++<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>++<p class="muted small">Actions are step 4 of the plan: a fired condition records its trigger and emits <code>watch.watch.triggered</code> today, and no other action is dispatched yet — turning notifications off does not silence that until actions run.</p>++</form>`;++ }++++ /** The GET chooser above the form, so a kind can be picked without any JavaScript. */++ const templateChooser = (template) => html`<form class="template-chooser" method="get" action="/watches/new">++<label for="template">What kind of watch?</label>++<select id="template" name="template">${TEMPLATE_NAMES.map((n) => html`<option value="${n}"${n === template ? raw(' selected') : ''}>${TEMPLATES[n].card}</option>`)}</select>++<button class="btn" type="submit">Show those fields</button>++</form>`;++++ // ── Home ────────────────────────────────────────────────────────────────────────────────++++ r.get('/', async (req, res) => {++ let summary = null;++ if (signedIn(req)) {++ const rows = await db.prepare('SELECT status, COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL GROUP BY status').all(subjectOf(req));++ const counts = { active: 0, paused: 0, disabled: 0, failed: 0, total: 0 };++ for (const row of rows) { const n = Number(row.n); counts[row.status] = n; counts.total += n; }++ summary = counts;++ }++ const showcase = require('openvibe-shared/showcase');++ const yours = summary && summary.total++ ? `You have ${summary.total} watch${summary.total === 1 ? '' : 'es'}: ${summary.active} active, ${summary.paused} paused, ${summary.failed} failing.`++ : null;++ const hero = showcase.hero({++ eyebrow: 'OpenVibe.Watch · pages, feeds and APIs',++ title: 'Know the moment it changes.',++ accent: 'Checked politely, never guessed.',++ lede: 'Give Watch a page, a feed or a JSON API, say which value you care about and when to tell you. It checks on the cadence you pick, records what every check found, and tells you when your condition fires.',++ actions: signedIn(req)++ ? [{ label: 'New watch', href: '/watches/new?template=page', primary: true }, { label: 'Your watches', href: '/watches' }]++ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fwatches%2Fnew%3Ftemplate%3Dpage', primary: true }, { label: 'How it checks', href: '#h-check' }],++ note: yours || 'Free while it is in alpha. Reading this page needs no account.',++ });++ const ICONS = { page: 'ov:docs', price: 'ov:deals', feed: 'ov:news', json: 'ov:json' };++ page(req, res, {++ index: true,++ cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }),++ jsonLd: homeJsonLd(config),++ description: DESCRIPTION,++ styles: [showcase.STYLESHEET],++ body: html`${raw(hero)}++${raw(showcase.features({++ id: 'start',++ title: 'Start with one of these',++ lede: 'Each opens the form filled in for that kind of watch; change anything before you save it.',++ items: TEMPLATE_NAMES.map((n) => ({ icon: ICONS[n] || 'ov:bell', title: TEMPLATES[n].card, text: TEMPLATES[n].cardBlurb, href: `/watches/new?template=${n}` })),++ }))}++<p class="muted small watch-later">Pages, feeds and APIs today. Events, webhooks, browser checks through Run, Node probes and AI readings come later, and are not offered until Watch can really check them.</p>++${raw(showcase.steps({++ id: 'steps',++ title: 'From a URL to a notification',++ items: [++ { title: 'Point it at something', text: 'A page (and, if you like, the CSS selector of the part you care about), an RSS or Atom feed, or a JSON API and a path into it.' },++ { title: 'Say when to tell you', text: 'Any change, a number above or below a value, text that appears or disappears, a new item in a feed — held for a while if you want it steady.' },++ { title: 'Pick a cadence', text: 'Every 15 minutes, hourly, every 6 hours or daily. Watch never checks faster than that, and slower when a site asks it to wait.' },++ { title: 'Get told', text: 'When the condition fires, the notification arrives on your OpenVibe account, and the watch page shows every check and the value it found.' },++ ],++ }))}++<section class="sc-sec" aria-labelledby="h-check"><h2 id="h-check">How it checks</h2>++<ul class="facts">++<li><strong>The cheapest way first.</strong> If the site gave Watch an ETag or Last-Modified, the next check asks with <code>If-None-Match</code>/<code>If-Modified-Since</code> and a “not modified” answer costs no body. When only “did it change?” matters, a <code>HEAD</code> is tried while a validator is held: two headers instead of a page.</li>++<li><strong>Feeds and APIs before pages.</strong> The carrier order is binding: an event or webhook when one exists, then the etag, then a feed, then an API, and only then an expensive browser check. A watch checks the kind its source names.</li>++<li><strong>Polite per-site spacing.</strong> Two requests to one host never leave at once, and no two requests to a host are closer than the host’s minimum interval — whatever the watch says.</li>++<li><strong>Never faster than your cadence.</strong> A watch is due on the cadence you pick; a failing site is checked less often (backoff), and a site that asks Watch to wait is waited for (<code>Retry-After</code>).</li>++<li><strong>Every check is recorded.</strong> Read, no change, changed, condition met, a site error, a timeout, an unreadable answer, a wait, a skipped kind — the state is kept with the value, the time and a capped snapshot, and the watch is re-armed on every path.</li>++</ul>++<p><a href="/how-it-works">The full picture, in plain words</a>.</p></section>++${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-yours">Your watches</h2>++<p>${summary.total ? html`You have <strong>${summary.total}</strong> watch${summary.total === 1 ? '' : 'es'}: ${summary.active} active, ${summary.paused} paused, ${summary.disabled} disabled, ${summary.failed} failing.` : 'You have no watches yet.'} <a href="/watches">See them</a> or <a href="/watches/new?template=page">start one</a>.</p></section>` : ''}`,++ });++ });++++ // ── Your watches ────────────────────────────────────────────────────────────────────────++++ const PAGE = 25;++ const beforeId = (q) => (/^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(q || '')) ? String(q) : null);++++ r.get('/watches', async (req, res) => {++ if (!signedIn(req)) {++ return page(req, res, {++ title: 'Your watches',++ body: html`<h1>Your watches</h1>++<p>Sign in to see and manage your watches. Each one belongs to the OpenVibe account that made it and to nobody else.</p>++<p><a class="btn primary" href="/auth/login?next=%2Fwatches">Sign in with OpenVibe</a> <a class="btn" href="/">Back home</a></p>`,++ });++ }++ const before = beforeId(req.query.before);++ const rows = await registry.all(principalOf(req), { before, limit: PAGE });++ const withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));++ const watchRows = withLatest.map(({ row, latest }) => {++ const v = registry.view(row);++ return [++ html`<a href="/watches/${row.id}">${v.name}</a>`,++ html`<code>${W.host(v.source.url)}</code>`,++ W.conditionWords(v.condition),++ html`${badge(W.statusWords(v.status), W.statusKind(v.status))}`,++ v.health.last_state++ ? html`${W.stateWords(v.health.last_state)} <span class="muted small">${v.health.last_check_at ? time(v.health.last_check_at) : ''}</span>`++ : html`<span class="muted">not checked yet</span>`,++ latest ? W.valueWords(latest.value, 60) : html`<span class="muted">—</span>`,++ v.health.next_due_at ? time(v.health.next_due_at) : html`<span class="muted">—</span>`,++ ];++ });++ const older = rows.length === PAGE ? `/watches?before=${rows[rows.length - 1].id}` : null;++ page(req, res, {++ title: 'Your watches', private: true,++ body: html`<h1>Your watches</h1>++<p><a class="btn primary" href="/watches/new?template=page">New watch</a></p>++${table(['Name', 'Source', 'Condition', 'Status', 'Last check', 'Last value', 'Next due'], watchRows, { empty: 'You have no watches yet. Start with one of the four kinds on the home page.' })}++<nav class="pager" aria-label="Pages">++${before ? html`<a class="btn" href="/watches">Newest first</a>` : ''}++${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span class="muted">That is every watch here.</span>`}++</nav>`,++ });++ });++++ // ── New watch ───────────────────────────────────────────────────────────────────────────++++ const getTemplate = (q) => (TEMPLATE_NAMES.includes(String(q)) ? String(q) : 'page');++++ r.get('/watches/new', (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const template = getTemplate(req.query.template);++ page(req, res, {++ title: `New watch: ${TEMPLATES[template].card}`, private: true,++ body: html`<h1>New watch</h1>++<p class="muted">${TEMPLATES[template].title}.</p>++${templateChooser(template)}++${watchForm({ template, fields: { ...blankFields(template), url: String(req.query.url || '') }, errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,++ });++ });++++ r.post('/watches/new', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches%2Fnew');++ const template = getTemplate((req.body || {}).template);++ if (!sameOrigin(req, config.baseUrl)) {++ return fail(req, res, 403, 'Cross-site request refused', html`<h1>Cross-site request refused</h1><p>Creating a watch must come from openvibe.watch itself.</p><p><a href="/watches/new?template=${template}">Back to the form</a></p>`);++ }++ const fields = postedFields(template, req.body);++ const limited = ctx.siteLimits.take('watch.write', subjectOf(req));++ if (!limited.ok) {++ res.set('Retry-After', String(limited.retryAfter));++ return fail(req, res, 429, 'Slow down', html`<h1>Slow down</h1><p>You have made a lot of watches in a short time. Try again in ${limited.retryAfter} seconds.</p>`);++ }++ const built = buildBody(fields, template);++ let errors = built.errors;++ if (!errors) {++ try {++ const row = await registry.create(built.body, principalOf(req));++ return res.redirect(303, `/watches/${row.id}`);++ } catch (err) {++ if (!(err instanceof RegistryError)) throw err;++ const field = registryField(err.message);++ errors = { [field || 'form']: err.message };++ }++ }++ page(req, res, {++ title: `New watch: ${TEMPLATES[template].card}`, private: true,++ body: html`<h1>New watch</h1>++${errors.form ? notice(errors.form, 'warn') : ''}++<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and create it again.</p>++${templateChooser(template)}++${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create this watch' })}`,++ }, 422);++ });++++ // ── One watch ───────────────────────────────────────────────────────────────────────────++++ /** The watch when it exists and belongs to the signed-in person; otherwise the 404 page. */++ async function owned(req, res) {++ if (!signedIn(req)) return null;++ const row = await registry.get(req.params.id, principalOf(req));++ return row || null;++ }++ const notFoundPage = (req, res) => fail(req, res, 404, 'Watch not found', html`<h1>Watch not found</h1>++<p>There is no watch here with that id, or it belongs to someone else. A watch is private to the account that made it.</p>++<p><a href="/watches">Your watches</a></p>`);++++ const obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);++ const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);++++ r.get('/watches/:id', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ const v = registry.view(row);++ const latest = await observations.latest(row.id);++ const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });++ const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });++++ const obsRows = obs.map((o) => [++ time(new Date(Number(o.observed_at)).toISOString()),++ W.valueWords(o.value, 120),++ o.changed ? badge('changed', 'info') : html`<span class="muted">no</span>`,++ o.condition_met ? badge('met', 'ok') : html`<span class="muted">no</span>`,++ ]);++ const runRows = runs.map((run) => {++ const view = check.runView(run);++ return [++ time(view.started_at),++ html`${badge(W.stateWords(view.state, view), W.stateKind(view.state))} <span class="muted small">${view.trigger}</span>`,++ view.http_status == null ? html`<span class="muted">—</span>` : String(view.http_status),++ view.detail ? html`<span class="muted small">${view.detail}</span>` : '',++ view.observations ? '1 observation' : '',++ view.triggers ? 'fired' : '',++ ];++ });++++ const ran = String(req.query.ran || '');++ const actionForms = html`<div class="actions">++${v.status === 'active'++ ? html`<form class="inline" method="post" action="/watches/${v.id}/pause"><button class="btn" type="submit">Pause</button></form>`++ : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="btn" type="submit">Resume</button></form>`}++<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>++<a class="btn" href="/watches/${v.id}/edit">Edit</a>++<form class="inline" method="post" action="/watches/${v.id}/delete"><button class="btn danger" type="submit">Delete</button></form>++</div>`;++++ const nextObs = obs.length === obsLimit(req.query.obs_limit) ? `/watches/${v.id}?obs_before=${obs[obs.length - 1].rid}` : null;++ const nextRuns = runs.length === obsLimit(req.query.checks_limit) ? `/watches/${v.id}?checks_before=${runs[runs.length - 1].rid}` : null;++++ page(req, res, {++ title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],++ body: html`<h1>${v.name}</h1>++<p class="in-words">${W.inWords(v)}</p>++${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}++<dl class="facts">++<dt>Status</dt><dd>${badge(W.statusWords(v.status), W.statusKind(v.status))}</dd>++<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || '(none)'}</code></dd>++<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : 'not polled'}</dd>++<dt>Last check</dt><dd>${v.health.last_check_at ? html`${time(v.health.last_check_at)} — ${W.stateWords(v.health.last_state)}` : html`<span class="muted">not checked yet</span>`}</dd>++<dt>Next due</dt><dd>${v.health.next_due_at ? time(v.health.next_due_at) : html`<span class="muted">—</span>`}</dd>++${v.health.consecutive_failures ? html`<dt>Failing</dt><dd>${v.health.consecutive_failures} check(s) in a row</dd>` : ''}++<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd>++</dl>++${actionForms}++<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>++${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet. Watch records one when a check reads a value.' })}++<nav class="pager">${nextObs ? html`<a class="btn" href="${nextObs}">Older observations</a>` : html`<span class="muted">That is every observation kept.</span>`}</nav></section>++<section aria-labelledby="h-runs"><h2 id="h-runs">Check runs</h2>++<p class="muted small">Every check, whatever it found. A skipped kind, a site error and a wait are recorded states, not silence.</p>++${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty: 'No checks yet.' })}++<nav class="pager">${nextRuns ? html`<a class="btn" href="${nextRuns}">Older checks</a>` : html`<span class="muted">That is every check recorded.</span>`}</nav></section>++<p class="muted small">In code: <code>GET /api/v1/watches/${v.id}</code>.</p>`,++ });++ });++++ // ── Pause, resume, delete, check now ────────────────────────────────────────────────────++++ function crossSite(req, res, what) {++ return fail(req, res, 403, 'Cross-site request refused', html`<h1>Cross-site request refused</h1><p>${what} a watch must come from openvibe.watch itself.</p>`);++ }++ const actionLimit = (req, res, name) => {++ const limited = ctx.siteLimits.take(name, subjectOf(req));++ if (limited.ok) return true;++ res.set('Retry-After', String(limited.retryAfter));++ fail(req, res, 429, 'Slow down', html`<h1>Slow down</h1><p>You have done that a lot in a short time. Try again in ${limited.retryAfter} seconds.</p>`);++ return false;++ };++++ r.post('/watches/:id/pause', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Pausing');++ if (!actionLimit(req, res, 'watch.write')) return;++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ await registry.patch(row.id, { status: 'paused' }, principalOf(req));++ return res.redirect(303, `/watches/${row.id}`);++ });++++ r.post('/watches/:id/resume', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Resuming');++ if (!actionLimit(req, res, 'watch.write')) return;++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ await registry.patch(row.id, { status: 'active' }, principalOf(req));++ return res.redirect(303, `/watches/${row.id}`);++ });++++ r.post('/watches/:id/delete', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Deleting');++ if (!actionLimit(req, res, 'watch.write')) return;++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ await registry.remove(row.id, principalOf(req));++ return res.redirect(303, '/watches');++ });++++ r.post('/watches/:id/check', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Checking');++ if (!actionLimit(req, res, 'watch.check.run')) return;++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ const out = await check.run(row.id, { trigger: 'manual' });++ if (!out) return notFoundPage(req, res);++ // The API's 409s and 422, said in words: a second check while one runs, a watch that is not++ // active, and a kind whose carrier is a later step (which fetches nothing).++ if (out.busy) return detailNotice(req, res, row, 409, 'A check of this watch is already running. Try again in a moment.');++ if (out.disabled) return detailNotice(req, res, row, 409, `This watch is ${row.status}, so it is not checked. Resume it first.`);++ if (out.unavailable) return detailNotice(req, res, row, 422, out.unavailable.detail);++ return res.redirect(303, `/watches/${row.id}?ran=${encodeURIComponent(out.run ? out.run.state : '')}`);++ });++++ /** Render the watch page with one notice, without a redirect (the API's 409s and 422). */++ async function detailNotice(req, res, row, status, text) {++ const v = registry.view(row);++ return page(req, res, {++ title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],++ body: html`<h1>${v.name}</h1>++${notice(text, status === 401 || status === 409 ? 'warn' : 'info')}++<p>${W.inWords(v)}</p>++<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>`,++ }, status);++ }++++ // ── Edit ────────────────────────────────────────────────────────────────────────────────++++ r.get('/watches/:id/edit', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ const v = registry.view(row);++ const fields = fieldsOfWatch(v);++ page(req, res, {++ title: `Edit ${v.name}`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }, { label: 'Edit' }],++ body: html`<h1>Edit: ${v.name}</h1>++<p class="muted">The kind is fixed by the watch's source; edit what it reads and when it fires.</p>++${watchForm({ template: fields.template, fields, errors: {}, action: `/watches/${v.id}/edit`, submit: 'Save changes' })}`,++ });++ });++++ r.post('/watches/:id/edit', formBody, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');++ const row = await owned(req, res);++ if (!row) return notFoundPage(req, res);++ const id = row.id;++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Changing');++ if (!actionLimit(req, res, 'watch.write')) return;++ const template = getTemplate((req.body || {}).template);++ const fields = { ...fieldsOfWatch(registry.view(row)), ...(req.body || {}) };++ fields.notify = req.body && req.body.notify === 'on' ? 'on' : '';++ const built = buildBody(fields, template);++ let errors = built.errors;++ if (!errors) {++ try {++ await registry.patch(id, built.body, principalOf(req));++ return res.redirect(303, `/watches/${id}`);++ } catch (err) {++ if (!(err instanceof RegistryError)) throw err;++ const field = registryField(err.message);++ errors = { [field || 'form']: err.message };++ }++ }++ const v = registry.view(row);++ page(req, res, {++ title: `Edit ${v.name}`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }, { label: 'Edit' }],++ body: html`<h1>Edit: ${v.name}</h1>++${errors.form ? notice(errors.form, 'warn') : ''}++<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and save again.</p>++${watchForm({ template, fields, errors, action: `/watches/${id}/edit`, submit: 'Save changes' })}`,++ }, 422);++ });++++ // ── How it works, and the update log ────────────────────────────────────────────────────++++ r.get('/how-it-works', (req, res) => page(req, res, {++ index: true, cache: cache.htmlHeaders({ maxAge: 3600 }),++ title: 'How it checks',++ description: 'How OpenVibe.Watch decides what to fetch and when: the carrier order, the conditional GET, the polite spacing, the cadence floor, and what every check state means.',++ body: html`<h1>How it checks</h1>++<p class="lede">A watch is a promise to look, not a promise to keep asking. Watch knows several ways to find out whether something changed, and it always takes the cheapest one the source allows.</p>++<h2>The order</h2>++<ol>++<li><strong>An event or a webhook</strong> — when the thing can push to Watch, nothing is polled at all. (Not built yet: a watch of that kind is accepted but never fetched, and says so.)</li>++<li><strong>The site's own ETag or Last-Modified</strong> — if the site gave one, the check carries <code>If-None-Match</code>/<code>If-Modified-Since</code>. A <code>304 Not Modified</code> means “no change” and no body is read.</li>++<li><strong>A feed</strong> — an RSS or Atom feed is read before the page it describes, and the feed's own conditional headers apply.</li>++<li><strong>An API</strong> — a JSON endpoint is read the same way, mapped by the watch itself.</li>++<li><strong>A page</strong> — the page's text is read, as the whole body or one element.</li>++<li><strong>An expensive browser check through OpenVibe.Run</strong> — the last resort, not built yet. A <code>run</code> watch is accepted but never fetched until it is.</li>++</ol>++<h2>The manners</h2>++<ul>++<li><strong>Per-site spacing.</strong> No two requests to one host are made at once, and none closer together than the host's minimum interval — whatever the watch says.</li>++<li><strong>Never faster than the cadence.</strong> A watch is due on the cadence you choose (the shortest here is 15 minutes). A failing site is checked less and less often (backoff), up to a cap.</li>++<li><strong>When the site asks you to wait.</strong> A <code>429</code> or <code>503</code> with <code>Retry-After</code> is obeyed: the watch is not checked again before that time, and the check is recorded <em>“the site asked us to wait”</em>.</li>++<li><strong>Conditional outcomes cost nothing.</strong> A <code>304</code> or a body identical to the last one records no observation — but the condition is still evaluated, so “this has been true for five minutes” can fire while nothing moved.</li>++</ul>++<h2>What a check state means</h2>++${table(['State', 'In plain words'], [++ ['ok', 'Read, and nothing changed since the last reading.'],++ ['not_modified', 'The site said “not modified” — no body was read.'],++ ['no_change', 'The body was read and was identical to last time.'],++ ['changed', 'The value changed.'],++ ['condition_met', 'The condition fired (and a notification was emitted).'],++ ['http_error', 'The site answered an error.'],++ ['timeout', 'The site did not answer in time.'],++ ['parse_error', 'The answer could not be read as what the watch expected.'],++ ['rate_limited', 'The site asked Watch to wait.'],++ ['budget_exceeded', "The watch's own budget was reached."],++ ['skipped', 'This kind of check is not run yet (or an AI reading is not supported yet). Nothing was fetched.'],++ ['disabled', 'The watch is paused, disabled or failing, so it was not checked.'],++ ].map(([state, words]) => [html`<code>${state}</code>`, words]))}++<h2>What is honest about it</h2>++<ul>++<li>A value the source did not state is <code>null</code>; Watch never invents one, and an absent value stays absent.</li>++<li>A failed check is a recorded state, never a silent skip; a failed check records no observation.</li>++<li>A site that is unavailable is retried with backoff, not hammered.</li>++<li>The kinds that arrive later (event, webhook, Run, Node) and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot.</li>++</ul>++<p><a href="/watches/new?template=page">Start a watch</a> · <a href="/">Back home</a></p>`,++ }));++++ r.get('/updates', (req, res) => page(req, res, {++ index: true, cache: cache.htmlHeaders({ maxAge: 3600 }),++ title: 'What shipped on OpenVibe.Watch',++ description: 'OpenVibe.Watch’s own changelog: what each release added.',++ body: html`<h1>What shipped on OpenVibe.Watch</h1>++<div class="changelog">${changelog.body()}</div>`,++ }));++++ // ── Crawl artifacts (robots, sitemap, llms.txt) ─────────────────────────────────────────++ r.use(createDiscoveryRoutes({ config }));++ return r;++}++++module.exports = { createPageRoutes, TEMPLATES, sameOrigin };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++/**++ * The public site's own write budgets, with the same numbers as the API's per-actor limits++ * (server/api/actor-limits.js): making or changing a watch is 30 a minute / 600 an hour, and a manual++ * check is 10 a minute / 120 an hour, counted per signed-in subject. The site renders a friendly page++ * when a budget is spent instead of the API's problem+json, but the numbers are one policy, stated++ * once here and in the README.++ *++ * Counters live in this process: a restart forgets them, exactly like the API's.++ */++const BUDGETS = {++ 'watch.write': { minute: 30, hour: 3600 },++ 'watch.check.run': { minute: 10, hour: 7200 },++};++++function createSiteLimits({ now = () => Date.now() } = {}) {++ const counters = new Map(); // `${name} ${actor}` -> { minute: [start, n], hour: [start, n] }++++ /** Count one action; { ok, retryAfter } — retryAfter in seconds when refused. */++ function take(name, actor) {++ const budget = BUDGETS[name];++ if (!budget || !actor) return { ok: true };++ const t = now();++ const key = `${name} ${actor}`;++ let c = counters.get(key);++ if (!c) { c = {}; counters.set(key, c); }++ for (const window of ['minute', 'hour']) {++ const span = window === 'minute' ? 60_000 : 3_600_000;++ const cell = c[window];++ if (!cell || t - cell[0] >= span) c[window] = [t, 1];++ else cell[1] += 1;++ if (c[window][1] > budget[window]) {++ return { ok: false, retryAfter: Math.max(1, Math.ceil((c[window][0] + span - t) / 1000)) };++ }++ }++ return { ok: true };++ }++++ return { take, BUDGETS };++}++++module.exports = { createSiteLimits, BUDGETS };@@@ -25,6 +25,9 @@ const { createOutbox, createRelay } = require('./events/outbox');const { jwksClient } = require('openvibe-sdk/auth');const { gracefulStop } = require('openvibe-sdk/service');const { createAuth } = require('./auth');++const { createSessions } = require('./sessions');++const { createKeyStore } = require('./auth/keys');++const { createSiteLimits } = require('./http/site-limits');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 } = {}) {@@@ -50,7 +53,12 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchconst 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 });++ // The public site: sessions in Watch's own database, the Network key to verify a sign-in token++ // against (the same shared JWKS client), and the site's write budgets.++ const sessions = createSessions({ db, now });++ const keyStore = createKeyStore({ config, fetchImpl, log });++ const siteLimits = createSiteLimits({ now: limitsNow || (() => Date.now()) });++ const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow, sessions, keyStore, siteLimits });// 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();@@@ -59,6 +67,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchif (config.worker.enabled) scheduler.start();const pruneTimer = setInterval(async () => {try { await observations.prune(); } catch (err) { log.error(`[observations] prune: ${err.message}`); }++ try { await sessions.prune(); } catch (err) { log.error(`[sessions] 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);@@@ -86,7 +95,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchif (!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 };++ return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, sessions, keyStore, siteLimits, app, server, close };}/**nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * The update log (/updates) renders Watch's own CHANGELOG.md. Only the few markdown shapes a++ * changelog uses are understood — headings, bullet lists and paragraphs, with **bold** and `code`++ * inline — and the text is escaped first, so nothing a changelog says can become markup.++ */++const fs = require('fs');++const path = require('path');++const { html, raw, esc } = require('./html');++++const FILE = path.join(__dirname, '..', '..', 'CHANGELOG.md');++let cache = null;++++function read() {++ if (cache) return cache;++ try { cache = fs.readFileSync(FILE, 'utf8'); } catch { cache = '# Changelog\n\nNothing has been recorded yet.\n'; }++ return cache;++}++++const inline = (s) => raw(String(s)++ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')++ .replace(/`([^`]+)`/g, '<code>$1</code>'));++++/** Render the changelog as escaped HTML: h2/h3, <ul><li>, <p>. */++function renderMarkdown(md) {++ const out = [];++ let list = null;++ const flush = () => { if (list) { out.push(`<ul>${list.join('')}</ul>`); list = null; } };++ for (const line of String(md).split(/\r?\n/)) {++ const t = line.trim();++ if (!t) { flush(); continue; }++ if (t.startsWith('### ')) { flush(); out.push(html`<h3>${inline(esc(t.slice(4)))}</h3>`); continue; }++ if (t.startsWith('## ')) { flush(); out.push(html`<h2>${inline(esc(t.slice(3)))}</h2>`); continue; }++ if (t.startsWith('# ')) { flush(); out.push(html`<h2>${inline(esc(t.slice(2)))}</h2>`); continue; }++ if (/^[-*] /.test(t)) { list = list || []; list.push(html`<li>${inline(esc(t.slice(2)))}</li>`); continue; }++ flush();++ out.push(html`<p>${inline(esc(t))}</p>`);++ }++ flush();++ return html`${out}`;++}++++const body = () => renderMarkdown(read());++++module.exports = { body, renderMarkdown };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++/** A <table> from headers and rows of cells (cells are already html or plain values). */++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */++function problemBox(p, { title = 'The request failed' } = {}) {++ if (!p) return '';++ return html`<div class="problem" role="alert"><strong>${title}</strong>++<p>${p.detail || 'No detail was given.'}</p>++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without++ * JavaScript: forms POST, links navigate, and the signed-out pages say what they mean:++ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise),++ * Open Graph/Twitter, JSON-LD (openvibe-shared/seo), the shared app icon and the stylesheet++ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the++ * navbar, the footer is initialised below), a <noscript> account bar and the rendered footer++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const ovServe = require('openvibe-shared/serve');++const appIcon = require('openvibe-shared/app-icon');++const frame = require('openvibe-shared/frame');++const shell = require('openvibe-shared/shell');++const cache = require('openvibe-shared/cache-policy');++const { html, raw, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Watch';++const TAGLINE = 'Get told when a page, a feed or an API changes.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++// The product's navigation: one entry per public page it serves.++const NAV = [++ { label: 'Your watches', href: '/watches' },++ { label: 'How it checks', href: '/how-it-works' },++ { label: 'What shipped', href: '/updates' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++// The deployed release (server/app.js sets it): openvibe-shared/boost swaps a page in place only++// between pages of the same release, and does a normal load across a deploy.++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++/** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], crumbs, jsonLd */++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const loginNext = encodeURIComponent(pathNow);++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const nav = {++ service: 'watch',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ history: { type: 'page', title: o.title || SITE_NAME },++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ const footer = { service: 'watch', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };++ const account = signedIn++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'watch' })}++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++<link rel="stylesheet" href="${asset('css/app.css')}">++${render(scripts)}++<meta name="ov-boost" content="watch@${RELEASE}">++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>++<main id="main" class="page">++${render(crumbs)}++${render(o.body)}++${o.path === '/' ? frame.shipped({ service: 'watch', title: `Recently shipped on ${SITE_NAME}` }) : ''}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++function render(v) { return require('./html').render(v); }++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = (o.viewer && o.viewer.kind === 'user') || o.private;++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV, raw, html };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * A watch in words. Every page that shows a watch, an extraction, a condition or a check state says++ * what it means in plain sentences instead of printing the JSON the contract carries — the README's++ * honesty rule applied to the site: the words are derived from the stored watch and the recorded++ * state, never a guess, and everything goes through the escaping `html` tag.++ */++const { html } = require('./html');++++const CADENCES = [++ { sec: 900, label: 'every 15 minutes' },++ { sec: 3600, label: 'every hour' },++ { sec: 21600, label: 'every 6 hours' },++ { sec: 86400, label: 'every day' },++];++/** The cadences the form offers a person; the contract's floor is 30 s, the site's floor is 15 min. */++const CADENCE_OPTIONS = CADENCES.map((c) => c.sec);++const MIN_CADENCE_SEC = 900;++++function everyWords(sec) {++ const n = Number(sec);++ if (!Number.isFinite(n) || n <= 0) return 'on no cadence';++ const known = CADENCES.find((c) => c.sec === n);++ if (known) return known.label;++ if (n % 86400 === 0) return `every ${n / 86400} day${n / 86400 === 1 ? '' : 's'}`;++ if (n % 3600 === 0) return `every ${n / 3600} hour${n / 3600 === 1 ? '' : 's'}`;++ if (n % 60 === 0) return `every ${n / 60} minute${n / 60 === 1 ? '' : 's'}`;++ return `every ${n} seconds`;++}++++const host = (url) => { try { return new URL(url).host; } catch { return url ? String(url) : 'an unnamed source'; } };++const short = (s, n = 80) => { const t = String(s == null ? '' : s); return t.length <= n ? t : `${t.slice(0, n - 1)}…`; };++++/** The extracted value, as text, shortened. Objects are shown as their JSON, escaped. */++function valueWords(value, max = 90) {++ if (value === undefined || value === null) return html`<span class="muted">nothing stated</span>`;++ const text = typeof value === 'string' ? value : JSON.stringify(value);++ return html`<code title="${String(text).slice(0, 200)}">${short(text, max)}</code>`;++}++++function extractionWords(extraction) {++ const e = extraction || {};++ if (e.kind === 'text') return 'the page text';++ if (e.kind === 'html' || e.kind === 'css') return e.selector ? html`the text of <code>${e.selector}</code>` : 'the page text';++ if (e.kind === 'regex') return html`the first match of <code>${e.selector}</code>`;++ if (e.kind === 'json' || e.kind === 'jsonpath') return e.value_path ? html`the value at <code>${e.value_path}</code>` : 'the value';++ if (e.kind === 'ai') return 'an AI reading (not supported yet)';++ return 'the value';++}++++function conditionWords(condition, { forSec = true } = {}) {++ const c = condition || {};++ const v = c.value;++ let core;++ switch (c.op) {++ case 'changed': core = 'changes'; break;++ case 'gt': core = html`goes above <b>${v}</b>`; break;++ case 'gte': core = html`reaches <b>${v}</b> or more`; break;++ case 'lt': core = html`drops below <b>${v}</b>`; break;++ case 'lte': core = html`is <b>${v}</b> or less`; break;++ case 'eq': core = html`equals <b>${v}</b>`; break;++ case 'ne': core = html`is not <b>${v}</b>`; break;++ case 'contains': core = html`contains “${v}”`; break;++ case 'matches': core = html`matches <code>${v}</code>`; break;++ case 'exists': core = 'is present'; break;++ case 'absent': core = 'is absent'; break;++ default: core = 'meets its condition';++ }++ if (forSec && Number.isInteger(c.for_sec) && c.for_sec > 0) {++ const sec = c.for_sec;++ const dur = sec % 3600 === 0 ? `${sec / 3600} hour${sec / 3600 === 1 ? '' : 's'}` : sec % 60 === 0 ? `${sec / 60} minute${sec / 60 === 1 ? '' : 's'}` : `${sec} seconds`;++ return html`${core} and stays that way for ${dur}`;++ }++ return core;++}++++/** The watch as one sentence: "Tell me when … on …, checked every …". */++function inWords(watch) {++ const source = watch.source || {};++ const what = source.kind === 'feed'++ ? (watch.extraction && watch.extraction.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed')++ : html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;++ const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';++ return html`Tell me when ${what} ${conditionWords(watch.condition)}${every}.`;++}++++/** The plain words for one check state; `run` supplies the HTTP status a failure answered with. */++function stateWords(state, run = null) {++ switch (state) {++ case 'ok': return 'Read, nothing new';++ case 'not_modified': return 'No change (the site said so)';++ case 'no_change': return 'No change';++ case 'changed': return 'Changed';++ case 'condition_met': return 'Condition met';++ case 'http_error': return run && run.http_status ? `The site answered ${run.http_status}` : 'The site answered an error';++ case 'timeout': return 'The site did not answer in time';++ case 'parse_error': return 'The answer could not be read';++ case 'rate_limited': return 'The site asked us to wait';++ case 'budget_exceeded': return "The watch's budget was reached";++ case 'skipped': return 'This kind of check is not run yet';++ case 'disabled': return 'The watch is not active';++ default: return state ? String(state) : 'No state recorded';++ }++}++++/** How a check state is pill-coloured. */++function stateKind(state) {++ if (state === 'changed' || state === 'condition_met') return 'ok';++ if (state === 'http_error' || state === 'timeout' || state === 'parse_error' || state === 'rate_limited' || state === 'budget_exceeded') return 'bad';++ if (state === 'skipped' || state === 'disabled') return 'warn';++ return '';++}++++const STATUS_WORDS = { active: 'Active', paused: 'Paused', disabled: 'Disabled', failed: 'Failing' };++const statusWords = (status) => STATUS_WORDS[status] || String(status || 'unknown');++const statusKind = (status) => (status === 'active' ? 'ok' : status === 'paused' ? 'warn' : status === 'failed' ? 'bad' : '');++++module.exports = {++ inWords, conditionWords, extractionWords, valueWords, everyWords, stateWords, stateKind,++ statusWords, statusKind, host, short, CADENCES, CADENCE_OPTIONS, MIN_CADENCE_SEC,++};nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * Sign-in sessions for the public site, kept in Watch's own database (migration 0002).++ *++ * The browser holds an opaque random token in an httpOnly cookie (server/auth/sso.js sets it); this++ * module stores only sha256(token), so a database read never hands out a live session. The row names++ * the canonical subject (usr_…/agt_…) the watches belong to — the same authority the API scopes++ * ownership by. The person's Network access token is never stored: it is verified once at sign-in and++ * discarded, because the site acts on the registry in-process, not as the person over Network.++ */++const crypto = require('crypto');++++const DEFAULT_TTL_MS = 30 * 24 * 3600 * 1000;++const TOUCH_INTERVAL_MS = 60 * 1000; // at most one last_seen_at write a minute per session++++const hash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');++++function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS }) {++ const st = {++ insert: db.prepare(`INSERT INTO web_sessions (id, subject, username, display_name, role, created_at, last_seen_at, expires_at, revoked_at)++ VALUES (@id, @subject, @username, @display_name, @role, @created_at, @last_seen_at, @expires_at, NULL)`),++ get: db.prepare('SELECT * FROM web_sessions WHERE id = ?'),++ touch: db.prepare('UPDATE web_sessions SET last_seen_at = ? WHERE id = ?'),++ revoke: db.prepare('UPDATE web_sessions SET revoked_at = ? WHERE id = ?'),++ prune: db.prepare('DELETE FROM web_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL'),++ };++++ /** A fresh opaque token; only its hash is stored. */++ const newToken = () => crypto.randomBytes(32).toString('base64url');++++ /**++ * Create a session for a verified person. Returns the cookie token (shown once, never stored).++ * The subject is required: a session with nobody's subject owns nothing and is refused.++ */++ async function create({ subject, username = null, displayName = null, role = null }) {++ if (typeof subject !== 'string' || !subject) throw new Error('a session needs a subject');++ const token = newToken();++ const t = now();++ await st.insert.run({++ id: hash(token), subject,++ username: username == null ? null : String(username).slice(0, 64),++ display_name: displayName == null ? null : String(displayName).slice(0, 80),++ role: role == null ? null : String(role).slice(0, 32),++ created_at: t, last_seen_at: t, expires_at: t + ttlMs,++ });++ return token;++ }++++ /**++ * The session a cookie token names, or null when it is unknown, expired or revoked. A live++ * session past its touch interval has last_seen_at moved (best effort: a failed write never++ * refuses a request).++ */++ async function resolve(token) {++ if (!token || typeof token !== 'string') return null;++ const row = await st.get.get(hash(token));++ if (!row) return null;++ if (row.revoked_at != null) return null;++ const t = now();++ if (Number(row.expires_at) <= t) return null;++ if (t - Number(row.last_seen_at) >= TOUCH_INTERVAL_MS) {++ try { await st.touch.run(t, row.id); } catch { /* the session is still valid */ }++ }++ return row;++ }++++ /** End one session (sign-out): the cookie token names it. */++ async function revoke(token) {++ if (!token) return false;++ const out = await st.revoke.run(now(), hash(token));++ return out.changes > 0;++ }++++ /** Drop expired and revoked rows (called hourly from server/index.js). */++ async function prune(at = now()) {++ return (await st.prune.run(at)).changes;++ }++++ /** The viewer shape the pages read (never includes the token). */++ function viewer(row) {++ if (!row) return { kind: 'anonymous' };++ return {++ kind: 'user',++ subject: row.subject,++ username: row.username || null,++ displayName: row.display_name || row.username || 'you',++ role: row.role || 'user',++ };++ }++++ return { create, resolve, revoke, prune, viewer, hash, DEFAULT_TTL_MS };++}++++module.exports = { createSessions, DEFAULT_TTL_MS };@@@ -9,6 +9,7 @@ const nodeHttp = require('http');const { serviceAuth } = require('openvibe-contracts');const { load } = require('../server/config');const { start } = require('../server/index');++const { startNetwork } = require('./helpers/network');const ISSUER = 'https://openvibe.network';const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {@@@ -52,10 +53,21 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo// 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] }) }) });++ // A stand-in Network for the site's sign-in (token endpoint + PKCE), signing with the same key the++ // JWKS above serves. OV_NETWORK_ISSUER keeps the API's expected issuer while OV_NETWORK_URL points++ // the authorize/token endpoints here, so existing service/user tokens still verify unchanged.++ const network = await startNetwork({ privateKey, issuer: ISSUER });const config = load({NODE_ENV: 'test',PORT: '0',++ BASE_URL: 'https://openvibe.watch',++ COOKIE_SECURE: 'false',++ OV_NETWORK_URL: network.url,++ OV_NETWORK_INTERNAL_URL: network.url,++ OV_NETWORK_ISSUER: ISSUER,OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,++ OV_OAUTH_CLIENT_ID: 'watch',++ OV_OAUTH_CLIENT_SECRET: 'watch-secret',WATCH_WORKER: worker,WATCH_ALLOW_PRIVATE_HOSTS: allowPrivate ? '127.0.0.1' : '',WATCH_HOST_MIN_INTERVAL_MS: '0',@@@ -67,7 +79,55 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, loconst 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(); } };++++ /** A session cookie for a person, as /auth/callback would have set it. */++ async function signIn(user) {++ const token = await h.sessions.create({++ subject: user.subject, username: user.username || null,++ displayName: user.display_name || user.username || null, role: user.role || null,++ });++ return `watch_session=${token}`;++ }++++ /**++ * The whole browser sign-in, over HTTP: /auth/login → Network /oauth/authorize → /auth/callback,++ * with the stand-in Network issuing the code against the PKCE challenge the site generated.++ */++ async function login(user, { next = '/watches' } = {}) {++ const start = await get(`/auth/login?next=${encodeURIComponent(next)}`);++ const loc = new URL(start.headers.get('location'));++ const flow = (start.headers.getSetCookie ? start.headers.getSetCookie() : [start.headers.get('set-cookie')])++ .map((c) => c && c.split(';')[0]).find((c) => c && c.startsWith('watch_oauth='));++ const code = network.issueCode(user, loc.searchParams.get('code_challenge'));++ const cb = await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flow });++ const cookies = cb.headers.getSetCookie ? cb.headers.getSetCookie() : [cb.headers.get('set-cookie')];++ const session = cookies.map((c) => c && c.split(';')[0]).find((c) => c && c.startsWith('watch_session='));++ return { status: cb.status, location: cb.headers.get('location'), cookie: session, start };++ }++++ /** The HTTP client every site test uses: a page or a form, as a person (or nobody). */++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) {++ const c = await signIn(o.as);++ headers.cookie = headers.cookie ? `${c}; ${headers.cookie}` : c;++ }++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body !== undefined ? 'POST' : 'GET');++ // A form write comes from this site unless the test says otherwise (the cross-site case).++ if (method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers.Origin) headers.origin = o.origin || base;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, location: res.headers.get('location'), text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ return { ...h, base, jwksSite, network, signIn, login, get, async stop() { await h.close(); await testdb.close(); await jwksSite.close(); await network.close(); } };}async function request(base, method, p, { token, body, headers = {} } = {}) {nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * A stand-in for the half of OpenVibe.Network the site's sign-in uses: the authorization-code token++ * endpoint (client `watch`, PKCE S256), revocation, and users with canonical subjects. It signs its++ * session tokens with the same key test/helpers.js already serves as the JWKS, so a token minted++ * here verifies offline exactly as a real one would.++ *++ * const net = await startNetwork({ privateKey, issuer });++ * const u = net.addUser('ada');++ * const challenge = …; // the S256 challenge the browser sent to /oauth/authorize++ * const code = net.issueCode(u, challenge);++ *++ * It never touches the internet and knows nothing about watches.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++++const b64url = (buf) => Buffer.from(buf).toString('base64url');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++async function startNetwork({ privateKey, issuer = 'https://openvibe.network', clientId = 'watch', clientSecret = 'watch-secret' } = {}) {++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), requests: [], tokens: [] };++ let nextId = 1;++++ function addUser(username, { role = 'user' } = {}) {++ const u = { id: nextId++, subject: ids.newId('user'), username, display_name: username, role };++ st.users.set(u.id, u); st.byUsername.set(username, u);++ return u;++ }++ function userToken(u, { ttl = 3600 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ const token = serviceAuth.signServiceToken({++ iss: issuer, sub: String(u.id), subject_id: u.subject, username: u.username, display_name: u.display_name,++ role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, privateKey);++ st.tokens.push(token);++ return token;++ }++++ const problem = (json, status, error, description) => json(status, { error, error_description: description });++++ const srv = await listen(async (req, raw, json, res) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, query: url.search });++ if (url.pathname === '/oauth/authorize') {++ // A test that wants the browser half can follow this: the Network signs a person in and++ // sends them back with a code bound to the PKCE challenge they sent.++ const user = st.byUsername.get(url.searchParams.get('user') || '') || [...st.users.values()][0];++ const redirect = url.searchParams.get('redirect_uri');++ const challenge = url.searchParams.get('code_challenge') || '';++ if (!user || !redirect || url.searchParams.get('response_type') !== 'code') return problem(json, 400, 'invalid_request', 'nothing to authorize');++ const code = st.issueCode(user, challenge);++ const back = new URL(redirect);++ back.searchParams.set('code', code);++ if (url.searchParams.get('state')) back.searchParams.set('state', url.searchParams.get('state'));++ res.writeHead(302, { Location: back.toString() });++ return res.end();++ }++ if (url.pathname === '/oauth/revoke') return json(200, {});++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const body = Object.fromEntries(new URLSearchParams(raw.toString('utf8')));++ if (body.client_id !== clientId || body.client_secret !== clientSecret) return problem(json, 401, 'invalid_client', 'bad client credentials');++ if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);++ const c = st.codes.get(body.code);++ if (!c) return problem(json, 400, 'invalid_grant', 'unknown code');++ st.codes.delete(body.code);++ const challenge = b64url(crypto.createHash('sha256').update(String(body.code_verifier || '')).digest());++ if (challenge !== c.challenge) return problem(json, 400, 'invalid_grant', 'PKCE verification failed');++ return json(200, { access_token: userToken(c.user), refresh_token: `rt_${crypto.randomBytes(8).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });++ }++ return json(404, { error: 'not found' });++ });++++ function issueCode(user, challenge) {++ const code = `code_${crypto.randomBytes(8).toString('hex')}`;++ st.codes.set(code, { user, challenge });++ return code;++ }++++ return { url: srv.url, issuer, state: st, requests: st.requests, addUser, userToken, issueCode, close: srv.close };++}++++module.exports = { startNetwork };nnew file mode 100644@@@ -0,0 +1,356 @@++'use strict';++/**++ * The public site (plan T18 step 8): sign-in with the stand-in Network, the signed-out pages, the++ * four create templates against what the API would store, validation errors that keep the input,++ * ownership 404 on every page and form, pause/resume/delete/check-now, cross-site refusal, and++ * hostile names/values escaped everywhere.++ */++const assert = require('assert');++const { boot, request, site, suite, userToken } = require('./helpers');++++const t = suite('site');++let svc;++let web;++let me;++let other;++let ME;++let OTHER;++let session; // the cookie from a real sign-in++++const create = (fields) => svc.get('/watches/new', { cookie: session, form: fields });++const indexOf = (res) => (String(res.location || '').match(/\/watches\/(wch_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1] || null;++++t('boot', async () => {++ web = await site({ '/price': () => ({ body: '<html><body><span class="price">19.99</span></body></html>' }) });++ svc = await boot();++ me = svc.network.addUser('ada');++ other = svc.network.addUser('grace');++ ME = userToken(me.subject);++ OTHER = userToken(other.subject);++});++++t('the signed-out pages say what Watch is and ask for a sign-in', async () => {++ const home = await svc.get('/', { headers: { accept: 'text/html' } });++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /OpenVibe\.Watch/);++ assert.match(home.text, /template=page/);++ assert.match(home.text, /template=price/);++ assert.match(home.text, /template=feed/);++ assert.match(home.text, /template=json/);++ assert.match(home.text, /Sign in with OpenVibe/);++ assert.strictEqual(home.headers.get('content-type').split(';')[0], 'text/html');++++ const watches = await svc.get('/watches');++ assert.strictEqual(watches.status, 200);++ assert.match(watches.text, /Sign in with OpenVibe/);++++ const redirect = await svc.get('/watches/new');++ assert.strictEqual(redirect.status, 303);++ assert.match(redirect.headers.get('location'), /^\/auth\/login\?next=/);++++ const one = await svc.get('/watches/wch_01J0000000000000000000000');++ assert.strictEqual(one.status, 303, 'a signed-out watch page asks for a sign-in');++++ const how = await svc.get('/how-it-works');++ assert.strictEqual(how.status, 200);++ assert.match(how.text, /If-None-Match/);++ assert.match(how.text, /15 minutes/);++});++++t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {++ const who = svc.network.addUser('ada'); // same username, distinct subject for this flow test++ const out = await svc.login(who);++ assert.strictEqual(out.status, 302);++ assert.strictEqual(out.location, '/watches');++ assert.ok(out.cookie && out.cookie.startsWith('watch_session='), 'a session cookie is set');++++ // /auth/me answers the signed-in person's identity and nothing more.++ const meRes = await svc.get('/auth/me', { cookie: out.cookie });++ assert.strictEqual(meRes.status, 200);++ assert.strictEqual(meRes.json().user.subject_id, who.subject);++ assert.strictEqual(meRes.json().user.username, 'ada');++++ // A garbage session cookie is a guest, not an error.++ const guest = await svc.get('/auth/me', { cookie: 'watch_session=nonsense' });++ assert.strictEqual(guest.status, 401);++++ // The token exchange really ran, with the PKCE verifier, against the stand-in Network.++ assert.ok(svc.network.requests.some((r) => r.path === '/oauth/token'));++++ // Sign-out revokes the session: the same cookie no longer reads a person.++ const bye = await svc.get('/auth/logout', { cookie: out.cookie, method: 'POST', form: {} });++ assert.strictEqual(bye.status, 303);++ const after = await svc.get('/watches', { cookie: out.cookie });++ assert.match(after.text, /Sign in with OpenVibe/);++++ // The state must match the browser that started: a callback with the wrong state is refused.++ const started = await svc.get('/auth/login?next=%2Fwatches');++ const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));++ const bad = await svc.get('/auth/callback?code=code_x&state=not-the-state', { cookie: flow });++ assert.strictEqual(bad.status, 400);++});++++t('a person signs in for the rest of the tests', async () => {++ session = await svc.signIn(me);++ const mine = await svc.get('/watches', { cookie: session });++ assert.strictEqual(mine.status, 200);++ assert.match(mine.text, /no watches yet/i);++});++++t('each template creates exactly what the API would store, and the API reads it back', async () => {++ const cases = [++ ['page', { template: 'page', name: 'Home page', url: `${web.origin}/price`, selector: '.price', cadence: '3600', notify: 'on' }, {++ name: 'Home page', source: { kind: 'http', url: `${web.origin}/price`, format: null },++ cadence: { every_sec: 3600, jitter_sec: 0 }, extraction: { kind: 'html', selector: '.price' },++ condition: { op: 'changed' }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ }],++ ['price', { template: 'price', name: 'Laptop price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900', notify: 'on' }, {++ name: 'Laptop price', source: { kind: 'http', url: `${web.origin}/price`, format: null },++ cadence: { every_sec: 900, jitter_sec: 0 }, extraction: { kind: 'css', selector: '.price' },++ condition: { op: 'lt', value: 20 }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ }],++ ['feed', { template: 'feed', name: 'Releases', url: 'https://example.org/releases.atom', cadence: '21600', notify: 'on' }, {++ name: 'Releases', source: { kind: 'feed', url: 'https://example.org/releases.atom', format: null },++ cadence: { every_sec: 21600, jitter_sec: 0 }, extraction: { kind: 'json', value_path: 'latest' },++ condition: { op: 'changed' }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ }],++ ['json', { template: 'json', name: 'Stock', url: 'https://api.example.org/stock', value_path: 'data.count', op: 'gt', value: '0', cadence: '86400', notify: 'on' }, {++ name: 'Stock', source: { kind: 'api', url: 'https://api.example.org/stock', format: null },++ cadence: { every_sec: 86400, jitter_sec: 0 }, extraction: { kind: 'json', value_path: 'data.count' },++ condition: { op: 'gt', value: 0 }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ }],++ ];++ for (const [label, form, expected] of cases) {++ const created = await create(form);++ assert.strictEqual(created.status, 303, `${label}: ${created.text}`);++ const id = indexOf(created);++ assert.ok(id, `${label}: a watch id came back`);++ // The API, as the same person, reads it back and sees exactly the request the form meant.++ const read = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME });++ assert.strictEqual(read.status, 200, `${label}: ${read.text}`);++ for (const [k, v] of Object.entries(expected)) assert.deepStrictEqual(read.body.watch[k], v, `${label}.${k}`);++ assert.strictEqual(read.body.watch.owner, me.subject);++ }++});++++t('notify me off stores an event action instead of a notification, and says so', async () => {++ const created = await create({ template: 'page', name: 'Quiet', url: 'https://example.org/quiet', cadence: '3600' });++ assert.strictEqual(created.status, 303);++ const read = await request(svc.base, 'GET', `/api/v1/watches/${indexOf(created)}`, { token: ME });++ assert.deepStrictEqual(read.body.watch.action, [{ kind: 'event', event_type: 'watch.watch.triggered' }]);++});++++t('validation errors re-render the form with the registry\'s message and the person\'s input', async () => {++ const badUrl = await create({ template: 'page', name: 'Keep me', url: 'not-a-url', selector: '.x', cadence: '3600' });++ assert.strictEqual(badUrl.status, 422);++ assert.match(badUrl.text, /must start with http/);++ assert.match(badUrl.text, /value="Keep me"/, 'the name is kept');++ assert.match(badUrl.text, /value="\.x"/, 'the selector is kept');++++ const badCadence = await create({ template: 'page', url: 'https://example.org/a', cadence: '60' });++ assert.strictEqual(badCadence.status, 422);++ assert.match(badCadence.text, /shortest cadence here is 15 minutes/);++++ // A url the schema refuses (the registry's own message, mapped to the field).++ const refused = await create({ template: 'json', url: 'ftp://example.org/x', cadence: '3600', op: 'gt', value: '1', value_path: 'n' });++ assert.strictEqual(refused.status, 422);++ assert.match(refused.text, /must start with http|url/);++++ // A condition that needs a value, and one outside the template's ops.++ const noValue = await create({ template: 'price', url: 'https://example.org/p', extraction: 'css', selector: '.p', op: 'lt', value: '' });++ assert.strictEqual(noValue.status, 422);++ assert.match(noValue.text, /needs a value/);++});++++t('another person\'s watch is 404 on every page and form', async () => {++ const created = await create({ template: 'page', name: 'Mine', url: 'https://example.org/mine', cadence: '3600' });++ const id = indexOf(created);++ for (const [method, path] of [['GET', ''], ['GET', '/edit'], ['POST', '/edit'], ['POST', '/pause'], ['POST', '/resume'], ['POST', '/check'], ['POST', '/delete']]) {++ const res = await svc.get(`/watches/${id}${path}`, { as: other, method, form: method === 'POST' ? {} : undefined });++ assert.strictEqual(res.status, 404, `${method} ${path}`);++ assert.match(res.text, /Watch not found/);++ }++ // The owner still sees it.++ const mine = await svc.get(`/watches/${id}`, { cookie: session });++ assert.strictEqual(mine.status, 200);++ assert.match(mine.text, /Mine/);++});++++t('pause, resume, check now (against a stub site), and delete', async () => {++ const created = await create({ template: 'price', name: 'Stub price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });++ const id = indexOf(created);++++ const paused = await svc.get(`/watches/${id}/pause`, { cookie: session, form: {} });++ assert.strictEqual(paused.status, 303);++ let view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;++ assert.strictEqual(view.status, 'paused');++++ // check-now of a paused watch is the API's 409, said in words.++ const refused = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });++ assert.strictEqual(refused.status, 409);++ assert.match(refused.text, /is paused, so it is not checked/);++++ const resumed = await svc.get(`/watches/${id}/resume`, { cookie: session, form: {} });++ assert.strictEqual(resumed.status, 303);++++ const ran = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });++ assert.strictEqual(ran.status, 303);++ assert.match(ran.headers.get('location'), /ran=condition_met/, '19.99 is below 20: the condition fired');++ assert.strictEqual(web.hits('/price').length, 1);++++ const detail = await svc.get(`/watches/${id}`, { cookie: session });++ assert.match(detail.text, /19\.99/, 'the observation history shows the value');++ assert.match(detail.text, /Condition met/);++ assert.match(detail.text, /Observation history/);++++ const gone = await svc.get(`/watches/${id}/delete`, { cookie: session, form: {} });++ assert.strictEqual(gone.status, 303);++ assert.strictEqual((await svc.get(`/watches/${id}`, { cookie: session })).status, 404);++});++++t('edit is the same form with PATCH semantics, and the home summary counts the person\'s watches', async () => {++ const created = await create({ template: 'price', name: 'Before', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });++ const id = indexOf(created);++++ const form = await svc.get(`/watches/${id}/edit`, { cookie: session });++ assert.strictEqual(form.status, 200);++ assert.match(form.text, /value="Before"/);++ assert.match(form.text, /value="20"/);++++ const saved = await svc.get(`/watches/${id}/edit`, {++ cookie: session,++ form: { template: 'price', name: 'After', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '15', cadence: '21600', notify: 'on' },++ });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.strictEqual(saved.headers.get('location'), `/watches/${id}`);++ const view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;++ assert.strictEqual(view.name, 'After');++ assert.deepStrictEqual(view.condition, { op: 'lt', value: 15 });++ assert.strictEqual(view.cadence.every_sec, 21600);++++ // An invalid edit keeps the input and changes nothing.++ const bad = await svc.get(`/watches/${id}/edit`, {++ cookie: session, form: { template: 'price', name: 'Broken', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '', cadence: '900' },++ });++ assert.strictEqual(bad.status, 422);++ assert.match(bad.text, /needs a value/);++ assert.match(bad.text, /value="Broken"/);++ const unchanged = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;++ assert.strictEqual(unchanged.name, 'After');++++ const home = await svc.get('/', { cookie: session, headers: { accept: 'text/html' } });++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /Your watches/);++ assert.match(home.text, /You have <strong>\d+<\/strong> watch/);++});++++t('the watches list pages with before, newest first', async () => {++ // Enough watches to fill a page (PAGE = 25), made through the API so the site's write budget is++ // not spent. The pager is the last id of the page, since a wch_ id is a ULID (creation order).++ for (let i = 0; i < 26; i++) {++ const made = await request(svc.base, 'POST', '/api/v1/watches', { token: ME, body: {++ name: `Page ${i}`,++ source: { kind: 'http', url: `https://example.org/page/${i}`, format: null },++ cadence: { every_sec: 86400, jitter_sec: 0 },++ extraction: { kind: 'text' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ } });++ assert.strictEqual(made.status, 201, made.text);++ }++ const first = await svc.get('/watches', { cookie: session });++ assert.strictEqual(first.status, 200);++ assert.match(first.text, /Older watches/);++ const before = (first.text.match(/\/watches\?before=(wch_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1];++ assert.ok(before, 'the pager carries the last id of the page');++ const second = await svc.get(`/watches?before=${before}`, { cookie: session });++ assert.strictEqual(second.status, 200);++ assert.match(second.text, /Newest first/);++ // The union of the two pages lists no id twice, and neither lists the cursor's watch again.++ const ids = (text) => [...text.matchAll(/\/watches\/(wch_[0-9A-HJKMNP-TV-Z]{26})"/g)].map((m) => m[1]);++ const listed = [...ids(first.text), ...ids(second.text)];++ assert.strictEqual(new Set(listed).size, listed.length, 'no watch appears twice');++ assert.ok(!ids(second.text).includes(before), 'the second page starts after the cursor');++ assert.strictEqual(ids(first.text).length, 25, 'a full first page');++});++++t('a kind whose carrier is not built is never fetched: check-now is 422 with the reason', async () => {++ const body = {++ name: 'Event watch',++ source: { kind: 'event', pattern: 'host.release.published', filter: null },++ cadence: null,++ extraction: { kind: 'json', value_path: '$.release' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ };++ const made = await request(svc.base, 'POST', '/api/v1/watches', { token: ME, body });++ assert.strictEqual(made.status, 201, made.text);++ const res = await svc.get(`/watches/${made.body.watch.id}/check`, { cookie: session, form: {} });++ assert.strictEqual(res.status, 422);++ assert.match(res.text, /not checked yet|carrier/i);++});++++t('check-now is limited per person, with a friendly 429 and Retry-After', async () => {++ const u = svc.network.addUser('hurried');++ const cookie = await svc.signIn(u);++ const made = await svc.get('/watches/new', { cookie, form: { template: 'page', name: 'Hurried', url: `${web.origin}/price`, cadence: '3600' } });++ const id = indexOf(made);++ let refused = null;++ for (let i = 0; i < 12 && !refused; i++) {++ const res = await svc.get(`/watches/${id}/check`, { cookie, form: {} });++ if (res.status === 429) refused = res;++ }++ assert.ok(refused, 'the 11th manual check in a minute is refused');++ assert.match(refused.text, /Slow down/);++ assert.ok(Number(refused.headers.get('retry-after')) > 0);++});++++t('a signed-in write from another site is refused', async () => {++ const before = (await request(svc.base, 'GET', '/api/v1/watches', { token: ME })).body.watches.length;++ const res = await svc.get('/watches/new', { cookie: session, form: { template: 'page', url: 'https://example.org/x', cadence: '3600' }, origin: 'https://evil.example' });++ assert.strictEqual(res.status, 403);++ assert.match(res.text, /Cross-site request refused/);++ const after = (await request(svc.base, 'GET', '/api/v1/watches', { token: ME })).body.watches.length;++ assert.strictEqual(after, before, 'nothing was created');++++ const made = await create({ template: 'page', url: 'https://example.org/owned', cadence: '3600' });++ const id = indexOf(made);++ for (const path of ['/pause', '/resume', '/delete', '/check']) {++ const r = await svc.get(`/watches/${id}${path}`, { cookie: session, form: {}, origin: 'https://evil.example' });++ assert.strictEqual(r.status, 403, path);++ }++});++++t('hostile names, values and details are escaped on every page that shows them', async () => {++ const hostile = '<script>alert(1)</script>" onmouseover="alert(2)';++ const created = await create({ template: 'json', name: hostile, url: 'https://api.example.org/x', value_path: 'a', op: 'contains', value: '<img src=x onerror=alert(3)>', cadence: '3600' });++ assert.strictEqual(created.status, 303);++ const id = indexOf(created);++ for (const url of ['/watches', `/watches/${id}`, `/watches/${id}/edit`]) {++ const res = await svc.get(url, { cookie: session });++ assert.strictEqual(res.status, 200, url);++ assert.ok(!res.text.includes('<script>alert(1)</script>'), `${url}: the name is not raw script`);++ assert.ok(!res.text.includes('<img src=x onerror'), `${url}: the value is not a raw image tag`);++ assert.match(res.text, /<script>alert\(1\)<\/script>/, `${url}: the name is escaped`);++ }++ assert.ok(!(await svc.get('/watches', { cookie: session })).text.includes('onmouseover="alert(2)"'));++});++++t('discovery files describe the site and keep the person pages out of the sitemap', async () => {++ const robots = await svc.get('/robots.txt');++ assert.strictEqual(robots.status, 200);++ assert.match(robots.text, /Disallow: \/watches/);++ assert.match(robots.text, /Disallow: \/auth/);++ const sitemap = await svc.get('/sitemap.xml');++ assert.strictEqual(sitemap.status, 200);++ assert.match(sitemap.text, /<loc>[^<]*\/<\/loc>/);++ assert.match(sitemap.text, /how-it-works/);++ assert.ok(!/watches/.test(sitemap.text), 'no watches in the sitemap');++ const llms = await svc.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.match(llms.text, /OpenVibe\.Watch/);++});++++t('done', async () => { await web.close(); await svc.stop(); });++++t.run();
@@ -10,6 +10,8 @@ PORT=4730
@@ -10,6 +10,8 @@ PORT=4730
HOST=127.0.0.1
HOST=127.0.0.1
NODE_ENV=development
NODE_ENV=development
#BASE_URL=https://openvibe.watch
#BASE_URL=https://openvibe.watch
+# The public site sits behind nginx (and Cloudflare): trust one proxy hop for the real client address.
+#TRUST_PROXY=1
# Identity — OpenVibe.Network signs the tokens this service verifies. Service tokens (svc:…) carry
# 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
# capability grants (audience openvibe.watch); a person's bearer token (usr_…) is verified for the
@@ -27,10 +29,21 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -27,10 +29,21 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#WATCH_LIMITS_MINUTE=120
#WATCH_LIMITS_MINUTE=120
#WATCH_LIMITS_HOUR=3000
#WATCH_LIMITS_HOUR=3000
-# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish).
+# This service's own principal (svc:watch) — publishes watch.* events (events.event.publish) — and
+# the public site's sign-in client. Keep OV_OAUTH_CLIENT_ID=watch and set the secret in
+# /etc/openvibe/watch.env (0600): the site exchanges a person's sign-in code for a Network token with it.
OV_OAUTH_CLIENT_ID=watch
OV_OAUTH_CLIENT_ID=watch
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
+# The site's sign-in callback (default BASE_URL + /auth/callback; register this exact URI on the
+# Network client), and the scope and session audience a person's Network token is verified for.
+#OV_OAUTH_REDIRECT_URI=https://openvibe.watch/auth/callback
+#OV_OAUTH_SCOPE=profile
+#OV_SESSION_AUDIENCE=openvibe.network
+
+# The site's session cookie is Secure in production (https) by default; set false only in development.
+#COOKIE_SECURE=true
+
# Store (PostgreSQL, ADR-035), written by OpenVibe.Host roles/data/add-service.sh watch: the runtime role through
# 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).
# 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.
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+# Changelog
+
+What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
+the step it landed.
+
+## 2026-10-08 — the public site (T18 step 8)
+
+- **Sign in with OpenVibe** at `/auth/login`: OAuth 2 authorization code with PKCE (S256) as the
+ Network client `watch`, a session in Watch's own database (only the hash of the cookie token is
+ stored), and the signed-in person's `usr_…` subject as the owner.
+- **A site for watches**: the home page explains Watch and starts one of the four kinds; `/watches`
+ lists yours; `/watches/:id` states a watch in plain words with its observation history and every
+ check run; pause, resume, delete and check-now are plain forms. Every page works without
+ JavaScript.
+- **One create form, four starting points** (`/watches/new?template=page|price|feed|json`): a page
+ that changes, a price below a value, a new item in a feed, a JSON value — the server picks the
+ fields the kind needs.
+- **Honest by construction**: the kinds that arrive later (event, webhook, Run, Node) and an AI
+ reading are not offered, because Watch will not pretend to check what it cannot. Notifications go
+ through OpenVibe.Network exactly as before.
+- **Crawl artifacts** (robots.txt, sitemap.xml, llms.txt) and a public vhost that serves the site
+ while `/api/v1/*` stays loopback-only.
+
+## 2026-10-08 — the check engine and the pull carriers (T18 steps 2–3)
+
+- **The watch registry**: `watch.watch@1` in and out, validated with the released contracts; a
+ create needs a source, an extraction, a condition and an action; ownership is the authority and
+ another owner's watch is 404.
+- **The check engine**: conditional GET (ETag/Last-Modified, a 304 costs no body), HEAD-first when
+ only "did it change?" matters, an unchanged body recorded as `no_change`, and a recorded state for
+ every terminal path with backoff and `Retry-After`.
+- **Carriers**: `http`, `feed` (RSS 2.0 / RSS 1.0 / Atom 1.0) and `api` (JSON/XML, mapped), all
+ through the one SSRF-guarded fetcher with per-host spacing.
+- **Extraction and conditions**: text, html, a small CSS subset, json/jsonpath and regex; `changed`,
+ the numeric and string predicates, `for_sec` debounce, tolerance and percent.
+- **Observations and check runs**: immutable rows with value hashes, capped snapshots and retention.
+- **The API** (bearer tokens, one capability per route), the `watch.*` events through the
+ transactional outbox, and per-actor limits.
@@ -4,10 +4,11 @@
@@ -4,10 +4,11 @@
> watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or
> 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.
> 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).
+**Status:** alpha (plan T18 steps 2–3 and step 8). The registry, the check engine, the pull carriers
+and the public site (sign-in, pages, forms) exist; the event, webhook, Node and Run rungs and the
+actions do not yet.
+**Domain:** `openvibe.watch` (a public, server-rendered site at [Public site](#public-site); the API
+is loopback-only). Install steps: [Public host](#public-host).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete),
**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,
the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison,
condition, action, budget, retention".
condition, action, budget, retention".
@@ -48,6 +49,57 @@ answers direct loopback callers only: golden signals by route template, `watch_w
@@ -48,6 +49,57 @@ answers direct loopback callers only: golden signals by route template, `watch_w
`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),
`watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`),
`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.
`watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`.
+## Public site
+
+`openvibe.watch` is also the place a person manages their own watches in a browser. Every page is
+server-rendered and complete without JavaScript: the create and edit forms POST, pause/resume/
+delete/check-now are plain forms, and the fields a form shows are chosen by the query
+(`/watches/new?template=price`), not by script. The visual layer is plain semantic HTML on the
+classes openvibe-shared's frame and this repository's `public/css/app.css` already provide.
+
+**Sign-in** is OpenVibe.Network's OAuth 2 authorization code with PKCE (S256), as the OAuth client
+`watch` (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`), redirecting to `BASE_URL/auth/callback`.
+The Network token is verified once (issuer, audience `openvibe.network`, a `usr_…`/`agt_…` subject)
+and then discarded; the browser holds an opaque random session token in an httpOnly, SameSite=Lax
+cookie, and Watch's database stores only its SHA-256 hash (`web_sessions`, migration 0002). The
+signed-in person's `usr_…` subject is the watch owner: the site calls the **registry directly,
+in-process**, with exactly the ownership rules the API applies — another person's watch is 404 on
+every page and every form, never 403. A signed-in write must also come from this site itself
+(same-origin), so another site cannot make a visitor act here.
+
+| Page | What it shows |
+|---|---|
+| `/` | what Watch is in one paragraph; four starting points as cards; how it checks in plain words; your watches summary (counts by status) when signed in |
+| `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` |
+| `/watches/new?template=page\|price\|feed\|json` | the one create form, showing only the fields the chosen kind needs |
+| `/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 |
+| `/watches/:id/edit` | the same form, PATCH semantics |
+| `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog |
+| `/robots.txt`, `/sitemap.xml`, `/llms.txt(/-full)` | discovery: the home page and how-it-works are listed; `/watches` and `/auth` are disallowed |
+
+**Four starting points**, each a page of the same form:
+
+| Template | Source | Extraction | Condition |
+|---|---|---|---|
+| `page` | http | html, an optional CSS selector | `changed` |
+| `price` | http | css selector or regex | `lt`/`lte`/`gt`/`gte` |
+| `feed` | feed | the newest item (`latest`) | `changed` |
+| `json` | api | json with an optional `value_path` | any of the ops |
+
+The cadence is a select (15 minutes, 1 hour, 6 hours, 1 day); 15 minutes is the shortest a person may
+choose here (the contract's floor is 30 seconds, for a service). A validation error re-renders the
+form with the person's own input and the registry's own message next to the field it names. The
+kinds whose carrier does not exist yet (event, webhook, run, node) and the `ai` extraction are **not
+offered** — and a watch of such a kind created through the API still answers `watch.carrier_unavailable`
+when checked from the site, fetching nothing.
+
+Check-now from the page respects the API's limits and its refusals: a second check while one runs or
+a check of a paused watch is the API's 409, said in words; a kind without a carrier is the API's 422;
+and the manual-check and write budgets (10/120 and 30/600 per person, a minute/hour) are enforced
+with a friendly page. "Notify me" stores the notification action; a fired condition still emits
+`watch.watch.triggered` through OpenVibe.Network as today (actions are step 4 and are not dispatched
+yet, which the form says on the page).
+
## The watch model
## The watch model
A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,
A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`,
@@ -218,7 +270,12 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -218,7 +270,12 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*`
limits. `X-OV-Subject` is honoured only from a first-party service. The vhost keeps `/api/v1/*`
loopback-only.
loopback-only.
- **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers
- **Ownership.** Reads and writes are scoped to the acting subject; another owner's watch answers
- 404 on every route.
+ 404 on every route — and on every page and form of the public site, which acts on the same
+ registry in-process.
+- **Site sessions.** The browser holds an opaque random token in an httpOnly, SameSite=Lax cookie;
+ the database keeps only its SHA-256 hash, and the Network access token is verified once and
+ discarded. A signed-in write must be same-origin, and the page forms escape every value they show
+ (the `html` tagged template, `server/render/html.js`).
- **Egress.** A watch fetches outside URLs by design, only through the guard:
- **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
[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
resolved address public at connect time (no DNS rebinding), each redirect hop re-checked, one
@@ -235,6 +292,7 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -235,6 +292,7 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
- the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` /
- the watch registry (`watches`, `watch_endpoint_state`) and the `watch.watch@1` /
`watch.watch-request@1` / `watch.watch-result@1` shapes it serves
`watch.watch-request@1` / `watch.watch-result@1` shapes it serves
- checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff
- checks: `check_runs`, `observations`, the scheduler, conditional-fetch state, backoff
+- the public site's sign-in sessions (`web_sessions`) and its pages
- `watch.*` events
- `watch.*` events
## Does not own
## Does not own
@@ -295,6 +353,11 @@ Called elsewhere, as the service principal `watch` (client credentials from Open
@@ -295,6 +353,11 @@ Called elsewhere, as the service principal `watch` (client credentials from Open
X-OV-Subject delegation, the check and observation reads, per-actor 429 (`test/api.test.js`)
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
- the registry: required fields, cadence rules, forbidden headers, due selection, re-arm, soft
delete, the keyset page (`test/registry.test.js`)
delete, the keyset page (`test/registry.test.js`)
+- the public site (`test/site.test.js`): sign-in through the stand-in Network (PKCE, a session,
+ state mismatch refused, `/auth/me`, sign-out), the signed-out pages, each create template against
+ what the API would store, validation errors that keep the input, another person's watch 404 on
+ every page and form, pause/resume/delete/check-now against a stub site (with the API's 409s and
+ 422), cross-site writes refused, and hostile names and values escaped on every page
Not yet demonstrated: a check against a real site from a deployed service (nothing is deployed
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
yet), the event/webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the
@@ -302,9 +365,11 @@ actions (step 4) — each answers `watch.carrier_unavailable` and fetches nothin
@@ -302,9 +365,11 @@ actions (step 4) — each answers `watch.carrier_unavailable` and fetches nothin
## Public host
## 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
+`openvibe.watch` serves the site at the edge: the pages, `/auth/*`, `/css/*` and `/shared/*`, the
+discovery files, and `/api/health`, `/api/ready`, `/release.json`. It keeps `/api/v1/*`
+loopback-only, exactly as before, so the service API is reached only from the host. Sign-in and form
+POSTs have their own per-address limit zones (nginx refuses `limit_req` inside `limit_except`, so a
+`map` on `$request_method` gives GET/HEAD an empty key). Install: copy the unit and the vhost, write
`/etc/openvibe/watch.env` (0600) from `.env.example`, run
`/etc/openvibe/watch.env` (0600) from `.env.example`, run
`sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then
`sudo /opt/openvibe.host/roles/data/add-service.sh watch`, then
`sudo ovhost deploy watch`.
`sudo ovhost deploy watch`.
@@ -3,10 +3,10 @@
@@ -3,10 +3,10 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W18",
"wave": "W18",
- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026) — T18 Watch complete, steps 2-3",
+ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026) — T18 Watch complete, steps 2-3 (registry, engine, pull carriers) and step 8 (the public site)",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4730,
"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\")",
+ "domain": "openvibe.watch (a public server-rendered site + a loopback-only API; vhost in deploy/nginx; README \"Public site\" and \"Public host\")",
"contracts": "openvibe-contracts v0.112.0",
"contracts": "openvibe-contracts v0.112.0",
"sdk": "openvibe-sdk v0.35.0",
"sdk": "openvibe-sdk v0.35.0",
"shared": "openvibe-shared v2.13.2",
"shared": "openvibe-shared v2.13.2",
@@ -35,7 +35,26 @@
@@ -35,7 +35,26 @@
],
],
"seededWatches": 0,
"seededWatches": 0,
"deployed": false,
"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.",
+ "deployment": "Not deployed. Plan T18 steps 2-3 and step 8 are implemented and tested in the repository; nothing runs on the host yet, and openvibe.watch has no DNS or vhost installed.",
+ "works": [
+ "the watch registry with owner scoping (404 for another owner) validated against the released contracts",
+ "the check engine: conditional GET (ETag/Last-Modified, HEAD-first for change-only), 304/unchanged-body short circuits, recorded states, backoff and Retry-After",
+ "SSRF-guarded fetching (ports, private hosts, every redirect hop, byte cap, one deadline) and per-host spacing",
+ "the http, feed (RSS/Atom) and api carriers",
+ "extraction (text, html, css subset, json/jsonpath, regex) and conditions (changed/threshold/string, for_sec)",
+ "observations with value hashes, capped snapshots and retention; check runs with the full state set",
+ "the loopback-only API (one capability per route), watch.* events through the transactional outbox, per-actor limits",
+ "the public site: sign-in with OpenVibe.Network (OAuth2 + PKCE S256, sessions in Watch's database), the home page and four starting points, /watches (paged), the create and edit forms (server-side fieldsets, validation errors that keep the input), one watch with pause/resume/delete/check-now, the observation history and check runs, /how-it-works, /updates, and robots/sitemap/llms.txt",
+ "the site calls the registry in-process with the API's ownership rules (another person's watch is 404 on every page and form) and refuses cross-site writes"
+ ],
+ "notYet": [
+ "not deployed: no host install, DNS or vhost on openvibe.watch yet, so nothing is reachable in production",
+ "no check has run against a real external site from a deployed service (the tests use stub sites only)",
+ "the event and webhook carriers (step 5), the Run rung (step 6), Node probes (step 7) and the actions (step 4): a watch of such a kind is accepted but never fetched",
+ "an ai extraction is not a check (step 10); such a watch is recorded skipped and nothing is fetched",
+ "the site offers only page/price/feed/json watches: the kinds and the AI reading above are not in the form",
+ "notifications are the watch.watch.triggered event OpenVibe.Network turns into WATCH_TRIGGERED; no other action is dispatched"
+ ],
"features": [
"features": [
"watch registry with owner scoping (404 for another owner) validated against the released contracts",
"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",
"binding carrier preference order: webhook/event → ETag/Last-Modified → feed → API → Run",
@@ -48,7 +67,8 @@
@@ -48,7 +67,8 @@
"check runs with the full terminal-state set, exponential backoff and Retry-After",
"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",
"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",
"watch.* events through the outbox in the same transaction as the state change",
- "per-actor limits (openvibe-sdk/limits)"
+ "per-actor limits (openvibe-sdk/limits)",
+ "the public site: Network sign-in (PKCE S256, web_sessions with only the token hash), server-rendered pages complete without JavaScript, the four create templates, watch management, observation history and check runs, how-it-works and the changelog, robots/sitemap/llms.txt"
],
],
"updated": "2026-10-08"
"updated": "2026-10-08"
}
}
@@ -4,20 +4,35 @@
@@ -4,20 +4,35 @@
## Deployed to /etc/nginx/sites-available/openvibe.watch.conf — keep them in sync,
## Deployed to /etc/nginx/sites-available/openvibe.watch.conf — keep them in sync,
## and reload nginx after changing it. Install: README "Public host".
## 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:
+## Watch now has a public site, server-rendered by the Node app (port 4730, systemd unit
+## openvibe-watch). The vhost serves:
+## /, /watches, /watches/:id, /how-it-works, /updates the pages
+## /auth/* sign-in with OpenVibe.Network
+## /css/*, /shared/* the stylesheet and the Frame assets
+## /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt crawl artifacts
+## /api/health, /api/ready, /release.json health and release metadata
+## and keeps the service API off the public edge:
## /api/v1/* service-to-service API (bearer token, one capability per route): loopback
## /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.
## 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
## /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.
## 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
## /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
+##
+## Sign-in and form writes are limited per client address. nginx refuses limit_req inside
+## limit_except, so a read is exempted with a map on $request_method to an empty key: nginx
+## does not count an empty key, and a GET/HEAD page keeps the ordinary page budget.
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
+limit_req_zone $binary_remote_addr zone=watch_pages:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=watch_auth:10m rate=10r/m;
+
+map $request_method $watch_form_key {
+ GET "";
+ HEAD "";
+ default $binary_remote_addr;
+}
+limit_req_zone $watch_form_key zone=watch_forms:10m rate=1r/s;
+
# ── HTTP → HTTPS redirect ──────────────────────────────────
# ── HTTP → HTTPS redirect ──────────────────────────────────
server {
server {
listen 80;
listen 80;
@@ -45,10 +60,17 @@ server {
@@ -45,10 +60,17 @@ server {
client_max_body_size 256k;
client_max_body_size 256k;
+ # The app decides robots per page (the person pages are noindex; the home page is not).
add_header X-Content-Type-Options nosniff always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
- add_header X-Robots-Tag "noindex, nofollow" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
# Client address only from $remote_addr (realip has already replaced it with the visitor's
# 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.
# address for Cloudflare's ranges); never append to or pass through a client-sent header.
@@ -60,6 +82,7 @@ server {
@@ -60,6 +82,7 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
+ # ── Health, readiness and release metadata ──────────────
location = /api/health { proxy_pass http://127.0.0.1:4730; }
location = /api/health { proxy_pass http://127.0.0.1:4730; }
location = /api/ready { 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; }
location = /release.json { proxy_pass http://127.0.0.1:4730; }
@@ -67,14 +90,42 @@ server {
@@ -67,14 +90,42 @@ server {
# Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics).
location = /metrics { return 404; }
location = /metrics { return 404; }
- location = / {
- limit_except GET { deny all; }
+ # ── Sign-in: the network's own budget. /auth/me is the navbar's session probe and gets the
+ # page budget instead, because it runs on every page view. ──
+ location = /auth/me {
+ limit_req zone=watch_pages burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4730;
+ }
+ location ^~ /auth/ {
+ limit_req zone=watch_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4730;
+ }
+
+ # ── The site's own assets (content-hashed ?v=) and the OpenVibe Frame ──
+ location ^~ /css/ { proxy_pass http://127.0.0.1:4730; }
+ location ^~ /shared/ { proxy_pass http://127.0.0.1:4730; }
+
+ # ── Crawl artifacts ─────────────────────────────────────
+ location = /robots.txt { proxy_pass http://127.0.0.1:4730; }
+ location = /sitemap.xml { proxy_pass http://127.0.0.1:4730; }
+ location = /llms.txt { proxy_pass http://127.0.0.1:4730; }
+ location = /llms-full.txt { proxy_pass http://127.0.0.1:4730; }
+
+ # ── The watches pages: reads keep the page budget, writes (a POST) the strict form zone. The
+ # map above gives GET/HEAD an empty key, which nginx does not count. ──
+ location ^~ /watches {
+ limit_req zone=watch_forms burst=10 nodelay;
+ limit_req_status 429;
proxy_pass http://127.0.0.1:4730;
proxy_pass http://127.0.0.1:4730;
}
}
- location = /robots.txt {
- default_type text/plain;
- return 200 "User-agent: *\nDisallow: /\n";
+ # ── Pages ───────────────────────────────────────────────
+ location / {
+ limit_req zone=watch_pages burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4730;
}
}
# ── Service API: loopback only (see header) ─────────────
# ── Service API: loopback only (see header) ─────────────
@@ -85,9 +136,5 @@ server {
@@ -85,9 +136,5 @@ server {
proxy_pass http://127.0.0.1:4730;
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"}';
- }
+ location ~ /\.(?!well-known) { deny all; }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+-- phase: expand
+-- The public site's sign-in sessions (plan T18 step 8). Additive only; never edited after it runs.
+--
+-- A signed-in person's browser holds an opaque random token in an httpOnly cookie; only its SHA-256
+-- hash is stored here, so a database dump does not hand out live sessions. The person's Network
+-- access token is NOT stored: after the OAuth code exchange we verify it once (issuer, audience,
+-- subject) and keep only the canonical subject the watches belong to. The subject is what the
+-- registry scopes ownership by (usr_…/agt_…), the same authority the API applies.
+
+CREATE TABLE web_sessions (
+ id text COLLATE "C" PRIMARY KEY, -- sha256 hex of the cookie token
+ subject text COLLATE "C" NOT NULL, -- usr_… / agt_…
+ username text COLLATE "C",
+ display_name text COLLATE "C",
+ role text COLLATE "C",
+ created_at bigint NOT NULL,
+ last_seen_at bigint NOT NULL,
+ expires_at bigint NOT NULL,
+ revoked_at bigint
+);
+CREATE INDEX idx_web_sessions_expiry ON web_sessions (expires_at);
+CREATE INDEX idx_web_sessions_subject ON web_sessions (subject, created_at DESC);
@@ -12,9 +12,9 @@
@@ -12,9 +12,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.112.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",
+ "version": "0.116.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
+ "integrity": "sha512-mDqQJ6m8YSJf3UV+kVpqeMrgNDMMzBQdYmj/3o5JqGegj52ymiDH1NerVW6c+0ebsV3zX7RbRlknIy3vr634yw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -785,9 +785,9 @@
@@ -785,9 +785,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -814,9 +814,9 @@
@@ -814,9 +814,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.13.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
- "integrity": "sha512-rvLq6uKd0j84qxZXwGX+gE6+e8SJaP8bBilWu9Eosjgfc70Z4bPJOUJUiLNRte7fU5hW/ea97/JoCLdiwXmPQA==",
+ "version": "2.15.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
+ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+/* OpenVibe.Watch — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */
+:root { color-scheme: dark; }
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--accent-light, #60a5fa); }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
+.account-bar { max-width: 1080px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.page { max-width: 1080px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
+h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
+h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
+h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
+.muted { color: var(--text-muted, #7386a3); }
+.small { font-size: .85rem; }
+.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
+code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
+code { overflow-wrap: anywhere; }
+pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
+pre code { overflow-wrap: normal; }
+.table-wrap { overflow-x: auto; }
+table { border-collapse: collapse; width: 100%; font-size: .92rem; }
+th, td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border, #1f2d47); }
+th { color: var(--text-secondary, #96a7c2); font-weight: 600; }
+.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; line-height: 1.6; }
+.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
+.badge.info { border-color: var(--info, #38bdf8); color: var(--info, #38bdf8); }
+.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
+.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
+.notice { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
+.notice.ok { border-left-color: var(--success, #22c55e); }
+.notice.warn { border-left-color: var(--warning, #f59e0b); }
+.notice.bad { border-left-color: var(--error, #ef4444); }
+button { font: inherit; cursor: pointer; }
+form.inline { display: inline; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
+
+/* ── Buttons ────────────────────────────────────────────── */
+.btn { display: inline-block; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
+.btn:hover { border-color: var(--accent, #3b82f6); }
+.btn.primary { background: var(--accent, #3b82f6); border-color: var(--accent, #3b82f6); color: #fff; }
+.btn.danger { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
+.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 14px 0; }
+.pager { display: flex; gap: 12px; align-items: center; margin: 14px 0; }
+
+/* ── The home page ──────────────────────────────────────── */
+.lede { font-size: 1.08rem; max-width: 70ch; }
+.cards { list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; padding: 0; margin: 12px 0; }
+.card { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 14px 16px; }
+.card h3 { margin: 0 0 6px; }
+.card p { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .93rem; }
+.facts { margin: 12px 0; max-width: 80ch; }
+.facts li { margin: 0 0 8px; }
+
+/* ── The forms ──────────────────────────────────────────── */
+.watch-form { max-width: 80ch; }
+.watch-form fieldset, .saved-form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; margin: 12px 0; }
+.watch-form legend, .saved-form legend { padding: 0 6px; color: var(--text-secondary, #96a7c2); }
+.field { display: flex; flex-direction: column; gap: 3px; margin: 0 0 12px; }
+.field label { font-size: .9rem; color: var(--text-secondary, #96a7c2); }
+.field input[type="text"], .field input[type="url"], .field input[type="number"], .field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }
+.field.checkbox { flex-direction: column; }
+.field.has-error input, .field.has-error select { border-color: var(--error, #ef4444); }
+.field-error { color: var(--error, #ef4444); font-size: .88rem; }
+.hint { font-size: .82rem; }
+.fixed { color: var(--text-primary, #e6edf7); }
+.template-chooser { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; font-size: .92rem; }
+.template-chooser select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }
+.form-actions { margin: 16px 0 8px; display: flex; gap: 12px; align-items: center; }
+
+/* ── One watch ──────────────────────────────────────────── */
+.watch-detail .facts, dl.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }
+dl.facts dt { color: var(--text-secondary, #96a7c2); }
+dl.facts dd { margin: 0; overflow-wrap: anywhere; }
+.in-words { font-size: 1.05rem; max-width: 80ch; }
+.changelog { max-width: 80ch; }
+.changelog h2 { margin-top: 1.6rem; }
+.changelog ul { margin: .4rem 0 1rem; }
+
+/* The home page's showcase sections sit inside .page, which has its own gutter. */
+.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
+.watch-later { max-width: 1080px; margin: 10px auto 0; }
+ul.facts { list-style: none; margin: 12px 0; padding: 0; max-width: none; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
+ul.facts li { margin: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
+ul.facts li strong { display: block; margin-bottom: 4px; color: var(--text-primary, #e6edf7); font-size: 1rem; }
@@ -1,16 +1,35 @@
@@ -1,16 +1,35 @@
'use strict';
'use strict';
-/** Express app: request context, the v1 API, health/readiness, metrics. */
+/**
+ * Express app: the public site (pages, sign-in, discovery), the v1 API, health/readiness and metrics.
+ *
+ * /, /watches, /watches/:id, /how-it-works, /updates the pages (http/pages.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/v1/* the API (api/watches.js, api/checks.js), loopback-only at the vhost
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ *
+ * The site is server-rendered and complete without JavaScript; it acts on the registry in-process as
+ * the signed-in person's subject, with exactly the API's ownership rules. The API keeps working
+ * unchanged: bearer tokens, one capability per route, problem+json.
+ */
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { instrument } = require('openvibe-shared/metrics');
const { instrument } = require('openvibe-shared/metrics');
const { createRelease } = require('openvibe-shared/release');
const { createRelease } = require('openvibe-shared/release');
+const cache = require('openvibe-shared/cache-policy');
const { createWatchReadiness, registerWatchGauges } = require('./observability');
const { createWatchReadiness, registerWatchGauges } = require('./observability');
+const { createSso } = require('./auth/sso');
+const { createPageRoutes } = require('./http/pages');
const { watchesRouter } = require('./api/watches');
const { watchesRouter } = require('./api/watches');
const { checksRouter } = require('./api/checks');
const { checksRouter } = require('./api/checks');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
+const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
+const { html } = require('./render/html');
const pkg = require('../package.json');
const pkg = require('../package.json');
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+
+/** The text index non-HTML clients get for `/`; the site itself is at the same address for browsers. */
const TEXT_INDEX = [
const TEXT_INDEX = [
'OpenVibe.Watch: persistent user-defined observations and conditions over pages, feeds and APIs.',
'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),',
'A watch states what it observes (source), how often or what pushes to it (cadence / event pattern),',
@@ -18,9 +37,8 @@ const TEXT_INDEX = [
@@ -18,9 +37,8 @@ const TEXT_INDEX = [
'it wakes. A watch never polls when an event or a webhook will do, and it never fabricates a value:',
'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.',
'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.',
+ 'Pages (HTML, server-rendered): /, /watches, /how-it-works, /updates. Sign in at /auth/login.',
+ 'This text index is what a non-browser client gets here.',
'',
'',
'GET /api/v1/watches, /api/v1/watches/:id (watch.watch.read)',
'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)',
'POST/PATCH/DELETE /api/v1/watches[/:id], POST /api/v1/watches/:id/pause, /resume (watch.watch.manage)',
@@ -32,46 +50,19 @@ const TEXT_INDEX = [
@@ -32,46 +50,19 @@ const TEXT_INDEX = [
'',
'',
].join('\n');
].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>
-`;
+/** Content-Security-Policy for the site's HTML: the OpenVibe Frame (navbar, footer) is loaded from Network. */
+const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com; "
+ + "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
+ + "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https://openvibe.network https://openvibe.media; "
+ + "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com; "
+ + "frame-src 'self' https://openvibe.network; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https://openvibe.network";
-function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null }) {
+function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
- app.set('trust proxy', 'loopback');
+ app.set('trust proxy', config.trustProxy != null ? config.trustProxy : 'loopback');
const release = createRelease({ service: 'watch', root: path.join(__dirname, '..') });
const release = createRelease({ service: 'watch', root: path.join(__dirname, '..') });
+ setRelease(release.release);
// HTTP golden signals by route template, process metrics, release_info and the Watch gauges;
// HTTP golden signals by route template, process metrics, release_info and the Watch gauges;
// GET /metrics answers direct loopback callers only (Track O).
// GET /metrics answers direct loopback callers only (Track O).
const metrics = instrument(app, { service: 'watch', release: release.release });
const metrics = instrument(app, { service: 'watch', release: release.release });
@@ -81,7 +72,9 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -81,7 +72,9 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
require('openvibe-shared/trace').install(app);
require('openvibe-shared/trace').install(app);
app.use((req, res, next) => {
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
- res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ // The API and sign-in are never cached; a page decides for itself.
+ if (req.path.startsWith('/api') || req.path.startsWith('/auth')) res.setHeader('Cache-Control', 'no-store');
next();
next();
});
});
app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
@@ -97,29 +90,50 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -97,29 +90,50 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
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).
+ // ── Who is signed in (the session cookie; never a Network token) ──
+ const sso = createSso({ config, keys: keyStore, sessions, fetchImpl, now: now || (() => Date.now()), log });
+ app.use(sso.middleware());
+
+ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
+ app.use('/auth', sso.routes());
+
+ // ── Static assets (content-hashed ?v= → immutable) ──
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── The API (unchanged: bearer tokens, one capability per route, problem+json) ──
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
app.use(watchesRouter({ registry, auth, limits }));
app.use(watchesRouter({ registry, auth, limits }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
+ app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
- // 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) => {
+ // ── Pages ───────────────────────────────────────────────────────────────────────────────
+ // `/` answers the text index to a client that does not ask for HTML (the service's own index,
+ // kept from before the site existed); a browser falls through to the home page below.
+ app.get('/', (req, res, next) => {
+ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) return next();
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.setHeader('Vary', 'Accept');
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);
return res.type('text/plain').send(TEXT_INDEX);
});
});
+ app.use((req, res, next) => {
+ // A page is HTML; the CSP is the site's, and every page is complete without script.
+ if (!req.path.startsWith('/api') && !req.path.startsWith('/auth')) res.setHeader('Content-Security-Policy', CSP);
+ next();
+ });
+ app.use(createPageRoutes({ config, db, registry, check, observations, sessions, siteLimits, log }));
- app.use((req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
+ app.use((req, res) => send(res, 404, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
+ body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/watches">your watches</a>.</p>`,
+ }));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
@@ -127,11 +141,13 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -127,11 +141,13 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'watch.too_large', { detail: 'request body too large', 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}`);
log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);
if (res.headersSent) return res.end();
if (res.headersSent) return res.end();
- return http.sendProblem(res, 500, 'watch.internal', { detail: 'internal error', ctx: req.ov });
+ if (req.path.startsWith('/api')) return http.sendProblem(res, 500, 'watch.internal', { detail: 'internal error', ctx: req.ov });
+ return res.status(500).type('text/plain').send(`Something went wrong on ${SITE_NAME}. ${TAGLINE}`);
});
});
app.locals.metrics = metrics;
app.locals.metrics = metrics;
+ app.locals.sso = sso;
return app;
return app;
}
}
-module.exports = { createApp };
+module.exports = { createApp, TEXT_INDEX, CSP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+/**
+ * The Network's RS256 signing key, fetched once from the configured JWKS URL and kept fresh by
+ * openvibe-sdk/auth's JWKS client. The site needs it to verify the person's Network session token
+ * during the OAuth code exchange (server/auth/sso.js): a token that does not verify for
+ * `openvibe.network` with a real subject never becomes a session.
+ *
+ * The SDK keeps one client per URL (last good keys through an outage, backoff, rotations, unref'd
+ * timer). This is a thin shim so the SSO middleware can call `verifyUser`, and nothing the SDK says
+ * about a failed key fetch (it names the internal JWKS URL) leaves the process: a key that has not
+ * loaded is answered as 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+const jwksUrl = (config) => (config.jwksUrl || `${config.networkInternalUrl || config.networkUrl}/api/.well-known/jwks`);
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+
+ // The SDK's `now` is a NUMBER of milliseconds; a function disables every expiry check.
+ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
+
+ /** Fetch (or wait out the backoff window) once at boot. */
+ async function ensure() {
+ try { await client.keys(); } catch (err) { log.warn(`[watch] JWKS unavailable: ${err && err.message || err}`); }
+ }
+ function loaded() { return client.status().ready === true; }
+
+ /**
+ * Verify a person's (session) token: { ok, claims } or { ok: false, code, reason, expired }.
+ * `reason` is one of two fixed public strings; the SDK's own text (the JWKS URL, the fetch error)
+ * is logged, never returned.
+ */
+ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
+ return { ok: true, claims };
+ } catch (err) {
+ const code = err && err.code;
+ log.warn(`[watch] user token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
+ }
+ }
+
+ return { ensure, loaded, verifyUser, client, stop: () => client.stop() };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+/**
+ * Sign in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `watch`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge_method=S256 (?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier),
+ * the token verified for openvibe.network, then a session row in Watch's
+ * own database and an httpOnly cookie naming it
+ * GET /auth/logout → clear the cookie, revoke the session row
+ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest
+ *
+ * The browser never holds a Network token. The cookie carries an opaque random session token; the
+ * database stores only its hash (server/sessions.js). The Network access token is verified once, at
+ * the callback, and discarded: the site acts on the watch registry in-process as the person's
+ * canonical subject (usr_…), never as the person over Network's API.
+ *
+ * The OAuth half rides openvibe-sdk/auth's own helpers (startAuthorization / readCallback /
+ * exchangeCode), so the PKCE, state and token-endpoint rules are the SDK's, not a hand copy.
+ */
+const express = require('express');
+const sdk = require('openvibe-sdk/auth');
+const cache = require('openvibe-shared/cache-policy');
+
+const SESSION_COOKIE = 'watch_session';
+const FLOW_COOKIE = 'watch_oauth';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_TTL_MS = 10 * 60 * 1000;
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next) {
+ // Browsers drop tab/newline and read a backslash as "/": "/<TAB>/evil.com" would leave the site.
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+/** Parse a Cookie header into a plain object (express gives no parser and this is all we need). */
+function parseCookies(header) {
+ const out = {};
+ for (const part of String(header || '').split(';')) {
+ const i = part.indexOf('=');
+ if (i < 0) continue;
+ const k = part.slice(0, i).trim();
+ if (!k) continue;
+ let v = part.slice(i + 1).trim();
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
+ // express's res.cookie percent-encodes the value; undo that so JSON flow cookies round-trip.
+ try { v = decodeURIComponent(v); } catch { /* keep the raw value */ }
+ out[k] = v;
+ }
+ return out;
+}
+
+function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSessionCookie(res, token) {
+ res.cookie(SESSION_COOKIE, token, { ...cookieBase(), maxAge: sessions.DEFAULT_TTL_MS });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
+ }
+ function clearSessionCookie(res) {
+ res.clearCookie(SESSION_COOKIE, cookieBase());
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
+ }
+
+ /**
+ * Verify the token the Network just minted: a session token for openvibe.network, carrying the
+ * canonical subject. Anything else (a service token, a typed token, another audience) never
+ * becomes a session. Returns the claims or throws.
+ */
+ async function verifySessionToken(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ if (!v.ok) throw Object.assign(new Error(v.reason || 'the Network token did not verify'), { status: 401, code: v.code });
+ const claims = v.claims || {};
+ if (typeof claims.subject_id !== 'string' || !/^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/.test(claims.subject_id)) {
+ throw Object.assign(new Error('this account has no canonical subject yet; sign in again'), { status: 401, code: 'identity.no_subject' });
+ }
+ return claims;
+ }
+
+ /** req.viewer for every page: the signed-in person named by the session cookie, else anonymous. */
+ function middleware() {
+ return async (req, _res, next) => {
+ req.cookies = parseCookies(req.headers.cookie);
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies[SESSION_COOKIE];
+ if (!token) return next();
+ try {
+ const row = await sessions.resolve(token);
+ if (row) req.viewer = sessions.viewer(row);
+ } catch (err) {
+ log.warn(`[sso] session lookup failed: ${(err && err.message) || err}`);
+ }
+ return next();
+ };
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
+
+ r.get('/login', async (req, res) => {
+ const next = sanitizeNext(req.query.next);
+ const started = await sdk.startAuthorization({
+ network: config.networkUrl,
+ clientId: config.oauth.clientId,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state: sdk.createState(),
+ });
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.codeVerifier, next }), flowCookie());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ return res.redirect(started.url);
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next) : '/';
+ const query = new URLSearchParams();
+ for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));
+ let code;
+ try {
+ ({ code } = sdk.readCallback(`/auth/callback?${query.toString()}`, { expectedState: flow && flow.state }));
+ } catch (err) {
+ return res.status(400).type('text/plain').send('Sign-in was not completed. Please try again.');
+ }
+ if (!flow || typeof flow.verifier !== 'string') {
+ return res.status(400).type('text/plain').send('Sign-in was not started in this browser. Please try again.');
+ }
+ let token;
+ try {
+ const data = await sdk.exchangeCode({
+ code,
+ redirectUri: config.oauth.redirectUri,
+ codeVerifier: flow.verifier,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret || undefined,
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ network: config.networkUrl,
+ fetch: fetchImpl,
+ });
+ token = data.access_token;
+ } catch (err) {
+ log.warn(`[sso] code exchange failed: ${(err && err.code) || (err && err.status) || err}`);
+ return res.status(502).type('text/plain').send('Sign-in failed: OpenVibe.Network did not accept the sign-in code. Please try again.');
+ }
+ try {
+ const claims = await verifySessionToken(token);
+ const session = await sessions.create({
+ subject: claims.subject_id,
+ username: typeof claims.username === 'string' ? claims.username : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name : null,
+ role: typeof claims.role === 'string' ? claims.role : null,
+ });
+ setSessionCookie(res, session);
+ return res.redirect(next);
+ } catch (err) {
+ log.warn(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || err}`);
+ return res.status(401).type('text/plain').send('Sign-in failed: that account cannot be signed in here.');
+ }
+ });
+
+ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
+ // <img src=/auth/logout>). A same-origin navigation or form, or the address bar (Sec-Fetch-Site
+ // none), signs out directly; anything else gets a confirm button (POST).
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}` || origin === new URL(config.baseUrl).origin;
+ return req.method === 'GET';
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
+ return res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Watch?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const token = req.cookies && req.cookies[SESSION_COOKIE];
+ if (token) { try { await sessions.revoke(token); } catch { /* the cookie is cleared anyway */ } }
+ clearSessionCookie(res);
+ return res.redirect(303, sanitizeNext(req.query.next));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ // No cookie at all is a guest, not an error: the shared navbar asks on every page view.
+ const c = req.cookies || {};
+ if (!c[SESSION_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ return res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, verifySessionToken, SESSION_COOKIE, FLOW_COOKIE };
+}
+
+module.exports = { createSso, sanitizeNext, parseCookies, SESSION_COOKIE, FLOW_COOKIE, HINT_COOKIE };
@@ -29,18 +29,24 @@ function load(env = process.env) {
@@ -29,18 +29,24 @@ function load(env = process.env) {
// The JWKS the SDK's client (openvibe-sdk/auth jwksClient) fetches, caches and keeps fresh; one URL,
// 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.
// 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`);
const jwksUrl = strip(env.OV_NETWORK_JWKS_URL || `${networkInternalUrl}/api/.well-known/jwks`);
+ const baseUrl = strip(env.BASE_URL || (isProduction ? 'https://openvibe.watch' : `http://localhost:${port}`));
return {
return {
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
isProduction,
isProduction,
serviceId: 'watch',
serviceId: 'watch',
- baseUrl: strip(env.BASE_URL || (isProduction ? 'https://openvibe.watch' : `http://localhost:${port}`)),
+ baseUrl,
+ // The public site is behind nginx (and Cloudflare): trust the proxy's forwarded address, as the
+ // API's own limits and same-origin checks need the real client. 0 disables it (tests).
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
networkUrl,
networkUrl,
networkInternalUrl,
networkInternalUrl,
jwksUrl,
jwksUrl,
issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
issuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
+ // The issuer a person's Network session token carries (sign-in); the same Network as the API's.
+ networkIssuer: strip(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
audience: 'openvibe.watch',
audience: 'openvibe.watch',
// Per-actor limits (server/api/actor-limits.js): the API reads one app or module may make per
// 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.
// minute and per hour. Writes and manual checks set their own numbers there.
@@ -51,7 +57,15 @@ function load(env = process.env) {
@@ -51,7 +57,15 @@ function load(env = process.env) {
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'watch',
clientId: env.OV_OAUTH_CLIENT_ID || 'watch',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ // The site's redirect (BASE_URL + /auth/callback) and the scope and audience a person's
+ // Network session token is verified for. The scope is 'profile': the site asks for nothing
+ // beyond who the person is, because it acts on its own registry, not on Network.
+ redirectUri: strip(env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`),
+ scope: env.OV_OAUTH_SCOPE || 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
+ // The site's session cookie: Secure in production (https), off in development and tests.
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * Crawl artifacts for openvibe.watch: robots.txt, sitemap.xml, llms.txt and llms-full.txt, plus the
+ * home page's JSON-LD, all built with openvibe-shared/seo.
+ *
+ * The site's own two public pages — the home page and how it checks — are for crawlers. A person's
+ * watches and their sign-in are not: /watches and /auth are disallowed, and the sitemap lists only
+ * the two pages that say what Watch is.
+ */
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+
+const SITE_NAME = 'OpenVibe.Watch';
+const DESCRIPTION = 'OpenVibe.Watch — get told when a page, a feed or an API changes. Define what to watch and the condition that fires; Watch checks each source on the cadence you pick and records every check.';
+const DISALLOW = ['/auth/', '/watches'];
+
+const PAGE_TEXT = {
+ '/': ['OpenVibe.Watch home', 'Define a watch over a page, a feed or an API, choose the condition that fires, and sign in to see what each check found. Watch prefers the site\'s own ETag before it re-reads a body, and never checks more often than the cadence you choose.'],
+ '/how-it-works': ['How it checks', 'The carrier order (conditional GET first, feeds and APIs before pages), the polite per-host spacing, the cadence floor, and what every check state means.'],
+};
+
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+function siteUpdated() {
+ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
+}
+
+function homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'UtilitiesApplication', keywords: 'openvibe, watch, monitoring, price watch, feed' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+const publicPages = () => [
+ { path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
+];
+
+function createDiscoveryRoutes({ config }) {
+ const r = express.Router();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# openvibe.watch: the two pages that say what Watch is are for crawlers. A person\'s watches and sign-in are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: DESCRIPTION,
+ details: 'Every page is server-rendered and complete without JavaScript. A person signs in with OpenVibe.Network to define and manage their own watches; nobody else can see them.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Watch', url: abs('/'), note: 'What Watch is and the four ways to start a watch.' },
+ { title: 'How it checks', url: abs('/how-it-works'), note: 'The carrier order, the cadence floor and what each state means.' },
+ ] },
+ { title: 'For machines', links: [
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'The watch API (for services)', url: abs('/how-it-works'), note: 'Bearer tokens, one capability per route; /api/v1/* stays loopback-only.' },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Watch, one paragraph each.',
+ base: site,
+ maxBytes: 64 * 1024,
+ sections: [{ title: 'Pages', pages }],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME, DISALLOW, PAGE_TEXT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,759 @@
@@ -0,0 +1,759 @@
+'use strict';
+/**
+ * The public site: the home page, a person's watches, one watch, the create and edit forms, how it
+ * checks and the update log, plus the crawl artifacts (server/http/discovery.js).
+ *
+ * Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:
+ * the create and edit forms POST, pause/resume/delete/check-now are plain POST buttons, and the
+ * template a form shows is chosen by the query (`/watches/new?template=price`), never by script.
+ *
+ * The site is the same authority as the API, in-process: it calls the watch registry directly with
+ * the signed-in person's canonical subject, so exactly the API's ownership rules apply — another
+ * person's watch is 404 on every page and every form, never 403. A signed-in write must come from
+ * this site (same-origin), so another site cannot make a visitor act here; the session cookie is
+ * SameSite=Lax as well.
+ *
+ * Kinds whose carrier does not exist yet (event, webhook, run, node) and the `ai` extraction are not
+ * offered: the four starting points are the three pull carriers — a page, a feed, an API.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { RegistryError } = require('../registry');
+const { html, raw, table, notice, time, badge } = require('../render/html');
+const { send } = require('../render/layout');
+const W = require('../render/watch');
+const changelog = require('../render/changelog');
+const { createDiscoveryRoutes, homeJsonLd, DESCRIPTION } = require('./discovery');
+
+/** The four starting points. Each fixes the source kind, the extraction and the condition's ops. */
+const TEMPLATES = {
+ page: {
+ card: 'A page changes',
+ cardBlurb: 'Give a URL and, if you like, the CSS selector of the part you care about. Watch tells you when its text changes.',
+ title: 'Watch a page for changes',
+ kind: 'http',
+ kindWords: 'a web page',
+ ops: ['changed'],
+ extraction: 'html',
+ },
+ price: {
+ card: 'Price drops below',
+ cardBlurb: 'Read one number off a page — a CSS selector or a regular expression — and fire when it drops below (or rises above) a value.',
+ title: 'Watch a price',
+ kind: 'http',
+ kindWords: 'a web page',
+ ops: ['lt', 'lte', 'gt', 'gte'],
+ defaultOp: 'lt',
+ extraction: 'css',
+ },
+ feed: {
+ card: 'New item in a feed',
+ cardBlurb: 'Give an RSS or Atom feed — a GitHub releases .atom URL, a blog’s RSS — and fire when a new item appears.',
+ title: 'Watch a feed for new items',
+ kind: 'feed',
+ kindWords: 'an RSS or Atom feed',
+ ops: ['changed'],
+ extraction: 'json',
+ valuePath: 'latest',
+ },
+ json: {
+ card: 'A JSON value',
+ cardBlurb: 'Give a JSON API and a path to the value inside it, and fire on the condition you choose.',
+ title: 'Watch a JSON value',
+ kind: 'api',
+ kindWords: 'a JSON API',
+ ops: ['changed', 'gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'contains', 'matches', 'exists', 'absent'],
+ defaultOp: 'lt',
+ extraction: 'json',
+ },
+};
+const TEMPLATE_NAMES = Object.keys(TEMPLATES);
+const OPS = {
+ changed: 'changes', gt: 'goes above', gte: 'reaches or is more than', lt: 'drops below', lte: 'is less than or equal to',
+ eq: 'equals', ne: 'is not', contains: 'contains', matches: 'matches the pattern', exists: 'is present', absent: 'is absent',
+};
+const NEEDS_VALUE = new Set(['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'contains', 'matches']);
+const NOTIFY_ACTION = [{ kind: 'notification', category: 'service', priority: 'normal' }];
+// With notifications off the watch still needs an action (the contract requires one); actions are
+// step 4 of the plan and none is dispatched yet, so this records the person's intent without changing
+// what happens today (see how-it-works and the note on the form).
+const QUIET_ACTION = [{ kind: 'event', event_type: 'watch.watch.triggered' }];
+
+const INT = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
+
+/** Same-site writes only: another site must not make a signed-in visitor act here. */
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (!origin) return false;
+ if (origin === `${req.protocol}://${req.get('host')}`) return true;
+ try { return origin === new URL(baseUrl).origin; } catch { return false; }
+}
+
+function createPageRoutes(ctx) {
+ const { config, db, registry, check, observations, sessions, log = console } = ctx;
+ const r = express.Router();
+ const formBody = express.urlencoded({ extended: false, limit: '32kb' });
+
+ const viewer = (req) => req.viewer || { kind: 'anonymous' };
+ const signedIn = (req) => viewer(req).kind === 'user' && Boolean(viewer(req).subject);
+ const principalOf = (req) => ({ sub: viewer(req).subject, subject: null });
+ const subjectOf = (req) => viewer(req).subject;
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: viewer(req), config, path: req.originalUrl, ...o });
+ const fail = (req, res, status, title, body) => page(req, res, { title, body }, status);
+
+ // ── The form model ───────────────────────────────────────────────────────────────────────
+
+ /** The values a form starts from: a template's defaults, or an existing watch (the edit form). */
+ function blankFields(template) {
+ const t = TEMPLATES[template] || TEMPLATES.page;
+ return {
+ template, name: '', url: '', kind: t.kind,
+ extraction: t.extraction, selector: '', value_path: t.valuePath || '',
+ op: t.defaultOp || t.ops[0], value: '', for_sec: '',
+ cadence: '3600', notify: 'on',
+ };
+ }
+
+ /**
+ * The fields a submitted form means. A checkbox posts nothing when it is unchecked, so `notify`
+ * is read straight from the body — never inherited from the form's own default.
+ */
+ function postedFields(template, body) {
+ const fields = { ...blankFields(template), ...(body || {}) };
+ fields.notify = body && body.notify === 'on' ? 'on' : '';
+ return fields;
+ }
+
+ /** Which template an existing watch most resembles, so the edit form shows its fields. */
+ function templateOf(watch) {
+ const kind = watch.source && watch.source.kind;
+ if (kind === 'feed') return 'feed';
+ if (kind === 'api') return 'json';
+ const e = watch.extraction || {};
+ const op = (watch.condition || {}).op;
+ if (e.kind === 'regex' || ['lt', 'lte', 'gt', 'gte'].includes(op)) return 'price';
+ return 'page';
+ }
+
+ function fieldsOfWatch(watch) {
+ const t = templateOf(watch);
+ const e = watch.extraction || {};
+ return {
+ template: t,
+ name: watch.name || '',
+ url: (watch.source && watch.source.url) || '',
+ kind: (watch.source && watch.source.kind) || TEMPLATES[t].kind,
+ extraction: e.kind === 'regex' ? 'regex' : (e.kind || TEMPLATES[t].extraction),
+ selector: e.selector || '',
+ value_path: e.value_path || '',
+ op: (watch.condition || {}).op || TEMPLATES[t].ops[0],
+ value: (watch.condition && watch.condition.value != null) ? String(watch.condition.value) : '',
+ for_sec: (watch.condition && Number.isInteger(watch.condition.for_sec)) ? String(watch.condition.for_sec) : '',
+ cadence: String((watch.cadence && watch.cadence.every_sec) || 3600),
+ notify: notifyOn(watch.action) ? 'on' : '',
+ };
+ }
+ const notifyOn = (action) => Array.isArray(action) && action.some((a) => a && a.kind === 'notification');
+
+ /** The condition a form says, checked against the template's ops. Returns { condition } or { error }. */
+ function conditionFrom(fields, template) {
+ const t = TEMPLATES[template];
+ const op = String(fields.op || t.defaultOp || t.ops[0]);
+ if (!t.ops.includes(op)) return { error: `The condition must be one of: ${t.ops.join(', ')}.` };
+ const condition = { op };
+ if (NEEDS_VALUE.has(op)) {
+ const raw = String(fields.value == null ? '' : fields.value).trim();
+ if (!raw) return { error: `“${OPS[op]}” needs a value to compare against.`, field: 'value' };
+ // A plain decimal is stored as a number; anything else as the text the person typed.
+ condition.value = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
+ }
+ if (template === 'price' || template === 'json') {
+ const secs = String(fields.for_sec == null ? '' : fields.for_sec).trim();
+ if (secs) {
+ const n = INT(secs, NaN);
+ if (!Number.isFinite(n) || n < 0) return { error: '“Hold for” must be a whole number of seconds, or blank.', field: 'for_sec' };
+ if (n > 0) condition.for_sec = n;
+ }
+ }
+ return { condition };
+ }
+
+ /**
+ * The watch request body a submitted form means — the same shape a caller would POST to
+ * /api/v1/watches. Every rule the registry enforces still applies; this only assembles the JSON.
+ * Returns { body } or { errors: { field: message } }.
+ */
+ function buildBody(fields, template) {
+ const t = TEMPLATES[template];
+ const errors = {};
+ const url = String(fields.url == null ? '' : fields.url).trim();
+ if (!url) errors.url = 'Give the address of the page, feed or API to watch.';
+ else if (!/^https?:\/\/\S+$/i.test(url)) errors.url = 'The address must start with http:// or https://.';
+ else { try { new URL(url); } catch { errors.url = 'That is not a valid address.'; } }
+
+ const cadenceSec = INT(fields.cadence, 3600);
+ if (cadenceSec < W.MIN_CADENCE_SEC) errors.cadence = 'The shortest cadence here is 15 minutes.';
+ if (cadenceSec > 2592000) errors.cadence = 'The longest cadence is 30 days.';
+
+ const name = String(fields.name == null ? '' : fields.name).trim();
+ if (name.length > 120) errors.name = 'A name can be at most 120 characters.';
+
+ const source = { kind: t.kind, url, format: null };
+ let extraction;
+ if (template === 'page') {
+ extraction = { kind: 'html', selector: String(fields.selector || '').trim() || null };
+ } else if (template === 'price') {
+ const how = fields.extraction === 'regex' ? 'regex' : 'css';
+ const selector = String(fields.selector || '').trim();
+ if (!selector) errors.selector = how === 'regex' ? 'Give the regular expression that reads the price.' : 'Give the CSS selector of the element that holds the price.';
+ extraction = { kind: how, selector };
+ } else if (template === 'feed') {
+ extraction = { kind: 'json', value_path: 'latest' };
+ } else {
+ extraction = { kind: 'json', value_path: String(fields.value_path || '').trim() || null };
+ }
+
+ const c = conditionFrom(fields, template);
+ if (c.error) errors[c.field || 'op'] = c.error;
+
+ if (Object.keys(errors).length) return { errors };
+ const body = {
+ source,
+ cadence: { every_sec: cadenceSec, jitter_sec: 0 },
+ extraction,
+ condition: c.condition,
+ action: fields.notify === 'on' ? NOTIFY_ACTION : QUIET_ACTION,
+ };
+ if (name) body.name = name; // omitted, not undefined: the registry names it from the source
+ return { body };
+ }
+
+ /**
+ * Map a registry error to the form field it names, so the message can sit next to the input.
+ * Only a field this form renders is returned; anything else (a oneOf failure that names no
+ * single field) is answered as null and shown as a form-level notice instead of being lost.
+ */
+ function registryField(message) {
+ const m = String(message);
+ const named = m.match(/\/(?:source|extraction|condition|cadence|name|action)\/(url|selector|value_path|value|for_sec|every_sec)\b/);
+ if (named) return named[1] === 'every_sec' ? 'cadence' : named[1];
+ if (/\/name\b/.test(m)) return 'name';
+ if (/\/condition\b/.test(m)) return 'op';
+ if (/\/cadence\b/.test(m)) return 'cadence';
+ if (/\/extraction\b/.test(m)) return 'selector';
+ if (/\burl\b/i.test(m)) return 'url';
+ return null;
+ }
+
+ // ── The form, rendered ───────────────────────────────────────────────────────────────────
+
+ const idOf = (f) => `f-${f}`;
+ function field(label, control, { name, error, hint = null, cls = '' } = {}) {
+ const id = idOf(name);
+ return html`<p class="field ${cls}${error ? ' has-error' : ''}">
+<label for="${id}">${label}</label>
+${control}
+${hint ? html`<span class="hint muted small" id="${id}-hint">${hint}</span>` : ''}
+${error ? html`<span class="field-error" role="alert">${error}</span>` : ''}
+</p>`;
+ }
+
+ function checkbox(label, { name, checked, hint = null, error = null }) {
+ const id = idOf(name);
+ return html`<p class="field checkbox${error ? ' has-error' : ''}">
+<label for="${id}"><input type="checkbox" id="${id}" name="${name}" value="on"${checked ? raw(' checked') : ''}> ${label}</label>
+${hint ? html`<span class="hint muted small">${hint}</span>` : ''}
+${error ? html`<span class="field-error" role="alert">${error}</span>` : ''}
+</p>`;
+ }
+
+ const select = (name, options, value) => html`<select id="${idOf(name)}" name="${name}">${options.map((o) => html`<option value="${o.value}"${String(o.value) === String(value) ? raw(' selected') : ''}>${o.label}</option>`)}</select>`;
+
+ const CADENCE_CHOICES = W.CADENCES.map((c) => ({ value: c.sec, label: c.label }));
+
+ /**
+ * The one form for all four kinds: the template picks the fieldset (server-side), so the page
+ * needs no script. action is where it POSTs; submit is the button's words.
+ */
+ function watchForm({ template, fields, errors, action, submit }) {
+ const t = TEMPLATES[template];
+ const cadence = select('cadence', CADENCE_CHOICES, fields.cadence);
+ const opChoices = t.ops.map((op) => ({ value: op, label: OPS[op] }));
+ const opControl = t.ops.length > 1
+ ? select('op', opChoices, fields.op)
+ : html`<input type="hidden" name="op" value="${t.ops[0]}"><span class="fixed">fires when it ${OPS[t.ops[0]]}</span>`;
+
+ const needsValue = t.ops.some((op) => NEEDS_VALUE.has(op));
+ const conditionBlock = html`<fieldset><legend>When should it fire?</legend>
+${field('Condition', opControl, { name: 'op', error: errors.op })}
+${needsValue ? field('Value', html`<input type="text" id="${idOf('value')}" name="value" value="${fields.value}" maxlength="200">`, { name: 'value', error: errors.value, hint: 'The value to compare the reading against. Numbers are compared as numbers.' }) : ''}
+${(template === 'price' || template === 'json') ? field('Hold for (seconds)', html`<input type="number" id="${idOf('for_sec')}" name="for_sec" value="${fields.for_sec}" min="0" max="2592000" inputmode="numeric">`, { name: 'for_sec', error: errors.for_sec, hint: 'Blank fires as soon as the condition is met; a number makes it wait that long and stay true.' }) : ''}
+</fieldset>`;
+
+ let extractionBlock;
+ if (template === 'page') {
+ extractionBlock = html`<fieldset><legend>What should it read?</legend>
+${field('CSS selector', html`<input type="text" id="${idOf('selector')}" name="selector" value="${fields.selector}" maxlength="300" placeholder="e.g. .price (blank: the whole page text)">`, { name: 'selector', error: errors.selector, hint: 'The text of the first element matching this selector. Leave it blank to read the whole page text.' })}
+</fieldset>`;
+ } else if (template === 'price') {
+ extractionBlock = html`<fieldset><legend>How should it read the price?</legend>
+${field('Read it with', select('extraction', [{ value: 'css', label: 'a CSS selector' }, { value: 'regex', label: 'a regular expression' }], fields.extraction), { name: 'extraction', error: errors.extraction, hint: 'A CSS selector reads one element; a regular expression reads the first match (its first group when it has one).' })}
+${field('Selector or pattern', html`<input type="text" id="${idOf('selector')}" name="selector" value="${fields.selector}" maxlength="300" placeholder=".price or (\\d+\\.\\d+) EUR">`, { name: 'selector', error: errors.selector })}
+</fieldset>`;
+ } else if (template === 'feed') {
+ extractionBlock = html`<fieldset><legend>What should it read?</legend>
+<p class="muted small">Watch reads the feed’s newest item (title, link, published time — all of it). The feed is read before the page is ever fetched and only when the feed says it changed.</p>
+</fieldset>`;
+ } else {
+ extractionBlock = html`<fieldset><legend>What should it read?</legend>
+${field('Value path', html`<input type="text" id="${idOf('value_path')}" name="value_path" value="${fields.value_path}" maxlength="200" placeholder="e.g. price, data.0.value">`, { name: 'value_path', error: errors.value_path, hint: 'A dot path into the JSON — `items.0.price`. Leave blank to watch the whole body.' })}
+</fieldset>`;
+ }
+
+ return html`<form class="watch-form" method="post" action="${action}">
+<input type="hidden" name="template" value="${template}">
+<input type="hidden" name="kind" value="${t.kind}">
+<fieldset><legend>What to watch</legend>
+${field('Name', html`<input type="text" id="${idOf('name')}" name="name" value="${fields.name}" maxlength="120" placeholder="e.g. Laptop price at the usual shop">`, { name: 'name', error: errors.name, hint: 'Optional. Without one, Watch names it after the address.' })}
+${field('Address', html`<input type="url" id="${idOf('url')}" name="url" value="${fields.url}" maxlength="2000" required placeholder="https://…">`, { name: 'url', error: errors.url, hint: html`This is ${t.kindWords}, fetched with the site’s own ETag/Last-Modified when it gives one.` })}
+</fieldset>
+${extractionBlock}
+${conditionBlock}
+<fieldset><legend>How often, and how to tell you</legend>
+${field('Cadence', cadence, { name: 'cadence', error: errors.cadence, hint: 'Watch never checks more often than this, and can wait longer when the site asks it to.' })}
+${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'A fired condition becomes a WATCH_TRIGGERED notification through OpenVibe.Network, as today.' })}
+</fieldset>
+<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>
+<p class="muted small">Actions are step 4 of the plan: a fired condition records its trigger and emits <code>watch.watch.triggered</code> today, and no other action is dispatched yet — turning notifications off does not silence that until actions run.</p>
+</form>`;
+ }
+
+ /** The GET chooser above the form, so a kind can be picked without any JavaScript. */
+ const templateChooser = (template) => html`<form class="template-chooser" method="get" action="/watches/new">
+<label for="template">What kind of watch?</label>
+<select id="template" name="template">${TEMPLATE_NAMES.map((n) => html`<option value="${n}"${n === template ? raw(' selected') : ''}>${TEMPLATES[n].card}</option>`)}</select>
+<button class="btn" type="submit">Show those fields</button>
+</form>`;
+
+ // ── Home ────────────────────────────────────────────────────────────────────────────────
+
+ r.get('/', async (req, res) => {
+ let summary = null;
+ if (signedIn(req)) {
+ const rows = await db.prepare('SELECT status, COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL GROUP BY status').all(subjectOf(req));
+ const counts = { active: 0, paused: 0, disabled: 0, failed: 0, total: 0 };
+ for (const row of rows) { const n = Number(row.n); counts[row.status] = n; counts.total += n; }
+ summary = counts;
+ }
+ const showcase = require('openvibe-shared/showcase');
+ const yours = summary && summary.total
+ ? `You have ${summary.total} watch${summary.total === 1 ? '' : 'es'}: ${summary.active} active, ${summary.paused} paused, ${summary.failed} failing.`
+ : null;
+ const hero = showcase.hero({
+ eyebrow: 'OpenVibe.Watch · pages, feeds and APIs',
+ title: 'Know the moment it changes.',
+ accent: 'Checked politely, never guessed.',
+ lede: 'Give Watch a page, a feed or a JSON API, say which value you care about and when to tell you. It checks on the cadence you pick, records what every check found, and tells you when your condition fires.',
+ actions: signedIn(req)
+ ? [{ label: 'New watch', href: '/watches/new?template=page', primary: true }, { label: 'Your watches', href: '/watches' }]
+ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fwatches%2Fnew%3Ftemplate%3Dpage', primary: true }, { label: 'How it checks', href: '#h-check' }],
+ note: yours || 'Free while it is in alpha. Reading this page needs no account.',
+ });
+ const ICONS = { page: 'ov:docs', price: 'ov:deals', feed: 'ov:news', json: 'ov:json' };
+ page(req, res, {
+ index: true,
+ cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }),
+ jsonLd: homeJsonLd(config),
+ description: DESCRIPTION,
+ styles: [showcase.STYLESHEET],
+ body: html`${raw(hero)}
+${raw(showcase.features({
+ id: 'start',
+ title: 'Start with one of these',
+ lede: 'Each opens the form filled in for that kind of watch; change anything before you save it.',
+ items: TEMPLATE_NAMES.map((n) => ({ icon: ICONS[n] || 'ov:bell', title: TEMPLATES[n].card, text: TEMPLATES[n].cardBlurb, href: `/watches/new?template=${n}` })),
+ }))}
+<p class="muted small watch-later">Pages, feeds and APIs today. Events, webhooks, browser checks through Run, Node probes and AI readings come later, and are not offered until Watch can really check them.</p>
+${raw(showcase.steps({
+ id: 'steps',
+ title: 'From a URL to a notification',
+ items: [
+ { title: 'Point it at something', text: 'A page (and, if you like, the CSS selector of the part you care about), an RSS or Atom feed, or a JSON API and a path into it.' },
+ { title: 'Say when to tell you', text: 'Any change, a number above or below a value, text that appears or disappears, a new item in a feed — held for a while if you want it steady.' },
+ { title: 'Pick a cadence', text: 'Every 15 minutes, hourly, every 6 hours or daily. Watch never checks faster than that, and slower when a site asks it to wait.' },
+ { title: 'Get told', text: 'When the condition fires, the notification arrives on your OpenVibe account, and the watch page shows every check and the value it found.' },
+ ],
+ }))}
+<section class="sc-sec" aria-labelledby="h-check"><h2 id="h-check">How it checks</h2>
+<ul class="facts">
+<li><strong>The cheapest way first.</strong> If the site gave Watch an ETag or Last-Modified, the next check asks with <code>If-None-Match</code>/<code>If-Modified-Since</code> and a “not modified” answer costs no body. When only “did it change?” matters, a <code>HEAD</code> is tried while a validator is held: two headers instead of a page.</li>
+<li><strong>Feeds and APIs before pages.</strong> The carrier order is binding: an event or webhook when one exists, then the etag, then a feed, then an API, and only then an expensive browser check. A watch checks the kind its source names.</li>
+<li><strong>Polite per-site spacing.</strong> Two requests to one host never leave at once, and no two requests to a host are closer than the host’s minimum interval — whatever the watch says.</li>
+<li><strong>Never faster than your cadence.</strong> A watch is due on the cadence you pick; a failing site is checked less often (backoff), and a site that asks Watch to wait is waited for (<code>Retry-After</code>).</li>
+<li><strong>Every check is recorded.</strong> Read, no change, changed, condition met, a site error, a timeout, an unreadable answer, a wait, a skipped kind — the state is kept with the value, the time and a capped snapshot, and the watch is re-armed on every path.</li>
+</ul>
+<p><a href="/how-it-works">The full picture, in plain words</a>.</p></section>
+${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-yours">Your watches</h2>
+<p>${summary.total ? html`You have <strong>${summary.total}</strong> watch${summary.total === 1 ? '' : 'es'}: ${summary.active} active, ${summary.paused} paused, ${summary.disabled} disabled, ${summary.failed} failing.` : 'You have no watches yet.'} <a href="/watches">See them</a> or <a href="/watches/new?template=page">start one</a>.</p></section>` : ''}`,
+ });
+ });
+
+ // ── Your watches ────────────────────────────────────────────────────────────────────────
+
+ const PAGE = 25;
+ const beforeId = (q) => (/^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(q || '')) ? String(q) : null);
+
+ r.get('/watches', async (req, res) => {
+ if (!signedIn(req)) {
+ return page(req, res, {
+ title: 'Your watches',
+ body: html`<h1>Your watches</h1>
+<p>Sign in to see and manage your watches. Each one belongs to the OpenVibe account that made it and to nobody else.</p>
+<p><a class="btn primary" href="/auth/login?next=%2Fwatches">Sign in with OpenVibe</a> <a class="btn" href="/">Back home</a></p>`,
+ });
+ }
+ const before = beforeId(req.query.before);
+ const rows = await registry.all(principalOf(req), { before, limit: PAGE });
+ const withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));
+ const watchRows = withLatest.map(({ row, latest }) => {
+ const v = registry.view(row);
+ return [
+ html`<a href="/watches/${row.id}">${v.name}</a>`,
+ html`<code>${W.host(v.source.url)}</code>`,
+ W.conditionWords(v.condition),
+ html`${badge(W.statusWords(v.status), W.statusKind(v.status))}`,
+ v.health.last_state
+ ? html`${W.stateWords(v.health.last_state)} <span class="muted small">${v.health.last_check_at ? time(v.health.last_check_at) : ''}</span>`
+ : html`<span class="muted">not checked yet</span>`,
+ latest ? W.valueWords(latest.value, 60) : html`<span class="muted">—</span>`,
+ v.health.next_due_at ? time(v.health.next_due_at) : html`<span class="muted">—</span>`,
+ ];
+ });
+ const older = rows.length === PAGE ? `/watches?before=${rows[rows.length - 1].id}` : null;
+ page(req, res, {
+ title: 'Your watches', private: true,
+ body: html`<h1>Your watches</h1>
+<p><a class="btn primary" href="/watches/new?template=page">New watch</a></p>
+${table(['Name', 'Source', 'Condition', 'Status', 'Last check', 'Last value', 'Next due'], watchRows, { empty: 'You have no watches yet. Start with one of the four kinds on the home page.' })}
+<nav class="pager" aria-label="Pages">
+${before ? html`<a class="btn" href="/watches">Newest first</a>` : ''}
+${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span class="muted">That is every watch here.</span>`}
+</nav>`,
+ });
+ });
+
+ // ── New watch ───────────────────────────────────────────────────────────────────────────
+
+ const getTemplate = (q) => (TEMPLATE_NAMES.includes(String(q)) ? String(q) : 'page');
+
+ r.get('/watches/new', (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const template = getTemplate(req.query.template);
+ page(req, res, {
+ title: `New watch: ${TEMPLATES[template].card}`, private: true,
+ body: html`<h1>New watch</h1>
+<p class="muted">${TEMPLATES[template].title}.</p>
+${templateChooser(template)}
+${watchForm({ template, fields: { ...blankFields(template), url: String(req.query.url || '') }, errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,
+ });
+ });
+
+ r.post('/watches/new', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches%2Fnew');
+ const template = getTemplate((req.body || {}).template);
+ if (!sameOrigin(req, config.baseUrl)) {
+ return fail(req, res, 403, 'Cross-site request refused', html`<h1>Cross-site request refused</h1><p>Creating a watch must come from openvibe.watch itself.</p><p><a href="/watches/new?template=${template}">Back to the form</a></p>`);
+ }
+ const fields = postedFields(template, req.body);
+ const limited = ctx.siteLimits.take('watch.write', subjectOf(req));
+ if (!limited.ok) {
+ res.set('Retry-After', String(limited.retryAfter));
+ return fail(req, res, 429, 'Slow down', html`<h1>Slow down</h1><p>You have made a lot of watches in a short time. Try again in ${limited.retryAfter} seconds.</p>`);
+ }
+ const built = buildBody(fields, template);
+ let errors = built.errors;
+ if (!errors) {
+ try {
+ const row = await registry.create(built.body, principalOf(req));
+ return res.redirect(303, `/watches/${row.id}`);
+ } catch (err) {
+ if (!(err instanceof RegistryError)) throw err;
+ const field = registryField(err.message);
+ errors = { [field || 'form']: err.message };
+ }
+ }
+ page(req, res, {
+ title: `New watch: ${TEMPLATES[template].card}`, private: true,
+ body: html`<h1>New watch</h1>
+${errors.form ? notice(errors.form, 'warn') : ''}
+<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and create it again.</p>
+${templateChooser(template)}
+${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create this watch' })}`,
+ }, 422);
+ });
+
+ // ── One watch ───────────────────────────────────────────────────────────────────────────
+
+ /** The watch when it exists and belongs to the signed-in person; otherwise the 404 page. */
+ async function owned(req, res) {
+ if (!signedIn(req)) return null;
+ const row = await registry.get(req.params.id, principalOf(req));
+ return row || null;
+ }
+ const notFoundPage = (req, res) => fail(req, res, 404, 'Watch not found', html`<h1>Watch not found</h1>
+<p>There is no watch here with that id, or it belongs to someone else. A watch is private to the account that made it.</p>
+<p><a href="/watches">Your watches</a></p>`);
+
+ const obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);
+ const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
+
+ r.get('/watches/:id', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ const v = registry.view(row);
+ const latest = await observations.latest(row.id);
+ const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });
+ const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });
+
+ const obsRows = obs.map((o) => [
+ time(new Date(Number(o.observed_at)).toISOString()),
+ W.valueWords(o.value, 120),
+ o.changed ? badge('changed', 'info') : html`<span class="muted">no</span>`,
+ o.condition_met ? badge('met', 'ok') : html`<span class="muted">no</span>`,
+ ]);
+ const runRows = runs.map((run) => {
+ const view = check.runView(run);
+ return [
+ time(view.started_at),
+ html`${badge(W.stateWords(view.state, view), W.stateKind(view.state))} <span class="muted small">${view.trigger}</span>`,
+ view.http_status == null ? html`<span class="muted">—</span>` : String(view.http_status),
+ view.detail ? html`<span class="muted small">${view.detail}</span>` : '',
+ view.observations ? '1 observation' : '',
+ view.triggers ? 'fired' : '',
+ ];
+ });
+
+ const ran = String(req.query.ran || '');
+ const actionForms = html`<div class="actions">
+${v.status === 'active'
+ ? html`<form class="inline" method="post" action="/watches/${v.id}/pause"><button class="btn" type="submit">Pause</button></form>`
+ : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="btn" type="submit">Resume</button></form>`}
+<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>
+<a class="btn" href="/watches/${v.id}/edit">Edit</a>
+<form class="inline" method="post" action="/watches/${v.id}/delete"><button class="btn danger" type="submit">Delete</button></form>
+</div>`;
+
+ const nextObs = obs.length === obsLimit(req.query.obs_limit) ? `/watches/${v.id}?obs_before=${obs[obs.length - 1].rid}` : null;
+ const nextRuns = runs.length === obsLimit(req.query.checks_limit) ? `/watches/${v.id}?checks_before=${runs[runs.length - 1].rid}` : null;
+
+ page(req, res, {
+ title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
+ body: html`<h1>${v.name}</h1>
+<p class="in-words">${W.inWords(v)}</p>
+${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}
+<dl class="facts">
+<dt>Status</dt><dd>${badge(W.statusWords(v.status), W.statusKind(v.status))}</dd>
+<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || '(none)'}</code></dd>
+<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : 'not polled'}</dd>
+<dt>Last check</dt><dd>${v.health.last_check_at ? html`${time(v.health.last_check_at)} — ${W.stateWords(v.health.last_state)}` : html`<span class="muted">not checked yet</span>`}</dd>
+<dt>Next due</dt><dd>${v.health.next_due_at ? time(v.health.next_due_at) : html`<span class="muted">—</span>`}</dd>
+${v.health.consecutive_failures ? html`<dt>Failing</dt><dd>${v.health.consecutive_failures} check(s) in a row</dd>` : ''}
+<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd>
+</dl>
+${actionForms}
+<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>
+${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet. Watch records one when a check reads a value.' })}
+<nav class="pager">${nextObs ? html`<a class="btn" href="${nextObs}">Older observations</a>` : html`<span class="muted">That is every observation kept.</span>`}</nav></section>
+<section aria-labelledby="h-runs"><h2 id="h-runs">Check runs</h2>
+<p class="muted small">Every check, whatever it found. A skipped kind, a site error and a wait are recorded states, not silence.</p>
+${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty: 'No checks yet.' })}
+<nav class="pager">${nextRuns ? html`<a class="btn" href="${nextRuns}">Older checks</a>` : html`<span class="muted">That is every check recorded.</span>`}</nav></section>
+<p class="muted small">In code: <code>GET /api/v1/watches/${v.id}</code>.</p>`,
+ });
+ });
+
+ // ── Pause, resume, delete, check now ────────────────────────────────────────────────────
+
+ function crossSite(req, res, what) {
+ return fail(req, res, 403, 'Cross-site request refused', html`<h1>Cross-site request refused</h1><p>${what} a watch must come from openvibe.watch itself.</p>`);
+ }
+ const actionLimit = (req, res, name) => {
+ const limited = ctx.siteLimits.take(name, subjectOf(req));
+ if (limited.ok) return true;
+ res.set('Retry-After', String(limited.retryAfter));
+ fail(req, res, 429, 'Slow down', html`<h1>Slow down</h1><p>You have done that a lot in a short time. Try again in ${limited.retryAfter} seconds.</p>`);
+ return false;
+ };
+
+ r.post('/watches/:id/pause', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
+ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Pausing');
+ if (!actionLimit(req, res, 'watch.write')) return;
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ await registry.patch(row.id, { status: 'paused' }, principalOf(req));
+ return res.redirect(303, `/watches/${row.id}`);
+ });
+
+ r.post('/watches/:id/resume', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
+ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Resuming');
+ if (!actionLimit(req, res, 'watch.write')) return;
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ await registry.patch(row.id, { status: 'active' }, principalOf(req));
+ return res.redirect(303, `/watches/${row.id}`);
+ });
+
+ r.post('/watches/:id/delete', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
+ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Deleting');
+ if (!actionLimit(req, res, 'watch.write')) return;
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ await registry.remove(row.id, principalOf(req));
+ return res.redirect(303, '/watches');
+ });
+
+ r.post('/watches/:id/check', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
+ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Checking');
+ if (!actionLimit(req, res, 'watch.check.run')) return;
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ const out = await check.run(row.id, { trigger: 'manual' });
+ if (!out) return notFoundPage(req, res);
+ // The API's 409s and 422, said in words: a second check while one runs, a watch that is not
+ // active, and a kind whose carrier is a later step (which fetches nothing).
+ if (out.busy) return detailNotice(req, res, row, 409, 'A check of this watch is already running. Try again in a moment.');
+ if (out.disabled) return detailNotice(req, res, row, 409, `This watch is ${row.status}, so it is not checked. Resume it first.`);
+ if (out.unavailable) return detailNotice(req, res, row, 422, out.unavailable.detail);
+ return res.redirect(303, `/watches/${row.id}?ran=${encodeURIComponent(out.run ? out.run.state : '')}`);
+ });
+
+ /** Render the watch page with one notice, without a redirect (the API's 409s and 422). */
+ async function detailNotice(req, res, row, status, text) {
+ const v = registry.view(row);
+ return page(req, res, {
+ title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
+ body: html`<h1>${v.name}</h1>
+${notice(text, status === 401 || status === 409 ? 'warn' : 'info')}
+<p>${W.inWords(v)}</p>
+<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>`,
+ }, status);
+ }
+
+ // ── Edit ────────────────────────────────────────────────────────────────────────────────
+
+ r.get('/watches/:id/edit', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ const v = registry.view(row);
+ const fields = fieldsOfWatch(v);
+ page(req, res, {
+ title: `Edit ${v.name}`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }, { label: 'Edit' }],
+ body: html`<h1>Edit: ${v.name}</h1>
+<p class="muted">The kind is fixed by the watch's source; edit what it reads and when it fires.</p>
+${watchForm({ template: fields.template, fields, errors: {}, action: `/watches/${v.id}/edit`, submit: 'Save changes' })}`,
+ });
+ });
+
+ r.post('/watches/:id/edit', formBody, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
+ const row = await owned(req, res);
+ if (!row) return notFoundPage(req, res);
+ const id = row.id;
+ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Changing');
+ if (!actionLimit(req, res, 'watch.write')) return;
+ const template = getTemplate((req.body || {}).template);
+ const fields = { ...fieldsOfWatch(registry.view(row)), ...(req.body || {}) };
+ fields.notify = req.body && req.body.notify === 'on' ? 'on' : '';
+ const built = buildBody(fields, template);
+ let errors = built.errors;
+ if (!errors) {
+ try {
+ await registry.patch(id, built.body, principalOf(req));
+ return res.redirect(303, `/watches/${id}`);
+ } catch (err) {
+ if (!(err instanceof RegistryError)) throw err;
+ const field = registryField(err.message);
+ errors = { [field || 'form']: err.message };
+ }
+ }
+ const v = registry.view(row);
+ page(req, res, {
+ title: `Edit ${v.name}`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }, { label: 'Edit' }],
+ body: html`<h1>Edit: ${v.name}</h1>
+${errors.form ? notice(errors.form, 'warn') : ''}
+<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and save again.</p>
+${watchForm({ template, fields, errors, action: `/watches/${id}/edit`, submit: 'Save changes' })}`,
+ }, 422);
+ });
+
+ // ── How it works, and the update log ────────────────────────────────────────────────────
+
+ r.get('/how-it-works', (req, res) => page(req, res, {
+ index: true, cache: cache.htmlHeaders({ maxAge: 3600 }),
+ title: 'How it checks',
+ description: 'How OpenVibe.Watch decides what to fetch and when: the carrier order, the conditional GET, the polite spacing, the cadence floor, and what every check state means.',
+ body: html`<h1>How it checks</h1>
+<p class="lede">A watch is a promise to look, not a promise to keep asking. Watch knows several ways to find out whether something changed, and it always takes the cheapest one the source allows.</p>
+<h2>The order</h2>
+<ol>
+<li><strong>An event or a webhook</strong> — when the thing can push to Watch, nothing is polled at all. (Not built yet: a watch of that kind is accepted but never fetched, and says so.)</li>
+<li><strong>The site's own ETag or Last-Modified</strong> — if the site gave one, the check carries <code>If-None-Match</code>/<code>If-Modified-Since</code>. A <code>304 Not Modified</code> means “no change” and no body is read.</li>
+<li><strong>A feed</strong> — an RSS or Atom feed is read before the page it describes, and the feed's own conditional headers apply.</li>
+<li><strong>An API</strong> — a JSON endpoint is read the same way, mapped by the watch itself.</li>
+<li><strong>A page</strong> — the page's text is read, as the whole body or one element.</li>
+<li><strong>An expensive browser check through OpenVibe.Run</strong> — the last resort, not built yet. A <code>run</code> watch is accepted but never fetched until it is.</li>
+</ol>
+<h2>The manners</h2>
+<ul>
+<li><strong>Per-site spacing.</strong> No two requests to one host are made at once, and none closer together than the host's minimum interval — whatever the watch says.</li>
+<li><strong>Never faster than the cadence.</strong> A watch is due on the cadence you choose (the shortest here is 15 minutes). A failing site is checked less and less often (backoff), up to a cap.</li>
+<li><strong>When the site asks you to wait.</strong> A <code>429</code> or <code>503</code> with <code>Retry-After</code> is obeyed: the watch is not checked again before that time, and the check is recorded <em>“the site asked us to wait”</em>.</li>
+<li><strong>Conditional outcomes cost nothing.</strong> A <code>304</code> or a body identical to the last one records no observation — but the condition is still evaluated, so “this has been true for five minutes” can fire while nothing moved.</li>
+</ul>
+<h2>What a check state means</h2>
+${table(['State', 'In plain words'], [
+ ['ok', 'Read, and nothing changed since the last reading.'],
+ ['not_modified', 'The site said “not modified” — no body was read.'],
+ ['no_change', 'The body was read and was identical to last time.'],
+ ['changed', 'The value changed.'],
+ ['condition_met', 'The condition fired (and a notification was emitted).'],
+ ['http_error', 'The site answered an error.'],
+ ['timeout', 'The site did not answer in time.'],
+ ['parse_error', 'The answer could not be read as what the watch expected.'],
+ ['rate_limited', 'The site asked Watch to wait.'],
+ ['budget_exceeded', "The watch's own budget was reached."],
+ ['skipped', 'This kind of check is not run yet (or an AI reading is not supported yet). Nothing was fetched.'],
+ ['disabled', 'The watch is paused, disabled or failing, so it was not checked.'],
+ ].map(([state, words]) => [html`<code>${state}</code>`, words]))}
+<h2>What is honest about it</h2>
+<ul>
+<li>A value the source did not state is <code>null</code>; Watch never invents one, and an absent value stays absent.</li>
+<li>A failed check is a recorded state, never a silent skip; a failed check records no observation.</li>
+<li>A site that is unavailable is retried with backoff, not hammered.</li>
+<li>The kinds that arrive later (event, webhook, Run, Node) and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot.</li>
+</ul>
+<p><a href="/watches/new?template=page">Start a watch</a> · <a href="/">Back home</a></p>`,
+ }));
+
+ r.get('/updates', (req, res) => page(req, res, {
+ index: true, cache: cache.htmlHeaders({ maxAge: 3600 }),
+ title: 'What shipped on OpenVibe.Watch',
+ description: 'OpenVibe.Watch’s own changelog: what each release added.',
+ body: html`<h1>What shipped on OpenVibe.Watch</h1>
+<div class="changelog">${changelog.body()}</div>`,
+ }));
+
+ // ── Crawl artifacts (robots, sitemap, llms.txt) ─────────────────────────────────────────
+ r.use(createDiscoveryRoutes({ config }));
+ return r;
+}
+
+module.exports = { createPageRoutes, TEMPLATES, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+/**
+ * The public site's own write budgets, with the same numbers as the API's per-actor limits
+ * (server/api/actor-limits.js): making or changing a watch is 30 a minute / 600 an hour, and a manual
+ * check is 10 a minute / 120 an hour, counted per signed-in subject. The site renders a friendly page
+ * when a budget is spent instead of the API's problem+json, but the numbers are one policy, stated
+ * once here and in the README.
+ *
+ * Counters live in this process: a restart forgets them, exactly like the API's.
+ */
+const BUDGETS = {
+ 'watch.write': { minute: 30, hour: 3600 },
+ 'watch.check.run': { minute: 10, hour: 7200 },
+};
+
+function createSiteLimits({ now = () => Date.now() } = {}) {
+ const counters = new Map(); // `${name} ${actor}` -> { minute: [start, n], hour: [start, n] }
+
+ /** Count one action; { ok, retryAfter } — retryAfter in seconds when refused. */
+ function take(name, actor) {
+ const budget = BUDGETS[name];
+ if (!budget || !actor) return { ok: true };
+ const t = now();
+ const key = `${name} ${actor}`;
+ let c = counters.get(key);
+ if (!c) { c = {}; counters.set(key, c); }
+ for (const window of ['minute', 'hour']) {
+ const span = window === 'minute' ? 60_000 : 3_600_000;
+ const cell = c[window];
+ if (!cell || t - cell[0] >= span) c[window] = [t, 1];
+ else cell[1] += 1;
+ if (c[window][1] > budget[window]) {
+ return { ok: false, retryAfter: Math.max(1, Math.ceil((c[window][0] + span - t) / 1000)) };
+ }
+ }
+ return { ok: true };
+ }
+
+ return { take, BUDGETS };
+}
+
+module.exports = { createSiteLimits, BUDGETS };
@@ -25,6 +25,9 @@ const { createOutbox, createRelay } = require('./events/outbox');
@@ -25,6 +25,9 @@ const { createOutbox, createRelay } = require('./events/outbox');
const { jwksClient } = require('openvibe-sdk/auth');
const { jwksClient } = require('openvibe-sdk/auth');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { createAuth } = require('./auth');
const { createAuth } = require('./auth');
+const { createSessions } = require('./sessions');
+const { createKeyStore } = require('./auth/keys');
+const { createSiteLimits } = require('./http/site-limits');
const { createApp } = require('./app');
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 } = {}) {
async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null } = {}) {
@@ -50,7 +53,12 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -50,7 +53,12 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const scheduler = createScheduler({ db, check, config, now, log });
const scheduler = createScheduler({ db, check, config, now, log });
const keys = jwksClient(config.jwksUrl, { fetch: fetchImpl, log });
const keys = jwksClient(config.jwksUrl, { fetch: fetchImpl, log });
const auth = createAuth({ config, log });
const auth = createAuth({ config, log });
- const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow });
+ // The public site: sessions in Watch's own database, the Network key to verify a sign-in token
+ // against (the same shared JWKS client), and the site's write budgets.
+ const sessions = createSessions({ db, now });
+ const keyStore = createKeyStore({ config, fetchImpl, log });
+ const siteLimits = createSiteLimits({ now: limitsNow || (() => Date.now()) });
+ const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow, sessions, keyStore, siteLimits });
// One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
// 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.
// on an unref'd timer, keeping the last good keys through a Network outage.
keys.start();
keys.start();
@@ -59,6 +67,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -59,6 +67,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (config.worker.enabled) scheduler.start();
if (config.worker.enabled) scheduler.start();
const pruneTimer = setInterval(async () => {
const pruneTimer = setInterval(async () => {
try { await observations.prune(); } catch (err) { log.error(`[observations] prune: ${err.message}`); }
try { await observations.prune(); } catch (err) { log.error(`[observations] prune: ${err.message}`); }
+ try { await sessions.prune(); } catch (err) { log.error(`[sessions] prune: ${err.message}`); }
}, 3600 * 1000);
}, 3600 * 1000);
pruneTimer.unref?.();
pruneTimer.unref?.();
const outboxPruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
const outboxPruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
@@ -86,7 +95,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -86,7 +95,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
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 };
+ return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, sessions, keyStore, siteLimits, app, server, close };
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * The update log (/updates) renders Watch's own CHANGELOG.md. Only the few markdown shapes a
+ * changelog uses are understood — headings, bullet lists and paragraphs, with **bold** and `code`
+ * inline — and the text is escaped first, so nothing a changelog says can become markup.
+ */
+const fs = require('fs');
+const path = require('path');
+const { html, raw, esc } = require('./html');
+
+const FILE = path.join(__dirname, '..', '..', 'CHANGELOG.md');
+let cache = null;
+
+function read() {
+ if (cache) return cache;
+ try { cache = fs.readFileSync(FILE, 'utf8'); } catch { cache = '# Changelog\n\nNothing has been recorded yet.\n'; }
+ return cache;
+}
+
+const inline = (s) => raw(String(s)
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+ .replace(/`([^`]+)`/g, '<code>$1</code>'));
+
+/** Render the changelog as escaped HTML: h2/h3, <ul><li>, <p>. */
+function renderMarkdown(md) {
+ const out = [];
+ let list = null;
+ const flush = () => { if (list) { out.push(`<ul>${list.join('')}</ul>`); list = null; } };
+ for (const line of String(md).split(/\r?\n/)) {
+ const t = line.trim();
+ if (!t) { flush(); continue; }
+ if (t.startsWith('### ')) { flush(); out.push(html`<h3>${inline(esc(t.slice(4)))}</h3>`); continue; }
+ if (t.startsWith('## ')) { flush(); out.push(html`<h2>${inline(esc(t.slice(3)))}</h2>`); continue; }
+ if (t.startsWith('# ')) { flush(); out.push(html`<h2>${inline(esc(t.slice(2)))}</h2>`); continue; }
+ if (/^[-*] /.test(t)) { list = list || []; list.push(html`<li>${inline(esc(t.slice(2)))}</li>`); continue; }
+ flush();
+ out.push(html`<p>${inline(esc(t))}</p>`);
+ }
+ flush();
+ return html`${out}`;
+}
+
+const body = () => renderMarkdown(read());
+
+module.exports = { body, renderMarkdown };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+/** A <table> from headers and rows of cells (cells are already html or plain values). */
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
+function problemBox(p, { title = 'The request failed' } = {}) {
+ if (!p) return '';
+ return html`<div class="problem" role="alert"><strong>${title}</strong>
+<p>${p.detail || 'No detail was given.'}</p>
+<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without
+ * JavaScript: forms POST, links navigate, and the signed-out pages say what they mean:
+ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise),
+ * Open Graph/Twitter, JSON-LD (openvibe-shared/seo), the shared app icon and the stylesheet
+ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the
+ * navbar, the footer is initialised below), a <noscript> account bar and the rendered footer
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const ovServe = require('openvibe-shared/serve');
+const appIcon = require('openvibe-shared/app-icon');
+const frame = require('openvibe-shared/frame');
+const shell = require('openvibe-shared/shell');
+const cache = require('openvibe-shared/cache-policy');
+const { html, raw, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Watch';
+const TAGLINE = 'Get told when a page, a feed or an API changes.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+// The product's navigation: one entry per public page it serves.
+const NAV = [
+ { label: 'Your watches', href: '/watches' },
+ { label: 'How it checks', href: '/how-it-works' },
+ { label: 'What shipped', href: '/updates' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+// The deployed release (server/app.js sets it): openvibe-shared/boost swaps a page in place only
+// between pages of the same release, and does a normal load across a deploy.
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+/** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], crumbs, jsonLd */
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const loginNext = encodeURIComponent(pathNow);
+ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const nav = {
+ service: 'watch',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ const footer = { service: 'watch', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
+ const account = signedIn
+ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'watch' })}
+${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+<link rel="stylesheet" href="${asset('css/app.css')}">
+${render(scripts)}
+<meta name="ov-boost" content="watch@${RELEASE}">
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
+<main id="main" class="page">
+${render(crumbs)}
+${render(o.body)}
+${o.path === '/' ? frame.shipped({ service: 'watch', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+function render(v) { return require('./html').render(v); }
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = (o.viewer && o.viewer.kind === 'user') || o.private;
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV, raw, html };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * A watch in words. Every page that shows a watch, an extraction, a condition or a check state says
+ * what it means in plain sentences instead of printing the JSON the contract carries — the README's
+ * honesty rule applied to the site: the words are derived from the stored watch and the recorded
+ * state, never a guess, and everything goes through the escaping `html` tag.
+ */
+const { html } = require('./html');
+
+const CADENCES = [
+ { sec: 900, label: 'every 15 minutes' },
+ { sec: 3600, label: 'every hour' },
+ { sec: 21600, label: 'every 6 hours' },
+ { sec: 86400, label: 'every day' },
+];
+/** The cadences the form offers a person; the contract's floor is 30 s, the site's floor is 15 min. */
+const CADENCE_OPTIONS = CADENCES.map((c) => c.sec);
+const MIN_CADENCE_SEC = 900;
+
+function everyWords(sec) {
+ const n = Number(sec);
+ if (!Number.isFinite(n) || n <= 0) return 'on no cadence';
+ const known = CADENCES.find((c) => c.sec === n);
+ if (known) return known.label;
+ if (n % 86400 === 0) return `every ${n / 86400} day${n / 86400 === 1 ? '' : 's'}`;
+ if (n % 3600 === 0) return `every ${n / 3600} hour${n / 3600 === 1 ? '' : 's'}`;
+ if (n % 60 === 0) return `every ${n / 60} minute${n / 60 === 1 ? '' : 's'}`;
+ return `every ${n} seconds`;
+}
+
+const host = (url) => { try { return new URL(url).host; } catch { return url ? String(url) : 'an unnamed source'; } };
+const short = (s, n = 80) => { const t = String(s == null ? '' : s); return t.length <= n ? t : `${t.slice(0, n - 1)}…`; };
+
+/** The extracted value, as text, shortened. Objects are shown as their JSON, escaped. */
+function valueWords(value, max = 90) {
+ if (value === undefined || value === null) return html`<span class="muted">nothing stated</span>`;
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
+ return html`<code title="${String(text).slice(0, 200)}">${short(text, max)}</code>`;
+}
+
+function extractionWords(extraction) {
+ const e = extraction || {};
+ if (e.kind === 'text') return 'the page text';
+ if (e.kind === 'html' || e.kind === 'css') return e.selector ? html`the text of <code>${e.selector}</code>` : 'the page text';
+ if (e.kind === 'regex') return html`the first match of <code>${e.selector}</code>`;
+ if (e.kind === 'json' || e.kind === 'jsonpath') return e.value_path ? html`the value at <code>${e.value_path}</code>` : 'the value';
+ if (e.kind === 'ai') return 'an AI reading (not supported yet)';
+ return 'the value';
+}
+
+function conditionWords(condition, { forSec = true } = {}) {
+ const c = condition || {};
+ const v = c.value;
+ let core;
+ switch (c.op) {
+ case 'changed': core = 'changes'; break;
+ case 'gt': core = html`goes above <b>${v}</b>`; break;
+ case 'gte': core = html`reaches <b>${v}</b> or more`; break;
+ case 'lt': core = html`drops below <b>${v}</b>`; break;
+ case 'lte': core = html`is <b>${v}</b> or less`; break;
+ case 'eq': core = html`equals <b>${v}</b>`; break;
+ case 'ne': core = html`is not <b>${v}</b>`; break;
+ case 'contains': core = html`contains “${v}”`; break;
+ case 'matches': core = html`matches <code>${v}</code>`; break;
+ case 'exists': core = 'is present'; break;
+ case 'absent': core = 'is absent'; break;
+ default: core = 'meets its condition';
+ }
+ if (forSec && Number.isInteger(c.for_sec) && c.for_sec > 0) {
+ const sec = c.for_sec;
+ const dur = sec % 3600 === 0 ? `${sec / 3600} hour${sec / 3600 === 1 ? '' : 's'}` : sec % 60 === 0 ? `${sec / 60} minute${sec / 60 === 1 ? '' : 's'}` : `${sec} seconds`;
+ return html`${core} and stays that way for ${dur}`;
+ }
+ return core;
+}
+
+/** The watch as one sentence: "Tell me when … on …, checked every …". */
+function inWords(watch) {
+ const source = watch.source || {};
+ const what = source.kind === 'feed'
+ ? (watch.extraction && watch.extraction.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed')
+ : html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;
+ const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';
+ return html`Tell me when ${what} ${conditionWords(watch.condition)}${every}.`;
+}
+
+/** The plain words for one check state; `run` supplies the HTTP status a failure answered with. */
+function stateWords(state, run = null) {
+ switch (state) {
+ case 'ok': return 'Read, nothing new';
+ case 'not_modified': return 'No change (the site said so)';
+ case 'no_change': return 'No change';
+ case 'changed': return 'Changed';
+ case 'condition_met': return 'Condition met';
+ case 'http_error': return run && run.http_status ? `The site answered ${run.http_status}` : 'The site answered an error';
+ case 'timeout': return 'The site did not answer in time';
+ case 'parse_error': return 'The answer could not be read';
+ case 'rate_limited': return 'The site asked us to wait';
+ case 'budget_exceeded': return "The watch's budget was reached";
+ case 'skipped': return 'This kind of check is not run yet';
+ case 'disabled': return 'The watch is not active';
+ default: return state ? String(state) : 'No state recorded';
+ }
+}
+
+/** How a check state is pill-coloured. */
+function stateKind(state) {
+ if (state === 'changed' || state === 'condition_met') return 'ok';
+ if (state === 'http_error' || state === 'timeout' || state === 'parse_error' || state === 'rate_limited' || state === 'budget_exceeded') return 'bad';
+ if (state === 'skipped' || state === 'disabled') return 'warn';
+ return '';
+}
+
+const STATUS_WORDS = { active: 'Active', paused: 'Paused', disabled: 'Disabled', failed: 'Failing' };
+const statusWords = (status) => STATUS_WORDS[status] || String(status || 'unknown');
+const statusKind = (status) => (status === 'active' ? 'ok' : status === 'paused' ? 'warn' : status === 'failed' ? 'bad' : '');
+
+module.exports = {
+ inWords, conditionWords, extractionWords, valueWords, everyWords, stateWords, stateKind,
+ statusWords, statusKind, host, short, CADENCES, CADENCE_OPTIONS, MIN_CADENCE_SEC,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * Sign-in sessions for the public site, kept in Watch's own database (migration 0002).
+ *
+ * The browser holds an opaque random token in an httpOnly cookie (server/auth/sso.js sets it); this
+ * module stores only sha256(token), so a database read never hands out a live session. The row names
+ * the canonical subject (usr_…/agt_…) the watches belong to — the same authority the API scopes
+ * ownership by. The person's Network access token is never stored: it is verified once at sign-in and
+ * discarded, because the site acts on the registry in-process, not as the person over Network.
+ */
+const crypto = require('crypto');
+
+const DEFAULT_TTL_MS = 30 * 24 * 3600 * 1000;
+const TOUCH_INTERVAL_MS = 60 * 1000; // at most one last_seen_at write a minute per session
+
+const hash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
+
+function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS }) {
+ const st = {
+ insert: db.prepare(`INSERT INTO web_sessions (id, subject, username, display_name, role, created_at, last_seen_at, expires_at, revoked_at)
+ VALUES (@id, @subject, @username, @display_name, @role, @created_at, @last_seen_at, @expires_at, NULL)`),
+ get: db.prepare('SELECT * FROM web_sessions WHERE id = ?'),
+ touch: db.prepare('UPDATE web_sessions SET last_seen_at = ? WHERE id = ?'),
+ revoke: db.prepare('UPDATE web_sessions SET revoked_at = ? WHERE id = ?'),
+ prune: db.prepare('DELETE FROM web_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL'),
+ };
+
+ /** A fresh opaque token; only its hash is stored. */
+ const newToken = () => crypto.randomBytes(32).toString('base64url');
+
+ /**
+ * Create a session for a verified person. Returns the cookie token (shown once, never stored).
+ * The subject is required: a session with nobody's subject owns nothing and is refused.
+ */
+ async function create({ subject, username = null, displayName = null, role = null }) {
+ if (typeof subject !== 'string' || !subject) throw new Error('a session needs a subject');
+ const token = newToken();
+ const t = now();
+ await st.insert.run({
+ id: hash(token), subject,
+ username: username == null ? null : String(username).slice(0, 64),
+ display_name: displayName == null ? null : String(displayName).slice(0, 80),
+ role: role == null ? null : String(role).slice(0, 32),
+ created_at: t, last_seen_at: t, expires_at: t + ttlMs,
+ });
+ return token;
+ }
+
+ /**
+ * The session a cookie token names, or null when it is unknown, expired or revoked. A live
+ * session past its touch interval has last_seen_at moved (best effort: a failed write never
+ * refuses a request).
+ */
+ async function resolve(token) {
+ if (!token || typeof token !== 'string') return null;
+ const row = await st.get.get(hash(token));
+ if (!row) return null;
+ if (row.revoked_at != null) return null;
+ const t = now();
+ if (Number(row.expires_at) <= t) return null;
+ if (t - Number(row.last_seen_at) >= TOUCH_INTERVAL_MS) {
+ try { await st.touch.run(t, row.id); } catch { /* the session is still valid */ }
+ }
+ return row;
+ }
+
+ /** End one session (sign-out): the cookie token names it. */
+ async function revoke(token) {
+ if (!token) return false;
+ const out = await st.revoke.run(now(), hash(token));
+ return out.changes > 0;
+ }
+
+ /** Drop expired and revoked rows (called hourly from server/index.js). */
+ async function prune(at = now()) {
+ return (await st.prune.run(at)).changes;
+ }
+
+ /** The viewer shape the pages read (never includes the token). */
+ function viewer(row) {
+ if (!row) return { kind: 'anonymous' };
+ return {
+ kind: 'user',
+ subject: row.subject,
+ username: row.username || null,
+ displayName: row.display_name || row.username || 'you',
+ role: row.role || 'user',
+ };
+ }
+
+ return { create, resolve, revoke, prune, viewer, hash, DEFAULT_TTL_MS };
+}
+
+module.exports = { createSessions, DEFAULT_TTL_MS };
@@ -9,6 +9,7 @@ const nodeHttp = require('http');
@@ -9,6 +9,7 @@ const nodeHttp = require('http');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { load } = require('../server/config');
const { load } = require('../server/config');
const { start } = require('../server/index');
const { start } = require('../server/index');
+const { startNetwork } = require('./helpers/network');
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
@@ -52,10 +53,21 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
@@ -52,10 +53,21 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// 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.
// 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 jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
+ // A stand-in Network for the site's sign-in (token endpoint + PKCE), signing with the same key the
+ // JWKS above serves. OV_NETWORK_ISSUER keeps the API's expected issuer while OV_NETWORK_URL points
+ // the authorize/token endpoints here, so existing service/user tokens still verify unchanged.
+ const network = await startNetwork({ privateKey, issuer: ISSUER });
const config = load({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
PORT: '0',
PORT: '0',
+ BASE_URL: 'https://openvibe.watch',
+ COOKIE_SECURE: 'false',
+ OV_NETWORK_URL: network.url,
+ OV_NETWORK_INTERNAL_URL: network.url,
+ OV_NETWORK_ISSUER: ISSUER,
OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,
OV_NETWORK_JWKS_URL: `${jwksSite.origin}/api/.well-known/jwks`,
+ OV_OAUTH_CLIENT_ID: 'watch',
+ OV_OAUTH_CLIENT_SECRET: 'watch-secret',
WATCH_WORKER: worker,
WATCH_WORKER: worker,
WATCH_ALLOW_PRIVATE_HOSTS: allowPrivate ? '127.0.0.1' : '',
WATCH_ALLOW_PRIVATE_HOSTS: allowPrivate ? '127.0.0.1' : '',
WATCH_HOST_MIN_INTERVAL_MS: '0',
WATCH_HOST_MIN_INTERVAL_MS: '0',
@@ -67,7 +79,55 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
@@ -67,7 +79,55 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });
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}`;
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(); } };
+
+ /** A session cookie for a person, as /auth/callback would have set it. */
+ async function signIn(user) {
+ const token = await h.sessions.create({
+ subject: user.subject, username: user.username || null,
+ displayName: user.display_name || user.username || null, role: user.role || null,
+ });
+ return `watch_session=${token}`;
+ }
+
+ /**
+ * The whole browser sign-in, over HTTP: /auth/login → Network /oauth/authorize → /auth/callback,
+ * with the stand-in Network issuing the code against the PKCE challenge the site generated.
+ */
+ async function login(user, { next = '/watches' } = {}) {
+ const start = await get(`/auth/login?next=${encodeURIComponent(next)}`);
+ const loc = new URL(start.headers.get('location'));
+ const flow = (start.headers.getSetCookie ? start.headers.getSetCookie() : [start.headers.get('set-cookie')])
+ .map((c) => c && c.split(';')[0]).find((c) => c && c.startsWith('watch_oauth='));
+ const code = network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const cb = await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flow });
+ const cookies = cb.headers.getSetCookie ? cb.headers.getSetCookie() : [cb.headers.get('set-cookie')];
+ const session = cookies.map((c) => c && c.split(';')[0]).find((c) => c && c.startsWith('watch_session='));
+ return { status: cb.status, location: cb.headers.get('location'), cookie: session, start };
+ }
+
+ /** The HTTP client every site test uses: a page or a form, as a person (or nobody). */
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) {
+ const c = await signIn(o.as);
+ headers.cookie = headers.cookie ? `${c}; ${headers.cookie}` : c;
+ }
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body !== undefined ? 'POST' : 'GET');
+ // A form write comes from this site unless the test says otherwise (the cross-site case).
+ if (method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers.Origin) headers.origin = o.origin || base;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, location: res.headers.get('location'), text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ return { ...h, base, jwksSite, network, signIn, login, get, async stop() { await h.close(); await testdb.close(); await jwksSite.close(); await network.close(); } };
}
}
async function request(base, method, p, { token, body, headers = {} } = {}) {
async function request(base, method, p, { token, body, headers = {} } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * A stand-in for the half of OpenVibe.Network the site's sign-in uses: the authorization-code token
+ * endpoint (client `watch`, PKCE S256), revocation, and users with canonical subjects. It signs its
+ * session tokens with the same key test/helpers.js already serves as the JWKS, so a token minted
+ * here verifies offline exactly as a real one would.
+ *
+ * const net = await startNetwork({ privateKey, issuer });
+ * const u = net.addUser('ada');
+ * const challenge = …; // the S256 challenge the browser sent to /oauth/authorize
+ * const code = net.issueCode(u, challenge);
+ *
+ * It never touches the internet and knows nothing about watches.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+const b64url = (buf) => Buffer.from(buf).toString('base64url');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+async function startNetwork({ privateKey, issuer = 'https://openvibe.network', clientId = 'watch', clientSecret = 'watch-secret' } = {}) {
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), requests: [], tokens: [] };
+ let nextId = 1;
+
+ function addUser(username, { role = 'user' } = {}) {
+ const u = { id: nextId++, subject: ids.newId('user'), username, display_name: username, role };
+ st.users.set(u.id, u); st.byUsername.set(username, u);
+ return u;
+ }
+ function userToken(u, { ttl = 3600 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ const token = serviceAuth.signServiceToken({
+ iss: issuer, sub: String(u.id), subject_id: u.subject, username: u.username, display_name: u.display_name,
+ role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, privateKey);
+ st.tokens.push(token);
+ return token;
+ }
+
+ const problem = (json, status, error, description) => json(status, { error, error_description: description });
+
+ const srv = await listen(async (req, raw, json, res) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, query: url.search });
+ if (url.pathname === '/oauth/authorize') {
+ // A test that wants the browser half can follow this: the Network signs a person in and
+ // sends them back with a code bound to the PKCE challenge they sent.
+ const user = st.byUsername.get(url.searchParams.get('user') || '') || [...st.users.values()][0];
+ const redirect = url.searchParams.get('redirect_uri');
+ const challenge = url.searchParams.get('code_challenge') || '';
+ if (!user || !redirect || url.searchParams.get('response_type') !== 'code') return problem(json, 400, 'invalid_request', 'nothing to authorize');
+ const code = st.issueCode(user, challenge);
+ const back = new URL(redirect);
+ back.searchParams.set('code', code);
+ if (url.searchParams.get('state')) back.searchParams.set('state', url.searchParams.get('state'));
+ res.writeHead(302, { Location: back.toString() });
+ return res.end();
+ }
+ if (url.pathname === '/oauth/revoke') return json(200, {});
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const body = Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
+ if (body.client_id !== clientId || body.client_secret !== clientSecret) return problem(json, 401, 'invalid_client', 'bad client credentials');
+ if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);
+ const c = st.codes.get(body.code);
+ if (!c) return problem(json, 400, 'invalid_grant', 'unknown code');
+ st.codes.delete(body.code);
+ const challenge = b64url(crypto.createHash('sha256').update(String(body.code_verifier || '')).digest());
+ if (challenge !== c.challenge) return problem(json, 400, 'invalid_grant', 'PKCE verification failed');
+ return json(200, { access_token: userToken(c.user), refresh_token: `rt_${crypto.randomBytes(8).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
+ }
+ return json(404, { error: 'not found' });
+ });
+
+ function issueCode(user, challenge) {
+ const code = `code_${crypto.randomBytes(8).toString('hex')}`;
+ st.codes.set(code, { user, challenge });
+ return code;
+ }
+
+ return { url: srv.url, issuer, state: st, requests: st.requests, addUser, userToken, issueCode, close: srv.close };
+}
+
+module.exports = { startNetwork };
new file mode 100644
new file mode 100644
@@ -0,0 +1,356 @@
@@ -0,0 +1,356 @@
+'use strict';
+/**
+ * The public site (plan T18 step 8): sign-in with the stand-in Network, the signed-out pages, the
+ * four create templates against what the API would store, validation errors that keep the input,
+ * ownership 404 on every page and form, pause/resume/delete/check-now, cross-site refusal, and
+ * hostile names/values escaped everywhere.
+ */
+const assert = require('assert');
+const { boot, request, site, suite, userToken } = require('./helpers');
+
+const t = suite('site');
+let svc;
+let web;
+let me;
+let other;
+let ME;
+let OTHER;
+let session; // the cookie from a real sign-in
+
+const create = (fields) => svc.get('/watches/new', { cookie: session, form: fields });
+const indexOf = (res) => (String(res.location || '').match(/\/watches\/(wch_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1] || null;
+
+t('boot', async () => {
+ web = await site({ '/price': () => ({ body: '<html><body><span class="price">19.99</span></body></html>' }) });
+ svc = await boot();
+ me = svc.network.addUser('ada');
+ other = svc.network.addUser('grace');
+ ME = userToken(me.subject);
+ OTHER = userToken(other.subject);
+});
+
+t('the signed-out pages say what Watch is and ask for a sign-in', async () => {
+ const home = await svc.get('/', { headers: { accept: 'text/html' } });
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /OpenVibe\.Watch/);
+ assert.match(home.text, /template=page/);
+ assert.match(home.text, /template=price/);
+ assert.match(home.text, /template=feed/);
+ assert.match(home.text, /template=json/);
+ assert.match(home.text, /Sign in with OpenVibe/);
+ assert.strictEqual(home.headers.get('content-type').split(';')[0], 'text/html');
+
+ const watches = await svc.get('/watches');
+ assert.strictEqual(watches.status, 200);
+ assert.match(watches.text, /Sign in with OpenVibe/);
+
+ const redirect = await svc.get('/watches/new');
+ assert.strictEqual(redirect.status, 303);
+ assert.match(redirect.headers.get('location'), /^\/auth\/login\?next=/);
+
+ const one = await svc.get('/watches/wch_01J0000000000000000000000');
+ assert.strictEqual(one.status, 303, 'a signed-out watch page asks for a sign-in');
+
+ const how = await svc.get('/how-it-works');
+ assert.strictEqual(how.status, 200);
+ assert.match(how.text, /If-None-Match/);
+ assert.match(how.text, /15 minutes/);
+});
+
+t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {
+ const who = svc.network.addUser('ada'); // same username, distinct subject for this flow test
+ const out = await svc.login(who);
+ assert.strictEqual(out.status, 302);
+ assert.strictEqual(out.location, '/watches');
+ assert.ok(out.cookie && out.cookie.startsWith('watch_session='), 'a session cookie is set');
+
+ // /auth/me answers the signed-in person's identity and nothing more.
+ const meRes = await svc.get('/auth/me', { cookie: out.cookie });
+ assert.strictEqual(meRes.status, 200);
+ assert.strictEqual(meRes.json().user.subject_id, who.subject);
+ assert.strictEqual(meRes.json().user.username, 'ada');
+
+ // A garbage session cookie is a guest, not an error.
+ const guest = await svc.get('/auth/me', { cookie: 'watch_session=nonsense' });
+ assert.strictEqual(guest.status, 401);
+
+ // The token exchange really ran, with the PKCE verifier, against the stand-in Network.
+ assert.ok(svc.network.requests.some((r) => r.path === '/oauth/token'));
+
+ // Sign-out revokes the session: the same cookie no longer reads a person.
+ const bye = await svc.get('/auth/logout', { cookie: out.cookie, method: 'POST', form: {} });
+ assert.strictEqual(bye.status, 303);
+ const after = await svc.get('/watches', { cookie: out.cookie });
+ assert.match(after.text, /Sign in with OpenVibe/);
+
+ // The state must match the browser that started: a callback with the wrong state is refused.
+ const started = await svc.get('/auth/login?next=%2Fwatches');
+ const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));
+ const bad = await svc.get('/auth/callback?code=code_x&state=not-the-state', { cookie: flow });
+ assert.strictEqual(bad.status, 400);
+});
+
+t('a person signs in for the rest of the tests', async () => {
+ session = await svc.signIn(me);
+ const mine = await svc.get('/watches', { cookie: session });
+ assert.strictEqual(mine.status, 200);
+ assert.match(mine.text, /no watches yet/i);
+});
+
+t('each template creates exactly what the API would store, and the API reads it back', async () => {
+ const cases = [
+ ['page', { template: 'page', name: 'Home page', url: `${web.origin}/price`, selector: '.price', cadence: '3600', notify: 'on' }, {
+ name: 'Home page', source: { kind: 'http', url: `${web.origin}/price`, format: null },
+ cadence: { every_sec: 3600, jitter_sec: 0 }, extraction: { kind: 'html', selector: '.price' },
+ condition: { op: 'changed' }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ }],
+ ['price', { template: 'price', name: 'Laptop price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900', notify: 'on' }, {
+ name: 'Laptop price', source: { kind: 'http', url: `${web.origin}/price`, format: null },
+ cadence: { every_sec: 900, jitter_sec: 0 }, extraction: { kind: 'css', selector: '.price' },
+ condition: { op: 'lt', value: 20 }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ }],
+ ['feed', { template: 'feed', name: 'Releases', url: 'https://example.org/releases.atom', cadence: '21600', notify: 'on' }, {
+ name: 'Releases', source: { kind: 'feed', url: 'https://example.org/releases.atom', format: null },
+ cadence: { every_sec: 21600, jitter_sec: 0 }, extraction: { kind: 'json', value_path: 'latest' },
+ condition: { op: 'changed' }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ }],
+ ['json', { template: 'json', name: 'Stock', url: 'https://api.example.org/stock', value_path: 'data.count', op: 'gt', value: '0', cadence: '86400', notify: 'on' }, {
+ name: 'Stock', source: { kind: 'api', url: 'https://api.example.org/stock', format: null },
+ cadence: { every_sec: 86400, jitter_sec: 0 }, extraction: { kind: 'json', value_path: 'data.count' },
+ condition: { op: 'gt', value: 0 }, action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ }],
+ ];
+ for (const [label, form, expected] of cases) {
+ const created = await create(form);
+ assert.strictEqual(created.status, 303, `${label}: ${created.text}`);
+ const id = indexOf(created);
+ assert.ok(id, `${label}: a watch id came back`);
+ // The API, as the same person, reads it back and sees exactly the request the form meant.
+ const read = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME });
+ assert.strictEqual(read.status, 200, `${label}: ${read.text}`);
+ for (const [k, v] of Object.entries(expected)) assert.deepStrictEqual(read.body.watch[k], v, `${label}.${k}`);
+ assert.strictEqual(read.body.watch.owner, me.subject);
+ }
+});
+
+t('notify me off stores an event action instead of a notification, and says so', async () => {
+ const created = await create({ template: 'page', name: 'Quiet', url: 'https://example.org/quiet', cadence: '3600' });
+ assert.strictEqual(created.status, 303);
+ const read = await request(svc.base, 'GET', `/api/v1/watches/${indexOf(created)}`, { token: ME });
+ assert.deepStrictEqual(read.body.watch.action, [{ kind: 'event', event_type: 'watch.watch.triggered' }]);
+});
+
+t('validation errors re-render the form with the registry\'s message and the person\'s input', async () => {
+ const badUrl = await create({ template: 'page', name: 'Keep me', url: 'not-a-url', selector: '.x', cadence: '3600' });
+ assert.strictEqual(badUrl.status, 422);
+ assert.match(badUrl.text, /must start with http/);
+ assert.match(badUrl.text, /value="Keep me"/, 'the name is kept');
+ assert.match(badUrl.text, /value="\.x"/, 'the selector is kept');
+
+ const badCadence = await create({ template: 'page', url: 'https://example.org/a', cadence: '60' });
+ assert.strictEqual(badCadence.status, 422);
+ assert.match(badCadence.text, /shortest cadence here is 15 minutes/);
+
+ // A url the schema refuses (the registry's own message, mapped to the field).
+ const refused = await create({ template: 'json', url: 'ftp://example.org/x', cadence: '3600', op: 'gt', value: '1', value_path: 'n' });
+ assert.strictEqual(refused.status, 422);
+ assert.match(refused.text, /must start with http|url/);
+
+ // A condition that needs a value, and one outside the template's ops.
+ const noValue = await create({ template: 'price', url: 'https://example.org/p', extraction: 'css', selector: '.p', op: 'lt', value: '' });
+ assert.strictEqual(noValue.status, 422);
+ assert.match(noValue.text, /needs a value/);
+});
+
+t('another person\'s watch is 404 on every page and form', async () => {
+ const created = await create({ template: 'page', name: 'Mine', url: 'https://example.org/mine', cadence: '3600' });
+ const id = indexOf(created);
+ for (const [method, path] of [['GET', ''], ['GET', '/edit'], ['POST', '/edit'], ['POST', '/pause'], ['POST', '/resume'], ['POST', '/check'], ['POST', '/delete']]) {
+ const res = await svc.get(`/watches/${id}${path}`, { as: other, method, form: method === 'POST' ? {} : undefined });
+ assert.strictEqual(res.status, 404, `${method} ${path}`);
+ assert.match(res.text, /Watch not found/);
+ }
+ // The owner still sees it.
+ const mine = await svc.get(`/watches/${id}`, { cookie: session });
+ assert.strictEqual(mine.status, 200);
+ assert.match(mine.text, /Mine/);
+});
+
+t('pause, resume, check now (against a stub site), and delete', async () => {
+ const created = await create({ template: 'price', name: 'Stub price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });
+ const id = indexOf(created);
+
+ const paused = await svc.get(`/watches/${id}/pause`, { cookie: session, form: {} });
+ assert.strictEqual(paused.status, 303);
+ let view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;
+ assert.strictEqual(view.status, 'paused');
+
+ // check-now of a paused watch is the API's 409, said in words.
+ const refused = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });
+ assert.strictEqual(refused.status, 409);
+ assert.match(refused.text, /is paused, so it is not checked/);
+
+ const resumed = await svc.get(`/watches/${id}/resume`, { cookie: session, form: {} });
+ assert.strictEqual(resumed.status, 303);
+
+ const ran = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });
+ assert.strictEqual(ran.status, 303);
+ assert.match(ran.headers.get('location'), /ran=condition_met/, '19.99 is below 20: the condition fired');
+ assert.strictEqual(web.hits('/price').length, 1);
+
+ const detail = await svc.get(`/watches/${id}`, { cookie: session });
+ assert.match(detail.text, /19\.99/, 'the observation history shows the value');
+ assert.match(detail.text, /Condition met/);
+ assert.match(detail.text, /Observation history/);
+
+ const gone = await svc.get(`/watches/${id}/delete`, { cookie: session, form: {} });
+ assert.strictEqual(gone.status, 303);
+ assert.strictEqual((await svc.get(`/watches/${id}`, { cookie: session })).status, 404);
+});
+
+t('edit is the same form with PATCH semantics, and the home summary counts the person\'s watches', async () => {
+ const created = await create({ template: 'price', name: 'Before', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });
+ const id = indexOf(created);
+
+ const form = await svc.get(`/watches/${id}/edit`, { cookie: session });
+ assert.strictEqual(form.status, 200);
+ assert.match(form.text, /value="Before"/);
+ assert.match(form.text, /value="20"/);
+
+ const saved = await svc.get(`/watches/${id}/edit`, {
+ cookie: session,
+ form: { template: 'price', name: 'After', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '15', cadence: '21600', notify: 'on' },
+ });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.strictEqual(saved.headers.get('location'), `/watches/${id}`);
+ const view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;
+ assert.strictEqual(view.name, 'After');
+ assert.deepStrictEqual(view.condition, { op: 'lt', value: 15 });
+ assert.strictEqual(view.cadence.every_sec, 21600);
+
+ // An invalid edit keeps the input and changes nothing.
+ const bad = await svc.get(`/watches/${id}/edit`, {
+ cookie: session, form: { template: 'price', name: 'Broken', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '', cadence: '900' },
+ });
+ assert.strictEqual(bad.status, 422);
+ assert.match(bad.text, /needs a value/);
+ assert.match(bad.text, /value="Broken"/);
+ const unchanged = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME })).body.watch;
+ assert.strictEqual(unchanged.name, 'After');
+
+ const home = await svc.get('/', { cookie: session, headers: { accept: 'text/html' } });
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /Your watches/);
+ assert.match(home.text, /You have <strong>\d+<\/strong> watch/);
+});
+
+t('the watches list pages with before, newest first', async () => {
+ // Enough watches to fill a page (PAGE = 25), made through the API so the site's write budget is
+ // not spent. The pager is the last id of the page, since a wch_ id is a ULID (creation order).
+ for (let i = 0; i < 26; i++) {
+ const made = await request(svc.base, 'POST', '/api/v1/watches', { token: ME, body: {
+ name: `Page ${i}`,
+ source: { kind: 'http', url: `https://example.org/page/${i}`, format: null },
+ cadence: { every_sec: 86400, jitter_sec: 0 },
+ extraction: { kind: 'text' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ } });
+ assert.strictEqual(made.status, 201, made.text);
+ }
+ const first = await svc.get('/watches', { cookie: session });
+ assert.strictEqual(first.status, 200);
+ assert.match(first.text, /Older watches/);
+ const before = (first.text.match(/\/watches\?before=(wch_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1];
+ assert.ok(before, 'the pager carries the last id of the page');
+ const second = await svc.get(`/watches?before=${before}`, { cookie: session });
+ assert.strictEqual(second.status, 200);
+ assert.match(second.text, /Newest first/);
+ // The union of the two pages lists no id twice, and neither lists the cursor's watch again.
+ const ids = (text) => [...text.matchAll(/\/watches\/(wch_[0-9A-HJKMNP-TV-Z]{26})"/g)].map((m) => m[1]);
+ const listed = [...ids(first.text), ...ids(second.text)];
+ assert.strictEqual(new Set(listed).size, listed.length, 'no watch appears twice');
+ assert.ok(!ids(second.text).includes(before), 'the second page starts after the cursor');
+ assert.strictEqual(ids(first.text).length, 25, 'a full first page');
+});
+
+t('a kind whose carrier is not built is never fetched: check-now is 422 with the reason', async () => {
+ const body = {
+ name: 'Event watch',
+ source: { kind: 'event', pattern: 'host.release.published', filter: null },
+ cadence: null,
+ extraction: { kind: 'json', value_path: '$.release' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ };
+ const made = await request(svc.base, 'POST', '/api/v1/watches', { token: ME, body });
+ assert.strictEqual(made.status, 201, made.text);
+ const res = await svc.get(`/watches/${made.body.watch.id}/check`, { cookie: session, form: {} });
+ assert.strictEqual(res.status, 422);
+ assert.match(res.text, /not checked yet|carrier/i);
+});
+
+t('check-now is limited per person, with a friendly 429 and Retry-After', async () => {
+ const u = svc.network.addUser('hurried');
+ const cookie = await svc.signIn(u);
+ const made = await svc.get('/watches/new', { cookie, form: { template: 'page', name: 'Hurried', url: `${web.origin}/price`, cadence: '3600' } });
+ const id = indexOf(made);
+ let refused = null;
+ for (let i = 0; i < 12 && !refused; i++) {
+ const res = await svc.get(`/watches/${id}/check`, { cookie, form: {} });
+ if (res.status === 429) refused = res;
+ }
+ assert.ok(refused, 'the 11th manual check in a minute is refused');
+ assert.match(refused.text, /Slow down/);
+ assert.ok(Number(refused.headers.get('retry-after')) > 0);
+});
+
+t('a signed-in write from another site is refused', async () => {
+ const before = (await request(svc.base, 'GET', '/api/v1/watches', { token: ME })).body.watches.length;
+ const res = await svc.get('/watches/new', { cookie: session, form: { template: 'page', url: 'https://example.org/x', cadence: '3600' }, origin: 'https://evil.example' });
+ assert.strictEqual(res.status, 403);
+ assert.match(res.text, /Cross-site request refused/);
+ const after = (await request(svc.base, 'GET', '/api/v1/watches', { token: ME })).body.watches.length;
+ assert.strictEqual(after, before, 'nothing was created');
+
+ const made = await create({ template: 'page', url: 'https://example.org/owned', cadence: '3600' });
+ const id = indexOf(made);
+ for (const path of ['/pause', '/resume', '/delete', '/check']) {
+ const r = await svc.get(`/watches/${id}${path}`, { cookie: session, form: {}, origin: 'https://evil.example' });
+ assert.strictEqual(r.status, 403, path);
+ }
+});
+
+t('hostile names, values and details are escaped on every page that shows them', async () => {
+ const hostile = '<script>alert(1)</script>" onmouseover="alert(2)';
+ const created = await create({ template: 'json', name: hostile, url: 'https://api.example.org/x', value_path: 'a', op: 'contains', value: '<img src=x onerror=alert(3)>', cadence: '3600' });
+ assert.strictEqual(created.status, 303);
+ const id = indexOf(created);
+ for (const url of ['/watches', `/watches/${id}`, `/watches/${id}/edit`]) {
+ const res = await svc.get(url, { cookie: session });
+ assert.strictEqual(res.status, 200, url);
+ assert.ok(!res.text.includes('<script>alert(1)</script>'), `${url}: the name is not raw script`);
+ assert.ok(!res.text.includes('<img src=x onerror'), `${url}: the value is not a raw image tag`);
+ assert.match(res.text, /<script>alert\(1\)<\/script>/, `${url}: the name is escaped`);
+ }
+ assert.ok(!(await svc.get('/watches', { cookie: session })).text.includes('onmouseover="alert(2)"'));
+});
+
+t('discovery files describe the site and keep the person pages out of the sitemap', async () => {
+ const robots = await svc.get('/robots.txt');
+ assert.strictEqual(robots.status, 200);
+ assert.match(robots.text, /Disallow: \/watches/);
+ assert.match(robots.text, /Disallow: \/auth/);
+ const sitemap = await svc.get('/sitemap.xml');
+ assert.strictEqual(sitemap.status, 200);
+ assert.match(sitemap.text, /<loc>[^<]*\/<\/loc>/);
+ assert.match(sitemap.text, /how-it-works/);
+ assert.ok(!/watches/.test(sitemap.text), 'no watches in the sitemap');
+ const llms = await svc.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.match(llms.text, /OpenVibe\.Watch/);
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+
+t.run();