openvibers/openvibe.watch Commits
The openvibe.watch home offers ready-made watches on real things: channels on OpenVibe.Live with a go-live watch each, Host plan prices, release and job feeds and each blog's new posts, plus the API and the questions people ask (#83)
Changes
@@@ -79,9 +79,9 @@ every page and every form, never 403. A signed-in write must also come from this| Page | What it shows ||---|---|--| `/` | what Watch is in one paragraph; the seven starting points as cards; how it checks in plain words; your watches summary (counts by status) when signed in |++| `/` | what Watch is in one paragraph; the seven starting points as cards; channels active on OpenVibe.Live lately (live first) with a "tell me when they go live" watch each; ready-made watches (Host plan prices, release and job feeds, each blog's new posts); how it checks in plain words; the API samples; the questions (FAQPage JSON-LD); your watches summary (counts by status) when signed in. The channels, plans and blogs are OpenVibe.Search's public documents, read anonymously and kept 10 minutes ([server/http/network-reads.js](server/http/network-reads.js), `WATCH_SEARCH_URL`, `https://search.openvibe.network` in production, off elsewhere); a section with nothing to show is left out || `/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\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs |++| `/watches/new?template=page\|price\|feed\|json\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs; a link may fill it in (`url`, `name`, `value_path`, `op`, `value`, `event`, `event_filter`, `agent_id`), and the person still reviews and saves || `/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 |@@@ -2,7 +2,8 @@: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); }++/* accent-light is drawn for dark themes: links mix it toward the text colour so they read on light themes too (WCAG AA). */++a { color: color-mix(in srgb, var(--accent-light, #60a5fa) 55%, var(--text-primary, #e6edf7)); }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; }@@@ -11,7 +12,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visibh1 { 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); }++.muted { color: var(--text-secondary, #96a7c2); }.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; }nnew file mode 100644@@@ -0,0 +1,33 @@++/* openvibe.watch's home page only (server/http/pages.js): channels to watch, ready-made watches and the questions.++ Colours come from the Frame's theme tokens, so it follows the person's theme, light or dark. */++.page { --hm-accent-text: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary, #e6edf7)); }++.sc-eyebrow { color: var(--hm-accent-text); }++.hm-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 16px; flex-wrap: wrap; }++.hm-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; margin: 0 0 6px; }++.hm-more { font-weight: 650; font-size: 14.5px; white-space: nowrap; }++++.hm-group { margin: 22px 0 2px; font-size: 13px; letter-spacing: .05em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.hm-group-lede { margin: 0 0 10px; font-size: 14px; color: var(--text-secondary, #96a7c2); }++++.hm-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }++.hm-card { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); transition: border-color .15s; }++.hm-card:hover, .hm-card:focus-within { border-color: var(--accent, #3b82f6); }++.hm-card-body { display: grid; gap: 4px; min-width: 0; }++.hm-card-body b { font-size: 15.5px; overflow-wrap: anywhere; }++.hm-card-body b a { color: var(--text-primary, #e6edf7); text-decoration: none; }++.hm-card-body b a:hover { text-decoration: underline; }++.hm-card-body span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }++.hm-live { justify-self: start; margin-top: 2px; padding: 1px 8px; border-radius: 999px; font-size: 12px !important; font-weight: 700; color: color-mix(in srgb, var(--live-red, #ef4444) 50%, var(--text-primary, #e6edf7)) !important; border: 1px solid currentColor; }++.hm-go { align-self: flex-start; font-size: 14px; font-weight: 700; color: var(--hm-accent-text); text-decoration: none; }++.hm-go:hover, .hm-go:focus-visible { text-decoration: underline; }++++.hm-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }++.hm-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }++.hm-qa h3 { margin: 0 0 6px; font-size: 16px; }++.hm-qa p { margin: 0; font-size: 14.5px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }++++/* The shared steps and code grids ask for 280–320 px; a 320 px phone has less. */++.sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }++.sc-code { grid-template-columns: minmax(0, 1fr); }++@media (max-width: 520px) { .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } }++@media (prefers-reduced-motion: reduce) { .hm-card { transition: none; } }@@@ -70,6 +70,9 @@ function load(env = process.env) {// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ // OpenVibe.Search, read anonymously for the home page's ready-made watches (server/http/network-reads.js).++ // Off outside production unless WATCH_SEARCH_URL names one, so tests and local runs fetch nothing.++ search: { url: strip(env.WATCH_SEARCH_URL || (isProduction ? 'https://search.openvibe.network' : '')) },events: {url: strip(env.EVENTS_URL || ''),@@@ -14,7 +14,7 @@ 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, or when a webhook arrives. Define what to watch and the condition that fires; Watch checks on the cadence you pick (or the moment it is called) and records every check.';++const DESCRIPTION = 'Get told when a page, a price, a feed or an API changes, or when a channel goes live. Watch checks politely on your schedule and keeps every check.';const DISALLOW = ['/auth/', '/watches'];const PAGE_TEXT = {nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * The home page's reads from OpenVibe.Search (its anonymous browse: public, published, indexable documents, newest++ * first), so the page can offer ready-made watches on real things: channels on OpenVibe.Live, hosting plans on++ * OpenVibe.Host and blogs on OpenVibe.Blog.++ *++ * createNetworkReads({ searchUrl, fetchImpl, log }) → { channels(), plans(), posts() }++ *++ * A read never throws and never holds the page long: an answer is kept 10 minutes and refreshed in the background++ * once stale, the first read waits at most READ_WAIT_MS, and a failure leaves that section out. No searchUrl (tests,++ * a local run): every read is empty and nothing is fetched.++ */++const TTL_MS = 10 * 60 * 1000;++const RETRY_MS = 60 * 1000;++const TIMEOUT_MS = 4000;++const READ_WAIT_MS = 1200;++++const clean = (s, max = 140) => String(s == null ? '' : s).replace(/[\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++const httpsUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x : null; } catch { return null; } };++++/** A Live channel document → { name, username, url, live, category, followers }; the username is the /@handle path. */++function channelOf(doc) {++ const u = httpsUrl(doc && doc.canonical_url);++ const m = u && /^\/@([A-Za-z0-9_.-]{1,40})$/.exec(u.pathname);++ if (!m) return null;++ const f = (doc && doc.facets) || {};++ return { username: m[1], url: u.href, live: f.live === true, category: clean(f.category, 30), followers: Number.isFinite(f.followers) ? f.followers : null };++}++++/** A Host plan document → { id, title, url, detail }; its id is the catalog's plan id (hpl_…). */++function planOf(doc) {++ const u = httpsUrl(doc && doc.canonical_url);++ if (!u || !/^hpl_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(doc.id || ''))) return null;++ return { id: doc.id, title: clean(doc.title), url: u.href, origin: u.origin, detail: clean(doc.summary, 160) };++}++++/** A Blog post document → { title, url, blog }. */++function postOf(doc) {++ const u = httpsUrl(doc && doc.canonical_url);++ const blog = clean(((doc && doc.facets) || {}).blog, 60);++ if (!u || !clean(doc.title) || !/^[A-Za-z0-9_.-]{1,60}$/.test(blog)) return null;++ return { title: clean(doc.title), url: u.href, blog };++}++++function createNetworkReads({ searchUrl = '', fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String(searchUrl || '').replace(/\/+$/, '');++ const cache = new Map(); // key → { at, value, pending }++++ async function load(url, map) {++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ if (!r.ok) throw new Error(`search answered ${r.status}`);++ const body = await r.json();++ return ((body && body.results) || []).map(map).filter(Boolean);++ }++++ function read(key, query, map) {++ if (!base) return Promise.resolve([]);++ const hit = cache.get(key);++ if (hit && (Date.now() - hit.at < TTL_MS || hit.pending)) return Promise.resolve(hit.value);++ const pending = load(`${base}/api/v1/search?${query}`, map)++ .then((value) => { cache.set(key, { at: Date.now(), value, pending: null }); return value; })++ .catch((e) => {++ log.warn(`[watch] home page read ${key}: ${e.message}`);++ const value = hit ? hit.value : [];++ cache.set(key, { at: Date.now() - TTL_MS + RETRY_MS, value, pending: null });++ return value;++ });++ cache.set(key, { at: hit ? hit.at : 0, value: hit ? hit.value : [], pending });++ if (hit) return Promise.resolve(hit.value);++ return Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve([]), READ_WAIT_MS).unref())]);++ }++++ return {++ channels: () => read('channels', 'owner=live&type=channel&limit=12', channelOf),++ plans: () => read('plans', 'owner=host&type=plan&limit=3', planOf),++ posts: () => read('posts', 'owner=blog&type=post&limit=12', postOf),++ };++}++++module.exports = { createNetworkReads, channelOf, planOf, postOf };@@@ -24,6 +24,8 @@ const { send } = require('../render/layout');const W = require('../render/watch');const changelog = require('../render/changelog');const { createDiscoveryRoutes, homeJsonLd, DESCRIPTION } = require('./discovery');++const { createNetworkReads } = require('./network-reads');++const seo = require('openvibe-shared/seo');/** The four starting points. Each fixes the source kind, the extraction and the condition's ops. */const TEMPLATES = {@@@ -516,6 +518,26 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code// ── Home ────────────────────────────────────────────────────────────────────────────────++ const reads = createNetworkReads({ searchUrl: config.search && config.search.url, log });++ const newWatch = (q) => `/watches/new?${new URLSearchParams(q)}`;++ // Feeds anyone may watch, each checked to answer with a feed: what a person most often wants told about.++ const FEEDS = [++ { title: 'New releases of the OpenVibe Node', detail: 'The agent that runs on robots and computers, from GitHub.', url: 'https://github.com/OpenVibers/OpenVibe.Node/releases.atom', where: 'GitHub' },++ { title: 'New Node.js releases', detail: 'Every Node.js release, current and LTS.', url: 'https://nodejs.org/en/feed/releases.xml', where: 'nodejs.org' },++ { title: 'New remote jobs on OpenVibe.Work', detail: 'Every new remote listing OpenVibe.Work gathers.', url: 'https://openvibe.work/jobs.atom?remote=true', where: 'OpenVibe.Work' },++ ];++++ /** Questions people ask first, answered from what Watch does today. */++ const FAQ = [++ ['What can I watch?', 'A web page or one part of it, a price or any number on a page, an RSS or Atom feed, a value in a JSON API, a webhook that something calls, an OpenVibe event such as a channel going live, or a device on your own network.'],++ ['How often does it check?', 'On the cadence you pick: every 15 minutes, hourly, every 6 hours or daily. Events and webhooks are not polled at all: the watch wakes the moment they arrive.'],++ ['Will it hammer the sites I watch?', 'No. It asks whether anything changed before downloading again, never sends two requests to one site at once, keeps a minimum gap per site, and waits when a site asks it to.'],++ ['How am I told?', 'A notification on your OpenVibe account by default. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],++ ['Does it guess when a check fails?', 'Never. A failed check is recorded as failed with its reason, and a value that is missing stays missing. Every check is kept with the time and what it found.'],++ ['What does it cost?', 'Nothing while it is in alpha. Reading this page needs no account; making a watch needs your OpenVibe account.'],++ ['Can my app make watches?', 'Yes. The same API the site uses creates, pauses and checks watches and reads every observation, with the watch.* capabilities granted on OpenVibe.Services.'],++ ];++r.get('/', async (req, res) => {let summary = null;if (signedIn(req)) {@@@ -532,19 +554,31 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="codeeyebrow: 'OpenVibe.Watch · pages, feeds, APIs and webhooks',title: 'Know the moment it changes.',accent: 'Checked politely, never guessed.',-- lede: 'Give Watch a page, a feed or a JSON API (or let a webhook call it), say which value you care about and when to tell you. It checks on the cadence you pick or the moment a webhook arrives, records what every check found, and tells you when your condition fires.',++ lede: 'Give Watch a page, a feed or a JSON API, or pick something on OpenVibe such as a channel going live. Say which value you care about and when to tell you. It checks on the cadence you pick, or wakes the moment an event or a webhook arrives, keeps 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', event: 'ov:live', webhook: 'ov:code' };++ const ICONS = { page: 'ov:docs', price: 'ov:deals', feed: 'ov:news', json: 'ov:json', event: 'ov:live', webhook: 'ov:code', lan: 'ov:plug' };++ const [channelRows, plans, postRows] = await Promise.all([reads.channels(), reads.plans(), reads.posts()]);++ const channels = channelRows.slice().sort((a, b) => Number(b.live) - Number(a.live)).slice(0, 8);++ // Newest first from Search, so the first post seen for a blog is its latest.++ const blogs = [...postRows.reduce((m, p) => (m.has(p.blog) ? m : m.set(p.blog, p)), new Map()).values()].slice(0, 3);++ const card = ({ title, detail, href, label, extra = '' }) => html`<li class="hm-card"><div class="hm-card-body"><b>${title}</b>${detail ? html`<span>${detail}</span>` : ''}${raw(extra)}</div><a class="hm-go" href="${href}">${label}</a></li>`;++ const readyGroups = [++ plans.length ? { title: 'Hosting prices', lede: 'Plans on OpenVibe.Host, read from its catalog API.', items: plans.map((p) => card({ title: p.title, detail: p.detail, href: newWatch({ template: 'json', url: `${p.origin}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `Price: ${p.title}`.slice(0, 80) }), label: 'Tell me when the price changes' })) } : null,++ { title: 'Feeds', lede: 'Told the moment a new item appears.', items: FEEDS.map((f) => card({ title: f.title, detail: f.detail, href: newWatch({ template: 'feed', url: f.url, name: f.title.slice(0, 80) }), label: 'Watch this feed' })) },++ blogs.length ? { title: 'Blogs on OpenVibe', lede: 'Woken by the blog.post.published event, so nothing is polled.', items: blogs.map((b) => card({ title: `New posts on @${b.blog}`, detail: `Latest: ${b.title}`, href: newWatch({ template: 'event', event: 'blog.post.published', event_filter: b.blog, name: `New posts on @${b.blog}` }), label: 'Tell me about new posts' })) } : null,++ ].filter(Boolean);++ const faqLd = seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a })));page(req, res, {index: true,cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }),-- jsonLd: homeJsonLd(config),++ jsonLd: [...homeJsonLd(config), channels.length ? seo.jsonLd.itemList('Channels on OpenVibe.Live you can watch', channels.map((c) => ({ name: `@${c.username}`, url: c.url }))) : null, faqLd],description: DESCRIPTION,styles: [showcase.STYLESHEET],++ css: ['css/home.css'],body: html`${raw(hero)}${raw(showcase.features({id: 'start',@@@ -552,7 +586,12 @@ ${raw(showcase.features({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, APIs and webhooks today; watches woken by OpenVibe events are made through the API. Browser checks through Run, Node probes and AI readings come later, and are not offered until Watch can really check them.</p>++${channels.length ? html`<section class="sc-sec" aria-labelledby="h-live"><div class="hm-head"><h2 id="h-live">Know when a channel goes live</h2><a class="hm-more" href="https://openvibe.live/">OpenVibe.Live</a></div>++<p class="sc-lede">Channels active on OpenVibe.Live lately. A watch on one wakes on the live.stream.started event the moment it goes live: nothing is polled.</p>++<ul class="hm-grid">${channels.map((c) => html`<li class="hm-card"><div class="hm-card-body"><b><a href="${c.url}">@${c.username}</a></b><span>${[c.category, c.followers ? `${c.followers} follower${c.followers === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</span>${c.live ? raw('<span class="hm-live">Live now</span>') : ''}</div><a class="hm-go" href="${newWatch({ template: 'event', event: 'live.stream.started', event_filter: c.username, name: `@${c.username} goes live` })}">Tell me when they go live</a></li>`)}</ul></section>` : ''}++<section class="sc-sec" aria-labelledby="h-ready"><h2 id="h-ready">Ready-made watches</h2>++<p class="sc-lede">Each opens the form already filled in. Check it, change anything, and save.</p>++${readyGroups.map((g) => html`<h3 class="hm-group">${g.title}</h3><p class="hm-group-lede">${g.lede}</p><ul class="hm-grid">${g.items}</ul>`)}</section>${raw(showcase.steps({id: 'steps',title: 'From a URL to a notification',@@@ -572,6 +611,17 @@ ${raw(showcase.steps({<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>++${raw(showcase.code({++ id: 'api',++ title: 'Make watches from your own code',++ lede: 'The site and the API are the same: create, pause and check watches and read every observation with a token that holds the watch.* capabilities.',++ samples: [++ { label: 'Create a watch (curl)', lang: 'sh', code: `curl -X POST ${config.baseUrl}/api/v1/watches \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H 'Content-Type: application/json' \\\n -d '{"name": "Node.js releases",\n "source": {"kind": "feed", "url": "https://nodejs.org/en/feed/releases.xml"},\n "cadence": {"every_sec": 3600},\n "extraction": {"kind": "json", "value_path": "latest"},\n "condition": {"op": "changed"},\n "action": [{"kind": "notification"}]}'` },++ { label: 'Read what it found (curl)', lang: 'sh', code: `# Your watches\ncurl -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n ${config.baseUrl}/api/v1/watches\n\n# Every value one watch observed, newest first\ncurl -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n ${config.baseUrl}/api/v1/watches/wch_…/observations` },++ ],++ }))}++<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>++<div class="hm-faq">${FAQ.map(([q, a]) => html`<div class="hm-qa"><h3>${q}</h3><p>${a}</p></div>`)}</div></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>` : ''}`,});@@@ -639,6 +689,8 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl// A link from an Actor agent's page: run that agent when the watch fires (the person still submits the form).if (typeof q.agent_id === 'string' && /^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(q.agent_id)) { fields.agent_on = 'on'; fields.agent_id = q.agent_id; fields.notify = ''; }if (typeof q.event === 'string' && eventChoice(q.event)) fields.event = q.event;++ // The home page's "Tell me when they go live" names the channel: the event's filter (bounded like the form's).++ if (typeof q.event_filter === 'string' && q.event_filter.length <= 120 && !/[\u0000-\u001f]/.test(q.event_filter)) fields.event_filter = q.event_filter.trim();return fields;}@@@ -19,7 +19,7 @@ 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 TAGLINE = 'Get told when a page or a feed changes.';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// The product's navigation: one entry per public page it serves.const NAV = [@@@ -43,7 +43,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;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 */++/** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], crumbs, jsonLd, css [rel] (a page's own stylesheet) */function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };const signedIn = viewer.kind === 'user';@@@ -86,6 +86,7 @@ function renderPage(o) {${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')}">++${(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="watch@${RELEASE}"><script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * The home page's data sections (server/http/pages.js, server/http/network-reads.js): channels on OpenVibe.Live and++ * ready-made watches read from OpenVibe.Search (a stub here), each opening the new-watch form filled in; a section with++ * nothing to show is left out; the questions and their FAQPage JSON-LD match; the API sample's body is one the API++ * accepts.++ */++const assert = require('assert');++const { boot, site, suite, userToken } = require('./helpers');++const { channelOf, planOf, postOf, createNetworkReads } = require('../server/http/network-reads');++++const t = suite('home');++let svc;++let search;++let plain;++let me;++++const doc = (o) => ({ visibility: 'public', title: 'x', summary: '', facets: {}, ...o });++const RESULTS = {++ 'live/channel': [++ doc({ owner: 'live', type: 'channel', id: '1', title: 'quiet (@quiet)', canonical_url: 'https://openvibe.live/@quiet', facets: { live: false, category: 'gaming', followers: 3 } }),++ doc({ owner: 'live', type: 'channel', id: '2', title: 'goosely (@goosely)', canonical_url: 'https://openvibe.live/@goosely', facets: { live: true, category: 'irl', followers: 20 } }),++ doc({ owner: 'live', type: 'channel', id: '3', title: 'bad', canonical_url: 'javascript:alert(1)' }),++ ],++ 'host/plan': [doc({ owner: 'host', type: 'plan', id: 'hpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP', title: 'OVHcloud VPS-1 2026: $7.60 a month', summary: '4 vCPU · 8 GB RAM', canonical_url: 'https://openvibe.host/catalog/plans/hpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP' })],++ 'blog/post': [++ doc({ owner: 'blog', type: 'post', id: 'a', title: 'Patch notes (Oct 22)', canonical_url: 'https://openvibe.blog/@openvibe/a', facets: { blog: 'openvibe' } }),++ doc({ owner: 'blog', type: 'post', id: 'b', title: 'Older notes', canonical_url: 'https://openvibe.blog/@openvibe/b', facets: { blog: 'openvibe' } }),++ ],++};++++t('boot', async () => {++ search = await site({ '/api/v1/search': (req, res, { url }) => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ results: RESULTS[`${url.searchParams.get('owner')}/${url.searchParams.get('type')}`] || [] }) }) });++ svc = await boot({ env: { WATCH_SEARCH_URL: search.origin } });++ plain = await boot();++ me = svc.network.addUser('ada');++});++++t('the mappers keep only https documents of the right shape', async () => {++ assert.deepStrictEqual(channelOf(RESULTS['live/channel'][1]), { username: 'goosely', url: 'https://openvibe.live/@goosely', live: true, category: 'irl', followers: 20 });++ assert.strictEqual(channelOf(RESULTS['live/channel'][2]), null);++ assert.strictEqual(channelOf(doc({ canonical_url: 'https://openvibe.live/clips/1' })), null);++ assert.strictEqual(planOf(doc({ id: 'nope', canonical_url: 'https://openvibe.host/x' })), null);++ assert.strictEqual(planOf(RESULTS['host/plan'][0]).origin, 'https://openvibe.host');++ assert.strictEqual(postOf(doc({ title: 'x', canonical_url: 'https://openvibe.blog/x', facets: { blog: '<b>' } })), null);++ const off = createNetworkReads({ searchUrl: '', fetchImpl: () => { throw new Error('no fetch without a URL'); } });++ assert.deepStrictEqual(await off.channels(), []);++ const down = createNetworkReads({ searchUrl: 'https://search.example', fetchImpl: async () => ({ ok: false, status: 503 }), log: { warn() {} } });++ assert.deepStrictEqual(await down.plans(), []);++});++++t('channels, live first, each with a "go live" watch filled in for it', async () => {++ const home = await svc.get('/', { headers: { accept: 'text/html' } });++ assert.strictEqual(home.status, 200);++ const live = (/<section class="sc-sec" aria-labelledby="h-live">[\s\S]*?<\/section>/.exec(home.text) || [''])[0];++ const names = [...live.matchAll(/<b><a href="https:\/\/openvibe\.live\/@(\w+)">/g)].map((m) => m[1]);++ assert.deepStrictEqual(names, ['goosely', 'quiet']);++ assert.match(live, /<span class="hm-live">Live now<\/span>/);++ assert.match(live, /irl · 20 followers/);++ assert.match(live, /href="\/watches\/new\?template=event&event=live\.stream\.started&event_filter=goosely&name=%40goosely\+goes\+live"/);++ assert.ok(!live.includes('javascript:'));++});++++t('ready-made watches: a Host plan\'s price, feeds, and a blog\'s new posts', async () => {++ const home = await svc.get('/', { headers: { accept: 'text/html' } });++ const ready = (/<section class="sc-sec" aria-labelledby="h-ready">[\s\S]*?<\/section>/.exec(home.text) || [''])[0];++ assert.match(ready, /template=json&url=https%3A%2F%2Fopenvibe\.host%2Fapi%2Fv1%2Fcatalog%2Fplans%2Fhpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP&value_path=plan\.price_monthly&op=changed/);++ assert.match(ready, /template=feed&url=https%3A%2F%2Fnodejs\.org%2Fen%2Ffeed%2Freleases\.xml/);++ assert.strictEqual((ready.match(/New posts on @openvibe/g) || []).length, 1, 'one card per blog, with its latest post');++ assert.match(ready, /Latest: Patch notes \(Oct 22\)/);++});++++t('the form a ready-made link opens is filled in, the channel included', async () => {++ const r = await svc.get('/watches/new?template=event&event=live.stream.started&event_filter=goosely&name=%40goosely+goes+live', { as: me });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /name="event_filter" value="goosely"/);++ assert.match(r.text, /value="@goosely goes live"/);++ const bad = await svc.get(`/watches/new?template=event&event_filter=${encodeURIComponent('a\u0001b')}`, { as: me });++ assert.match(bad.text, /name="event_filter" value=""/);++});++++t('without OpenVibe.Search the Search sections are left out; the feeds and questions stay', async () => {++ const home = await plain.get('/', { headers: { accept: 'text/html' } });++ assert.strictEqual(home.status, 200);++ assert.ok(!home.text.includes('aria-labelledby="h-live"'));++ assert.ok(!home.text.includes('Hosting prices'));++ assert.match(home.text, /New Node\.js releases/);++ const ld = [...home.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(home.text) || [''])[0];++ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));++ assert.ok(shown.length >= 6);++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);++ assert.ok(!ld.some((x) => x['@type'] === 'ItemList'), 'no channel list without channels');++ assert.strictEqual((home.text.match(/<h1[ >]/g) || []).length, 1);++ assert.match(home.text, /<link rel="stylesheet" href="\/css\/home\.css\?v=[0-9a-f]+">/);++});++++t('the API sample\'s body is one the API accepts', async () => {++ const r = await svc.get('/api/v1/watches', {++ bearer: userToken(me.subject),++ json: { name: 'Node.js releases', source: { kind: 'feed', url: 'https://nodejs.org/en/feed/releases.xml' }, cadence: { every_sec: 3600 }, extraction: { kind: 'json', value_path: 'latest' }, condition: { op: 'changed' }, action: [{ kind: 'notification' }] },++ });++ assert.strictEqual(r.status, 201, r.text);++});++++t('done', async () => { await search.close(); await svc.stop(); await plain.stop(); });++++t.run();
@@ -79,9 +79,9 @@ every page and every form, never 403. A signed-in write must also come from this
@@ -79,9 +79,9 @@ every page and every form, never 403. A signed-in write must also come from this
| Page | What it shows |
| Page | What it shows |
|---|---|
|---|---|
-| `/` | what Watch is in one paragraph; the seven starting points as cards; how it checks in plain words; your watches summary (counts by status) when signed in |
+| `/` | what Watch is in one paragraph; the seven starting points as cards; channels active on OpenVibe.Live lately (live first) with a "tell me when they go live" watch each; ready-made watches (Host plan prices, release and job feeds, each blog's new posts); how it checks in plain words; the API samples; the questions (FAQPage JSON-LD); your watches summary (counts by status) when signed in. The channels, plans and blogs are OpenVibe.Search's public documents, read anonymously and kept 10 minutes ([server/http/network-reads.js](server/http/network-reads.js), `WATCH_SEARCH_URL`, `https://search.openvibe.network` in production, off elsewhere); a section with nothing to show is left out |
| `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` |
| `/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\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs |
+| `/watches/new?template=page\|price\|feed\|json\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs; a link may fill it in (`url`, `name`, `value_path`, `op`, `value`, `event`, `event_filter`, `agent_id`), and the person still reviews and saves |
| `/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` | 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 |
| `/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 |
| `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog |
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
:root { color-scheme: dark; }
:root { color-scheme: dark; }
* { box-sizing: border-box; }
* { 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; }
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); }
+/* accent-light is drawn for dark themes: links mix it toward the text colour so they read on light themes too (WCAG AA). */
+a { color: color-mix(in srgb, var(--accent-light, #60a5fa) 55%, var(--text-primary, #e6edf7)); }
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; }
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 { position: absolute; left: -9999px; }
.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
@@ -11,7 +12,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visib
@@ -11,7 +12,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visib
h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
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; }
h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
-.muted { color: var(--text-muted, #7386a3); }
+.muted { color: var(--text-secondary, #96a7c2); }
.small { font-size: .85rem; }
.small { font-size: .85rem; }
.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
.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, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+/* openvibe.watch's home page only (server/http/pages.js): channels to watch, ready-made watches and the questions.
+ Colours come from the Frame's theme tokens, so it follows the person's theme, light or dark. */
+.page { --hm-accent-text: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary, #e6edf7)); }
+.sc-eyebrow { color: var(--hm-accent-text); }
+.hm-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 16px; flex-wrap: wrap; }
+.hm-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; margin: 0 0 6px; }
+.hm-more { font-weight: 650; font-size: 14.5px; white-space: nowrap; }
+
+.hm-group { margin: 22px 0 2px; font-size: 13px; letter-spacing: .05em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.hm-group-lede { margin: 0 0 10px; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+
+.hm-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
+.hm-card { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); transition: border-color .15s; }
+.hm-card:hover, .hm-card:focus-within { border-color: var(--accent, #3b82f6); }
+.hm-card-body { display: grid; gap: 4px; min-width: 0; }
+.hm-card-body b { font-size: 15.5px; overflow-wrap: anywhere; }
+.hm-card-body b a { color: var(--text-primary, #e6edf7); text-decoration: none; }
+.hm-card-body b a:hover { text-decoration: underline; }
+.hm-card-body span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
+.hm-live { justify-self: start; margin-top: 2px; padding: 1px 8px; border-radius: 999px; font-size: 12px !important; font-weight: 700; color: color-mix(in srgb, var(--live-red, #ef4444) 50%, var(--text-primary, #e6edf7)) !important; border: 1px solid currentColor; }
+.hm-go { align-self: flex-start; font-size: 14px; font-weight: 700; color: var(--hm-accent-text); text-decoration: none; }
+.hm-go:hover, .hm-go:focus-visible { text-decoration: underline; }
+
+.hm-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }
+.hm-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }
+.hm-qa h3 { margin: 0 0 6px; font-size: 16px; }
+.hm-qa p { margin: 0; font-size: 14.5px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
+
+/* The shared steps and code grids ask for 280–320 px; a 320 px phone has less. */
+.sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
+.sc-code { grid-template-columns: minmax(0, 1fr); }
+@media (max-width: 520px) { .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } }
+@media (prefers-reduced-motion: reduce) { .hm-card { transition: none; } }
@@ -70,6 +70,9 @@ function load(env = process.env) {
@@ -70,6 +70,9 @@ function load(env = process.env) {
// 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 || '' },
+ // OpenVibe.Search, read anonymously for the home page's ready-made watches (server/http/network-reads.js).
+ // Off outside production unless WATCH_SEARCH_URL names one, so tests and local runs fetch nothing.
+ search: { url: strip(env.WATCH_SEARCH_URL || (isProduction ? 'https://search.openvibe.network' : '')) },
events: {
events: {
url: strip(env.EVENTS_URL || ''),
url: strip(env.EVENTS_URL || ''),
@@ -14,7 +14,7 @@ const seo = require('openvibe-shared/seo');
@@ -14,7 +14,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const SITE_NAME = 'OpenVibe.Watch';
const SITE_NAME = 'OpenVibe.Watch';
-const DESCRIPTION = 'OpenVibe.Watch — get told when a page, a feed or an API changes, or when a webhook arrives. Define what to watch and the condition that fires; Watch checks on the cadence you pick (or the moment it is called) and records every check.';
+const DESCRIPTION = 'Get told when a page, a price, a feed or an API changes, or when a channel goes live. Watch checks politely on your schedule and keeps every check.';
const DISALLOW = ['/auth/', '/watches'];
const DISALLOW = ['/auth/', '/watches'];
const PAGE_TEXT = {
const PAGE_TEXT = {
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * The home page's reads from OpenVibe.Search (its anonymous browse: public, published, indexable documents, newest
+ * first), so the page can offer ready-made watches on real things: channels on OpenVibe.Live, hosting plans on
+ * OpenVibe.Host and blogs on OpenVibe.Blog.
+ *
+ * createNetworkReads({ searchUrl, fetchImpl, log }) → { channels(), plans(), posts() }
+ *
+ * A read never throws and never holds the page long: an answer is kept 10 minutes and refreshed in the background
+ * once stale, the first read waits at most READ_WAIT_MS, and a failure leaves that section out. No searchUrl (tests,
+ * a local run): every read is empty and nothing is fetched.
+ */
+const TTL_MS = 10 * 60 * 1000;
+const RETRY_MS = 60 * 1000;
+const TIMEOUT_MS = 4000;
+const READ_WAIT_MS = 1200;
+
+const clean = (s, max = 140) => String(s == null ? '' : s).replace(/[\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+const httpsUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x : null; } catch { return null; } };
+
+/** A Live channel document → { name, username, url, live, category, followers }; the username is the /@handle path. */
+function channelOf(doc) {
+ const u = httpsUrl(doc && doc.canonical_url);
+ const m = u && /^\/@([A-Za-z0-9_.-]{1,40})$/.exec(u.pathname);
+ if (!m) return null;
+ const f = (doc && doc.facets) || {};
+ return { username: m[1], url: u.href, live: f.live === true, category: clean(f.category, 30), followers: Number.isFinite(f.followers) ? f.followers : null };
+}
+
+/** A Host plan document → { id, title, url, detail }; its id is the catalog's plan id (hpl_…). */
+function planOf(doc) {
+ const u = httpsUrl(doc && doc.canonical_url);
+ if (!u || !/^hpl_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(doc.id || ''))) return null;
+ return { id: doc.id, title: clean(doc.title), url: u.href, origin: u.origin, detail: clean(doc.summary, 160) };
+}
+
+/** A Blog post document → { title, url, blog }. */
+function postOf(doc) {
+ const u = httpsUrl(doc && doc.canonical_url);
+ const blog = clean(((doc && doc.facets) || {}).blog, 60);
+ if (!u || !clean(doc.title) || !/^[A-Za-z0-9_.-]{1,60}$/.test(blog)) return null;
+ return { title: clean(doc.title), url: u.href, blog };
+}
+
+function createNetworkReads({ searchUrl = '', fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String(searchUrl || '').replace(/\/+$/, '');
+ const cache = new Map(); // key → { at, value, pending }
+
+ async function load(url, map) {
+ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (!r.ok) throw new Error(`search answered ${r.status}`);
+ const body = await r.json();
+ return ((body && body.results) || []).map(map).filter(Boolean);
+ }
+
+ function read(key, query, map) {
+ if (!base) return Promise.resolve([]);
+ const hit = cache.get(key);
+ if (hit && (Date.now() - hit.at < TTL_MS || hit.pending)) return Promise.resolve(hit.value);
+ const pending = load(`${base}/api/v1/search?${query}`, map)
+ .then((value) => { cache.set(key, { at: Date.now(), value, pending: null }); return value; })
+ .catch((e) => {
+ log.warn(`[watch] home page read ${key}: ${e.message}`);
+ const value = hit ? hit.value : [];
+ cache.set(key, { at: Date.now() - TTL_MS + RETRY_MS, value, pending: null });
+ return value;
+ });
+ cache.set(key, { at: hit ? hit.at : 0, value: hit ? hit.value : [], pending });
+ if (hit) return Promise.resolve(hit.value);
+ return Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve([]), READ_WAIT_MS).unref())]);
+ }
+
+ return {
+ channels: () => read('channels', 'owner=live&type=channel&limit=12', channelOf),
+ plans: () => read('plans', 'owner=host&type=plan&limit=3', planOf),
+ posts: () => read('posts', 'owner=blog&type=post&limit=12', postOf),
+ };
+}
+
+module.exports = { createNetworkReads, channelOf, planOf, postOf };
@@ -24,6 +24,8 @@ const { send } = require('../render/layout');
@@ -24,6 +24,8 @@ const { send } = require('../render/layout');
const W = require('../render/watch');
const W = require('../render/watch');
const changelog = require('../render/changelog');
const changelog = require('../render/changelog');
const { createDiscoveryRoutes, homeJsonLd, DESCRIPTION } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd, DESCRIPTION } = require('./discovery');
+const { createNetworkReads } = require('./network-reads');
+const seo = require('openvibe-shared/seo');
/** The four starting points. Each fixes the source kind, the extraction and the condition's ops. */
/** The four starting points. Each fixes the source kind, the extraction and the condition's ops. */
const TEMPLATES = {
const TEMPLATES = {
@@ -516,6 +518,26 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
@@ -516,6 +518,26 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
// ── Home ────────────────────────────────────────────────────────────────────────────────
// ── Home ────────────────────────────────────────────────────────────────────────────────
+ const reads = createNetworkReads({ searchUrl: config.search && config.search.url, log });
+ const newWatch = (q) => `/watches/new?${new URLSearchParams(q)}`;
+ // Feeds anyone may watch, each checked to answer with a feed: what a person most often wants told about.
+ const FEEDS = [
+ { title: 'New releases of the OpenVibe Node', detail: 'The agent that runs on robots and computers, from GitHub.', url: 'https://github.com/OpenVibers/OpenVibe.Node/releases.atom', where: 'GitHub' },
+ { title: 'New Node.js releases', detail: 'Every Node.js release, current and LTS.', url: 'https://nodejs.org/en/feed/releases.xml', where: 'nodejs.org' },
+ { title: 'New remote jobs on OpenVibe.Work', detail: 'Every new remote listing OpenVibe.Work gathers.', url: 'https://openvibe.work/jobs.atom?remote=true', where: 'OpenVibe.Work' },
+ ];
+
+ /** Questions people ask first, answered from what Watch does today. */
+ const FAQ = [
+ ['What can I watch?', 'A web page or one part of it, a price or any number on a page, an RSS or Atom feed, a value in a JSON API, a webhook that something calls, an OpenVibe event such as a channel going live, or a device on your own network.'],
+ ['How often does it check?', 'On the cadence you pick: every 15 minutes, hourly, every 6 hours or daily. Events and webhooks are not polled at all: the watch wakes the moment they arrive.'],
+ ['Will it hammer the sites I watch?', 'No. It asks whether anything changed before downloading again, never sends two requests to one site at once, keeps a minimum gap per site, and waits when a site asks it to.'],
+ ['How am I told?', 'A notification on your OpenVibe account by default. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],
+ ['Does it guess when a check fails?', 'Never. A failed check is recorded as failed with its reason, and a value that is missing stays missing. Every check is kept with the time and what it found.'],
+ ['What does it cost?', 'Nothing while it is in alpha. Reading this page needs no account; making a watch needs your OpenVibe account.'],
+ ['Can my app make watches?', 'Yes. The same API the site uses creates, pauses and checks watches and reads every observation, with the watch.* capabilities granted on OpenVibe.Services.'],
+ ];
+
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
let summary = null;
let summary = null;
if (signedIn(req)) {
if (signedIn(req)) {
@@ -532,19 +554,31 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
@@ -532,19 +554,31 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
eyebrow: 'OpenVibe.Watch · pages, feeds, APIs and webhooks',
eyebrow: 'OpenVibe.Watch · pages, feeds, APIs and webhooks',
title: 'Know the moment it changes.',
title: 'Know the moment it changes.',
accent: 'Checked politely, never guessed.',
accent: 'Checked politely, never guessed.',
- lede: 'Give Watch a page, a feed or a JSON API (or let a webhook call it), say which value you care about and when to tell you. It checks on the cadence you pick or the moment a webhook arrives, records what every check found, and tells you when your condition fires.',
+ lede: 'Give Watch a page, a feed or a JSON API, or pick something on OpenVibe such as a channel going live. Say which value you care about and when to tell you. It checks on the cadence you pick, or wakes the moment an event or a webhook arrives, keeps what every check found, and tells you when your condition fires.',
actions: signedIn(req)
actions: signedIn(req)
? [{ label: 'New watch', href: '/watches/new?template=page', primary: true }, { label: 'Your watches', href: '/watches' }]
? [{ 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' }],
: [{ 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.',
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', event: 'ov:live', webhook: 'ov:code' };
+ const ICONS = { page: 'ov:docs', price: 'ov:deals', feed: 'ov:news', json: 'ov:json', event: 'ov:live', webhook: 'ov:code', lan: 'ov:plug' };
+ const [channelRows, plans, postRows] = await Promise.all([reads.channels(), reads.plans(), reads.posts()]);
+ const channels = channelRows.slice().sort((a, b) => Number(b.live) - Number(a.live)).slice(0, 8);
+ // Newest first from Search, so the first post seen for a blog is its latest.
+ const blogs = [...postRows.reduce((m, p) => (m.has(p.blog) ? m : m.set(p.blog, p)), new Map()).values()].slice(0, 3);
+ const card = ({ title, detail, href, label, extra = '' }) => html`<li class="hm-card"><div class="hm-card-body"><b>${title}</b>${detail ? html`<span>${detail}</span>` : ''}${raw(extra)}</div><a class="hm-go" href="${href}">${label}</a></li>`;
+ const readyGroups = [
+ plans.length ? { title: 'Hosting prices', lede: 'Plans on OpenVibe.Host, read from its catalog API.', items: plans.map((p) => card({ title: p.title, detail: p.detail, href: newWatch({ template: 'json', url: `${p.origin}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `Price: ${p.title}`.slice(0, 80) }), label: 'Tell me when the price changes' })) } : null,
+ { title: 'Feeds', lede: 'Told the moment a new item appears.', items: FEEDS.map((f) => card({ title: f.title, detail: f.detail, href: newWatch({ template: 'feed', url: f.url, name: f.title.slice(0, 80) }), label: 'Watch this feed' })) },
+ blogs.length ? { title: 'Blogs on OpenVibe', lede: 'Woken by the blog.post.published event, so nothing is polled.', items: blogs.map((b) => card({ title: `New posts on @${b.blog}`, detail: `Latest: ${b.title}`, href: newWatch({ template: 'event', event: 'blog.post.published', event_filter: b.blog, name: `New posts on @${b.blog}` }), label: 'Tell me about new posts' })) } : null,
+ ].filter(Boolean);
+ const faqLd = seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a })));
page(req, res, {
page(req, res, {
index: true,
index: true,
cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }),
cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }),
- jsonLd: homeJsonLd(config),
+ jsonLd: [...homeJsonLd(config), channels.length ? seo.jsonLd.itemList('Channels on OpenVibe.Live you can watch', channels.map((c) => ({ name: `@${c.username}`, url: c.url }))) : null, faqLd],
description: DESCRIPTION,
description: DESCRIPTION,
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
+ css: ['css/home.css'],
body: html`${raw(hero)}
body: html`${raw(hero)}
${raw(showcase.features({
${raw(showcase.features({
id: 'start',
id: 'start',
@@ -552,7 +586,12 @@ ${raw(showcase.features({
@@ -552,7 +586,12 @@ ${raw(showcase.features({
lede: 'Each opens the form filled in for that kind of watch; change anything before you save it.',
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}` })),
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, APIs and webhooks today; watches woken by OpenVibe events are made through the API. Browser checks through Run, Node probes and AI readings come later, and are not offered until Watch can really check them.</p>
+${channels.length ? html`<section class="sc-sec" aria-labelledby="h-live"><div class="hm-head"><h2 id="h-live">Know when a channel goes live</h2><a class="hm-more" href="https://openvibe.live/">OpenVibe.Live</a></div>
+<p class="sc-lede">Channels active on OpenVibe.Live lately. A watch on one wakes on the live.stream.started event the moment it goes live: nothing is polled.</p>
+<ul class="hm-grid">${channels.map((c) => html`<li class="hm-card"><div class="hm-card-body"><b><a href="${c.url}">@${c.username}</a></b><span>${[c.category, c.followers ? `${c.followers} follower${c.followers === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</span>${c.live ? raw('<span class="hm-live">Live now</span>') : ''}</div><a class="hm-go" href="${newWatch({ template: 'event', event: 'live.stream.started', event_filter: c.username, name: `@${c.username} goes live` })}">Tell me when they go live</a></li>`)}</ul></section>` : ''}
+<section class="sc-sec" aria-labelledby="h-ready"><h2 id="h-ready">Ready-made watches</h2>
+<p class="sc-lede">Each opens the form already filled in. Check it, change anything, and save.</p>
+${readyGroups.map((g) => html`<h3 class="hm-group">${g.title}</h3><p class="hm-group-lede">${g.lede}</p><ul class="hm-grid">${g.items}</ul>`)}</section>
${raw(showcase.steps({
${raw(showcase.steps({
id: 'steps',
id: 'steps',
title: 'From a URL to a notification',
title: 'From a URL to a notification',
@@ -572,6 +611,17 @@ ${raw(showcase.steps({
@@ -572,6 +611,17 @@ ${raw(showcase.steps({
<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>
<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>
</ul>
<p><a href="/how-it-works">The full picture, in plain words</a>.</p></section>
<p><a href="/how-it-works">The full picture, in plain words</a>.</p></section>
+${raw(showcase.code({
+ id: 'api',
+ title: 'Make watches from your own code',
+ lede: 'The site and the API are the same: create, pause and check watches and read every observation with a token that holds the watch.* capabilities.',
+ samples: [
+ { label: 'Create a watch (curl)', lang: 'sh', code: `curl -X POST ${config.baseUrl}/api/v1/watches \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n -H 'Content-Type: application/json' \\\n -d '{"name": "Node.js releases",\n "source": {"kind": "feed", "url": "https://nodejs.org/en/feed/releases.xml"},\n "cadence": {"every_sec": 3600},\n "extraction": {"kind": "json", "value_path": "latest"},\n "condition": {"op": "changed"},\n "action": [{"kind": "notification"}]}'` },
+ { label: 'Read what it found (curl)', lang: 'sh', code: `# Your watches\ncurl -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n ${config.baseUrl}/api/v1/watches\n\n# Every value one watch observed, newest first\ncurl -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n ${config.baseUrl}/api/v1/watches/wch_…/observations` },
+ ],
+ }))}
+<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
+<div class="hm-faq">${FAQ.map(([q, a]) => html`<div class="hm-qa"><h3>${q}</h3><p>${a}</p></div>`)}</div></section>
${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-yours">Your watches</h2>
${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>` : ''}`,
<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>` : ''}`,
});
});
@@ -639,6 +689,8 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
@@ -639,6 +689,8 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
// A link from an Actor agent's page: run that agent when the watch fires (the person still submits the form).
// A link from an Actor agent's page: run that agent when the watch fires (the person still submits the form).
if (typeof q.agent_id === 'string' && /^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(q.agent_id)) { fields.agent_on = 'on'; fields.agent_id = q.agent_id; fields.notify = ''; }
if (typeof q.agent_id === 'string' && /^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(q.agent_id)) { fields.agent_on = 'on'; fields.agent_id = q.agent_id; fields.notify = ''; }
if (typeof q.event === 'string' && eventChoice(q.event)) fields.event = q.event;
if (typeof q.event === 'string' && eventChoice(q.event)) fields.event = q.event;
+ // The home page's "Tell me when they go live" names the channel: the event's filter (bounded like the form's).
+ if (typeof q.event_filter === 'string' && q.event_filter.length <= 120 && !/[\u0000-\u001f]/.test(q.event_filter)) fields.event_filter = q.event_filter.trim();
return fields;
return fields;
}
}
@@ -19,7 +19,7 @@ const { html, raw, esc } = require('./html');
@@ -19,7 +19,7 @@ const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Watch';
const SITE_NAME = 'OpenVibe.Watch';
-const TAGLINE = 'Get told when a page, a feed or an API changes.';
+const TAGLINE = 'Get told when a page or a feed changes.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's navigation: one entry per public page it serves.
// The product's navigation: one entry per public page it serves.
const NAV = [
const NAV = [
@@ -43,7 +43,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
@@ -43,7 +43,7 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
let RELEASE = 'dev';
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
function setRelease(id) { if (id) RELEASE = String(id); }
-/** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], crumbs, jsonLd */
+/** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], crumbs, jsonLd, css [rel] (a page's own stylesheet) */
function renderPage(o) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
@@ -86,6 +86,7 @@ function renderPage(o) {
@@ -86,6 +86,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'watch' })}
${appIcon.headTags({ site: 'watch' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
+${(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="watch@${RELEASE}">
<meta name="ov-boost" content="watch@${RELEASE}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * The home page's data sections (server/http/pages.js, server/http/network-reads.js): channels on OpenVibe.Live and
+ * ready-made watches read from OpenVibe.Search (a stub here), each opening the new-watch form filled in; a section with
+ * nothing to show is left out; the questions and their FAQPage JSON-LD match; the API sample's body is one the API
+ * accepts.
+ */
+const assert = require('assert');
+const { boot, site, suite, userToken } = require('./helpers');
+const { channelOf, planOf, postOf, createNetworkReads } = require('../server/http/network-reads');
+
+const t = suite('home');
+let svc;
+let search;
+let plain;
+let me;
+
+const doc = (o) => ({ visibility: 'public', title: 'x', summary: '', facets: {}, ...o });
+const RESULTS = {
+ 'live/channel': [
+ doc({ owner: 'live', type: 'channel', id: '1', title: 'quiet (@quiet)', canonical_url: 'https://openvibe.live/@quiet', facets: { live: false, category: 'gaming', followers: 3 } }),
+ doc({ owner: 'live', type: 'channel', id: '2', title: 'goosely (@goosely)', canonical_url: 'https://openvibe.live/@goosely', facets: { live: true, category: 'irl', followers: 20 } }),
+ doc({ owner: 'live', type: 'channel', id: '3', title: 'bad', canonical_url: 'javascript:alert(1)' }),
+ ],
+ 'host/plan': [doc({ owner: 'host', type: 'plan', id: 'hpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP', title: 'OVHcloud VPS-1 2026: $7.60 a month', summary: '4 vCPU · 8 GB RAM', canonical_url: 'https://openvibe.host/catalog/plans/hpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP' })],
+ 'blog/post': [
+ doc({ owner: 'blog', type: 'post', id: 'a', title: 'Patch notes (Oct 22)', canonical_url: 'https://openvibe.blog/@openvibe/a', facets: { blog: 'openvibe' } }),
+ doc({ owner: 'blog', type: 'post', id: 'b', title: 'Older notes', canonical_url: 'https://openvibe.blog/@openvibe/b', facets: { blog: 'openvibe' } }),
+ ],
+};
+
+t('boot', async () => {
+ search = await site({ '/api/v1/search': (req, res, { url }) => ({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ results: RESULTS[`${url.searchParams.get('owner')}/${url.searchParams.get('type')}`] || [] }) }) });
+ svc = await boot({ env: { WATCH_SEARCH_URL: search.origin } });
+ plain = await boot();
+ me = svc.network.addUser('ada');
+});
+
+t('the mappers keep only https documents of the right shape', async () => {
+ assert.deepStrictEqual(channelOf(RESULTS['live/channel'][1]), { username: 'goosely', url: 'https://openvibe.live/@goosely', live: true, category: 'irl', followers: 20 });
+ assert.strictEqual(channelOf(RESULTS['live/channel'][2]), null);
+ assert.strictEqual(channelOf(doc({ canonical_url: 'https://openvibe.live/clips/1' })), null);
+ assert.strictEqual(planOf(doc({ id: 'nope', canonical_url: 'https://openvibe.host/x' })), null);
+ assert.strictEqual(planOf(RESULTS['host/plan'][0]).origin, 'https://openvibe.host');
+ assert.strictEqual(postOf(doc({ title: 'x', canonical_url: 'https://openvibe.blog/x', facets: { blog: '<b>' } })), null);
+ const off = createNetworkReads({ searchUrl: '', fetchImpl: () => { throw new Error('no fetch without a URL'); } });
+ assert.deepStrictEqual(await off.channels(), []);
+ const down = createNetworkReads({ searchUrl: 'https://search.example', fetchImpl: async () => ({ ok: false, status: 503 }), log: { warn() {} } });
+ assert.deepStrictEqual(await down.plans(), []);
+});
+
+t('channels, live first, each with a "go live" watch filled in for it', async () => {
+ const home = await svc.get('/', { headers: { accept: 'text/html' } });
+ assert.strictEqual(home.status, 200);
+ const live = (/<section class="sc-sec" aria-labelledby="h-live">[\s\S]*?<\/section>/.exec(home.text) || [''])[0];
+ const names = [...live.matchAll(/<b><a href="https:\/\/openvibe\.live\/@(\w+)">/g)].map((m) => m[1]);
+ assert.deepStrictEqual(names, ['goosely', 'quiet']);
+ assert.match(live, /<span class="hm-live">Live now<\/span>/);
+ assert.match(live, /irl · 20 followers/);
+ assert.match(live, /href="\/watches\/new\?template=event&event=live\.stream\.started&event_filter=goosely&name=%40goosely\+goes\+live"/);
+ assert.ok(!live.includes('javascript:'));
+});
+
+t('ready-made watches: a Host plan\'s price, feeds, and a blog\'s new posts', async () => {
+ const home = await svc.get('/', { headers: { accept: 'text/html' } });
+ const ready = (/<section class="sc-sec" aria-labelledby="h-ready">[\s\S]*?<\/section>/.exec(home.text) || [''])[0];
+ assert.match(ready, /template=json&url=https%3A%2F%2Fopenvibe\.host%2Fapi%2Fv1%2Fcatalog%2Fplans%2Fhpl_01M4Z4ZZ6B8FC1ZEV0KAQ9XFBP&value_path=plan\.price_monthly&op=changed/);
+ assert.match(ready, /template=feed&url=https%3A%2F%2Fnodejs\.org%2Fen%2Ffeed%2Freleases\.xml/);
+ assert.strictEqual((ready.match(/New posts on @openvibe/g) || []).length, 1, 'one card per blog, with its latest post');
+ assert.match(ready, /Latest: Patch notes \(Oct 22\)/);
+});
+
+t('the form a ready-made link opens is filled in, the channel included', async () => {
+ const r = await svc.get('/watches/new?template=event&event=live.stream.started&event_filter=goosely&name=%40goosely+goes+live', { as: me });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /name="event_filter" value="goosely"/);
+ assert.match(r.text, /value="@goosely goes live"/);
+ const bad = await svc.get(`/watches/new?template=event&event_filter=${encodeURIComponent('a\u0001b')}`, { as: me });
+ assert.match(bad.text, /name="event_filter" value=""/);
+});
+
+t('without OpenVibe.Search the Search sections are left out; the feeds and questions stay', async () => {
+ const home = await plain.get('/', { headers: { accept: 'text/html' } });
+ assert.strictEqual(home.status, 200);
+ assert.ok(!home.text.includes('aria-labelledby="h-live"'));
+ assert.ok(!home.text.includes('Hosting prices'));
+ assert.match(home.text, /New Node\.js releases/);
+ const ld = [...home.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(home.text) || [''])[0];
+ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));
+ assert.ok(shown.length >= 6);
+ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
+ assert.ok(!ld.some((x) => x['@type'] === 'ItemList'), 'no channel list without channels');
+ assert.strictEqual((home.text.match(/<h1[ >]/g) || []).length, 1);
+ assert.match(home.text, /<link rel="stylesheet" href="\/css\/home\.css\?v=[0-9a-f]+">/);
+});
+
+t('the API sample\'s body is one the API accepts', async () => {
+ const r = await svc.get('/api/v1/watches', {
+ bearer: userToken(me.subject),
+ json: { name: 'Node.js releases', source: { kind: 'feed', url: 'https://nodejs.org/en/feed/releases.xml' }, cadence: { every_sec: 3600 }, extraction: { kind: 'json', value_path: 'latest' }, condition: { op: 'changed' }, action: [{ kind: 'notification' }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+});
+
+t('done', async () => { await search.close(); await svc.stop(); await plain.stop(); });
+
+t.run();