openvibers/openvibe.help Commits
OpenVibe.Help: the help centre, search and support tickets
Changes
@@@ -24,5 +24,13 @@ OV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback# HELP_AUDIENCE=openvibe.help++# OpenVibe.Search: the public query API /search asks for the "From across OpenVibe" half of a search. This is the++# only host /search ever calls; a person's query is a parameter, never a URL. Answers are cached in this process++# for HELP_SEARCH_CACHE_MS, and a failure degrades the page to the help articles alone.++#HELP_SEARCH_URL=https://search.openvibe.network++#HELP_SEARCH_LIMIT=8++#HELP_SEARCH_TIMEOUT_MS=2500++#HELP_SEARCH_CACHE_MS=300000++# optional: defaults to true in production# COOKIE_SECURE=true@@@ -2,6 +2,44 @@What changed in OpenVibe.Help, newest first. Each site also publishes its patch notes at /updates.++## 0.2.0 — 2026-10-08++++The product: a help centre generated from OpenVibe.Contracts, search over it and over the network, and support++tickets that stay one conversation.++++- **Help articles from real content, generated at boot.** `server/catalog.js` reads every product manifest and every++ service manifest whose `site` block presents a site, and builds one page per site (`/sites`, `/sites/<domain>`) and++ one page per FAQ entry (`/a/<domain>/<slug>`, one question per page, the question as the title). Every text is the++ manifest's, verbatim; each page names the manifests it read and links back to them.++- **Search** (`server/search.js`, `/search`, `GET /api/v1/articles?q=`): a fixed, explainable score over the question,++ the answer and the site's own text, plus OpenVibe.Search's public API in a second section. The network answer is++ cached in-process for 5 minutes; a failure is shown as a failure and the page falls back to the articles alone.++- **Support tickets** (`migrations/0002_tickets.sql`, `server/tickets/`, `/tickets`, `/staff`): a signed-in person++ opens one (site from the catalog, subject, message, an optional page URL stored as text and never fetched), sees++ only their own, and replies; staff (`admin` / `global_mod` on the session's role claim) read the queue, filter it by++ site and state, reply, and set `open | waiting | solved`. Bodies are plain text, escaped with their line breaks,++ capped at 10 000 characters.++- **API:** `GET /sites`, `GET /sites/:id`, `GET /articles?q=`, `POST|GET /tickets`, `GET /tickets/:id`,++ `POST /tickets/:id/messages`, and for staff `GET /staff/tickets` and `POST /tickets/:id/state`. Writes made with the++ session cookie must come from openvibe.help itself; another person's ticket is a 404; an app token cannot act as a++ person. `http/principal.js` now carries the Network role claim so a route can tell staff from everyone else.++- **Per-caller limits:** `help.ticket.create` 6 a minute / 60 an hour, `help.ticket.message` 30 / 300. The skeleton's++ `caller-limits.test.js` pinned an empty `BUDGETS`; it now pins these.++- **Pages:** the home page is the help centre (a search box, the questions the sites answer, the site grid, contact++ support), plus `/sites`, `/sites/:id`, `/a/:site/:slug`, `/search`, `/tickets`, `/tickets/new`, `/tickets/:id`,++ `/staff` and `/updates`. All server-rendered, all working without JavaScript. The navbar gained Answers, Search and++ Your tickets.++- **Discovery:** the sitemap and `llms-full.txt` are two renderings of one page list, so every site and every question++ is crawlable in both; `llms.txt` lists the sites; `/tickets` and `/staff` are disallowed. The home page's JSON-LD++ gained the sitelinks search action.++- **CSS budget raised** (16.5 → 18.5 KB raw, 4.1 → 4.5 KB brotli) for the help centre's own styles: measured++ 17.5/4.3 KB. The HTML budget did not move — the home page is 25.3 KB against 27.5.++- **Tests:** `test/help-articles.test.js` (the pages against the manifests, escaping), `test/help-search.test.js` (the++ score and the OpenVibe.Search stand-in, `test/helpers/search-provider.js`), `test/tickets.test.js` (the whole++ lifecycle, privacy, cross-site writes, limits). `test/skeleton.test.js` now skips itself in a generated service++ (the skeleton's placeholders are already substituted here, so the generator cannot rebuild the sample) — a skip, not++ a pass.++## 0.1.0 — 2026-10-08- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.@@@ -2,48 +2,132 @@> Answers, support and a way to reach people.--**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.++**Status:** the product is written and tested; it is not deployed yet (see [STATUS.json](STATUS.json)).**Domain:** `openvibe.help` · **Port:** 5020 · **Service id:** `help` · **Env prefix:** `HELP`**License:** AGPL-3.0 (same as every OpenVibe service).--## What is already here++The help and support centre for every OpenVibe site: the answers each site gives about itself, search over those and++over the whole network, and a support ticket that stays one conversation.--| Piece | Where | What it does |--|---|---|---|--| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |--| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |--| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `help_at` / `help_rt` cookies; `/auth/me` for the shared navbar |--| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |--| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |--| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |--| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |--| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |--| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |--| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |--| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |--| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |--| Deploy | [deploy/nginx/openvibe.help.conf](deploy/nginx/openvibe.help.conf), [deploy/systemd/openvibe-help.service](deploy/systemd/openvibe-help.service) | nginx vhost and systemd unit (port 5020, `/opt/openvibe.help`, `/etc/openvibe/help.env`) |--| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |++## What it does--## API++### 1. Help articles, generated from real content — no database, no hand-written copy--| Route | Who | |--|---|---|---|--| `GET /api/v1/ping` | anyone | `{ ok: true, service: "help" }` |++`server/catalog.js` reads, at boot, from `openvibe-contracts`:++++- **every product manifest** (`manifests/products/<domain>.json`) — `name`, `tagline`, `description`, `pillars`, `faq`,++ `keywords`, `launch`, `kind`;++- **every service manifest whose `site` block presents a site** (`manifests/services/<id>.json`) — the same fields,++ plus `exposure.state`.++++It builds one site per domain (a domain that is both a product and a service site is merged into one) and one article++per FAQ entry. Every text is the manifest's, verbatim — this service quotes, it does not write. Each page shows where++its text came from (the manifest paths, a link back, and when it was read).++++| Page | What it is |++|---|---|++| `/sites` | every OpenVibe site: its one-line what, its state (`live` / `coming`), and links |++| `/sites/:id` | one site: its pillars as "what you can do", its FAQ as anchored articles, links to the site and to its `/updates` |++| `/a/:site/:slug` | one question, one page (SEO title = the question), the answer verbatim, links back |++++`live` is the manifest's own `exposure.state === 'live'` on a service that exposes that domain; everything else is++`coming`. Today: 45 sites, 22 live, 20 questions across the 3 sites that carry an `faq`.++++### 2. Search++++`/search?q=` shows two sections:++++- **Help articles** — `server/search.js` scores the generated articles over the question, the answer and the site's own++ text (a phrase hit beats term hits; the question beats the answer; the answer beats the site's blurb). The same++ ranking answers `GET /api/v1/articles?q=`. There is no index and no stemming: the same query always answers the same++ order, and every rule is in one function so a surprising order can be explained instead of tuned.++- **From across OpenVibe** — OpenVibe.Search's public API,++ `GET {HELP_SEARCH_URL}/api/v1/search?q=&limit=8` → `results[{title, summary, canonical_url, type, owner}]`. Answers++ are cached in this process for 5 minutes. A failure (timeout, an error, an answer without a result list) is shown as++ a failure and the page degrades to the articles alone — never to a fake empty index.++++OpenVibe.Search at `search.openvibe.network` is the **only** host `/search` calls; a person's query travels as a query++parameter, never as a URL, and a `canonical_url` becomes a link only when it parses as absolute http(s).++++### 3. Support tickets++++A signed-in person opens a ticket — the site it is about (picked from the catalog), a subject, a message, and++optionally the page they were on. The page URL is **stored as text only and never fetched**; it is rendered as a link++only when it parses as absolute http(s). The first message is the description, so a ticket is never empty; the ticket++page is the conversation; a reply stays there.++++- **A person sees only their own tickets** — anyone else gets 404, exactly like a ticket that does not exist.++- **Staff** — the Network role claim on the session (`admin` or `global_mod`, `server/tickets/staff.js`) — see++ `/staff`: every ticket, filterable by site and state, and they may reply and set `open | waiting | solved`.++- Bodies are plain text rendered escaped with line breaks kept, at most 10 000 characters.++++Tables (`migrations/0002_tickets.sql`): `help_tickets` (`tkt_<ULID>`, requester subject, site, subject, state,++`page_url`, created/updated) and `help_messages` (`ticket id, author subject, role person|staff, body, created_at`).++Neither is ever a credential, key or token.++++### 4. Discovery--Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming--each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in--[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).++`sitemap.xml` and `llms-full.txt` are two renderings of one page list, so every site and every question is crawlable in++both and they cannot drift apart. `llms.txt` lists the sites with their state. `/tickets`, `/staff`, `/auth/` and++`/api/` are disallowed; the home page's JSON-LD carries the sitelinks search action.--**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service--with a Network token for audience `openvibe.help` that holds the route's capability. A write made with the session cookie--must come from `openvibe.help` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.++## Pages++++| Route | What it is |++|---|---|++| `/` | the help centre: a search box, "Popular questions" (the questions the sites answer), "Browse by site", and "Contact support" |++| `/sites`, `/sites/:id`, `/a/:site/:slug` | the generated help centre |++| `/search?q=` | the help articles and the network, in two sections |++| `/tickets/new`, `/tickets`, `/tickets/:id` | open a ticket, your tickets, one conversation (sign in required) |++| `/staff` | the support queue (staff only) |++| `/updates` | the network changelog for this service |++++All of it is server-rendered through the OpenVibe Frame and works without JavaScript: the search box is a GET, the++forms are plain POSTs. A signed-in write must come from openvibe.help itself (as in the API).++++## API++++Everything under `/api/v1`. A person needs no capability (their own tickets, their own session); staff are judged by++their Network role claim. Writes made with the session cookie must be same-origin. Errors are RFC 9457++`application/problem+json` with a stable `code`.++++| Route | Who | What |++|---|---|---|++| `GET /ping` | anyone | `{ ok: true, service: "help" }` |++| `GET /sites` | anyone | the whole catalog with each site's state, one line and source |++| `GET /sites/:id` | anyone | one site: pillars, FAQ (with article URLs), where it lives, its source |++| `GET /articles?q=` | anyone | the generated articles; `?q=` ranks them |++| `POST /tickets` | a person | open a ticket (`{ site?, subject, message, page_url? }`) → 201 `tkt_<ULID>` |++| `GET /tickets` | a person | your tickets, newest first (`?limit=&before=`) |++| `GET /tickets/:id` | a person | one ticket and its conversation (staff may read any; anyone else 404) |++| `POST /tickets/:id/messages` | a person | reply (`{ message }`); a staff reply is marked `staff` |++| `GET /staff/tickets` | staff | the queue (`?site=&state=open\|waiting\|solved\|all`) with counts |++| `POST /tickets/:id/state` | staff | `{ state: "open" \| "waiting" \| "solved" }` |++++Problem codes: `token.required` (401), `help.person_required` (403 — an app token cannot act as a person),++`request.cross_site` (403), `staff.forbidden` (403), `ticket.not_found` (404), `help.site.not_found` (404),++`ticket.invalid` (422), `rate_limited` (429).++++`http/principal.js`'s `CAPABILITIES` is deliberately empty: `openvibe-contracts` declares no `help.*` capability yet,++and every route here is a person acting for themself or staff by their role claim. The day a route exists for an app++(a support widget a site embeds, say), its capability is added here and in `openvibe-contracts` together.## ConfigurationSee [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `help` OAuth client on the--Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the--Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).++Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. Optional: `HELP_SEARCH_URL` (default++`https://search.openvibe.network`), `HELP_SEARCH_LIMIT`, `HELP_SEARCH_TIMEOUT_MS`, `HELP_SEARCH_CACHE_MS`, the++per-caller limits and Valkey. The database is the only required readiness check; the Network signing key, the OAuth++client and Valkey are optional (the service says so, per check, on `/api/ready`).++++## Data sources and their terms++++| Source | What is taken | Terms as applied |++|---|---|---|++| [OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts) `manifests/products/*.json` and `manifests/services/*.json` (pinned `v0.115.0`, read at boot from `node_modules`) | site names, taglines, descriptions, pillars, FAQ, keywords, launch notes, `exposure.state` | Read-only, from the pinned release the service already depends on; quoted verbatim with the manifest path, a link back and the time it was read shown on the page. No text is invented, rewritten or translated. |++| [OpenVibe.Search](https://search.openvibe.network) (`GET /api/v1/search`) | the network half of `/search`: `title`, `summary`, `canonical_url`, `type`, `owner` | Public API (anonymous queries need no token). Fetched only from the configured host, with a `User-Agent` and a timeout, cached 5 minutes, never on a URL a person typed. Failures are shown as failures. |++++Nothing else is fetched. No user-supplied URL is ever requested by this service.## Development@@@ -56,6 +140,22 @@ fnm exec --using=22 npm run dev # http://localhost:5020Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm runtest:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).++## Tests++++`npm test` runs every `test/*.test.js` in its own process, on a temp PGlite database, with an in-process mock of++OpenVibe.Network; outbound HTTP is stubbed (`test/helpers/search-provider.js` stands in for OpenVibe.Search), so++nothing calls the real internet.++++| File | What it proves |++|---|---|++| `test/help-articles.test.js` | the catalog is exactly the manifests (once per domain), every site and every question has a page, the text and the state are the manifest's, escaping |++| `test/help-search.test.js` | the score and its rules, the ranking over the real catalog, both sections, the 5-minute cache, and failures (error, timeout, malformed) degrading to the articles alone |++| `test/tickets.test.js` | the whole lifecycle: create, list own only, reply, staff state change, another person 404, anonymous 401, app tokens refused, cross-site session writes refused, escaping, the 10 000-character cap, per-caller limits |++++`test/skeleton.test.js` skips itself in a generated service: the skeleton's `__ID__`-style placeholders are already++substituted here, so `scripts/new-service.js` (which copies the tree it lives in) cannot rebuild the sample. A skip is++listed with ○ and is not counted as a pass.++## Deploy (for the lead)- **Deploy:** `sudo ovhost deploy help` on the host (git checkout at `/opt/openvibe.help`, unit@@@ -63,17 +163,23 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work- **nginx:** [deploy/nginx/openvibe.help.conf](deploy/nginx/openvibe.help.conf), installed with `ov-vhost-install`.- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: help` in CI) and with-- **OpenVibe.Services** before the first deploy.++ **OpenVibe.Services** before the first deploy. No new environment variable is required to run: `HELP_SEARCH_URL` has++ a default.## Security (threat notes)Reporting a vulnerability: [SECURITY.md](SECURITY.md).- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.++- A ticket belongs to the person who opened it: another person gets 404, and staff are decided by the Network role++ claim on the session, never by anything in the request body.++- Nothing in a request may decide a URL this service fetches. A page URL a person pastes is stored as text and never++ opened; `/search` calls only the fixed `HELP_SEARCH_URL` host, with the query as a parameter. A `canonical_url` from++ OpenVibe.Search becomes a link only when it parses as absolute http(s).++- Every dynamic value in HTML goes through the `html` tagged template; bodies are escaped and shown with their line++ breaks.- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.--- Request bodies are never logged.--- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard-- decides what may be fetched.++- Request bodies are never logged (a ticket body can be personal).---@@@ -1,8 +1,8 @@{-- "stage": "skeleton",++ "stage": "product","code": true,"wave": "W20",-- "plan": "OpenVibe.Help starts from the OpenVibe service skeleton; the product's own plan is written here when it is",++ "plan": "OpenVibe.Help: a help centre generated from OpenVibe.Contracts at boot, search over it and over OpenVibe.Search, and support tickets that stay one conversation","runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "5020","domain": "openvibe.help",@@@ -14,32 +14,42 @@"openvibe-sdk v0.35.2","openvibe-shared v2.14.0"],-- "summary": "OpenVibe.Help — Answers, support and a way to reach people. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",++ "summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.","works": [++ "Help articles generated at boot from openvibe-contracts: 45 sites (22 live, 23 coming) and 20 questions across the 3 sites whose manifest carries an faq, quoted verbatim with provenance (manifest path, link back, read time)",++ "/sites, /sites/:id (pillars as \"what you can do\", the FAQ as anchored articles, links to the site and its /updates) and /a/:site/:slug (one question per page, the question as the title)",++ "Search: a fixed score over question/answer/site text for the help articles, plus OpenVibe.Search's public API in a second section; a 5-minute in-process cache, and a failure shown as a failure",++ "Support tickets: create, list your own, one conversation per ticket, reply, staff queue with filters and open|waiting|solved, all in help_tickets and help_messages",++ "Privacy: another person's ticket is a 404, anonymous is 401, an app token cannot act as a person, and a write with the session cookie must come from openvibe.help",++ "Bodies are plain text, escaped with line breaks kept, capped at 10 000 characters; a page URL is stored as text and never fetched",++ "Per-caller limits: help.ticket.create 6 a minute / 60 an hour, help.ticket.message 30 / 300",++ "The API at /api/v1 with the problem+json error shape: sites, articles, tickets, staff",++ "Crawl artifacts: a sitemap and llms-full.txt built from one page list (every site, every question), llms.txt listing the sites, JSON-LD for the home page, a site and a question","Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",-- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",-- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",-- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",-- "Capability verification for app, agent and service tokens (audience openvibe.help) and for a person's own token","Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",-- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",-- "Deploy files: deploy/nginx/openvibe.help.conf and deploy/systemd/openvibe-help.service",-- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"++ "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits"],"notYet": [-- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",-- "Its service and capability manifests in openvibe-contracts (contracts-service is help)",-- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"++ "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host",++ "Its own service manifest and capability manifests in openvibe-contracts (contracts-service is help): CAPABILITIES in server/http/principal.js is empty until they exist",++ "An email side to a ticket: today one conversation on the web, not yet the same thread by email",++ "A \"Report a problem\" button on every other OpenVibe site (the product manifest's own launch note); a ticket can name a site but no site embeds the form yet",++ "Answers drawn from the docs, wiki, blog and service status (the product manifest's description): today the articles are the sites' own manifests, and the network half of /search is what covers the rest"],"capabilitiesRegistered": false,"serviceManifestRegistered": false,"deployed": false,"placeholderInSites": false,"features": [++ "generated help articles",++ "search (articles + OpenVibe.Search)",++ "support tickets",++ "staff queue","Network sign-in","server-rendered pages","api mount","limits and readiness",++ "discovery","deploy files"],"updated": "2026-10-08"nnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++-- OpenVibe.Help: support tickets and their conversation. A ticket is one conversation from the moment it is++-- opened: the first message is the person's description, every later one is a reply from either side. Nothing++-- written here is ever a credential, a key or a token, and a page URL a person pastes is stored as text only —++-- this service never fetches it.++++CREATE TABLE help_tickets (++ id text COLLATE "C" PRIMARY KEY, -- tkt_<ULID>++ requester text COLLATE "C" NOT NULL, -- user:usr_…, the person who opened it++ site text COLLATE "C" NOT NULL, -- a catalog domain, or '' for "not about one site"++ subject text NOT NULL,++ state text COLLATE "C" NOT NULL, -- open | waiting | solved++ page_url text, -- where they were, as they typed it; text only++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++CREATE INDEX help_tickets_by_requester ON help_tickets (requester, id DESC);++CREATE INDEX help_tickets_by_state ON help_tickets (state, id DESC);++CREATE INDEX help_tickets_by_site ON help_tickets (site, id DESC);++++CREATE TABLE help_messages (++ id text COLLATE "C" PRIMARY KEY, -- msg_<ULID>++ ticket_id text COLLATE "C" NOT NULL REFERENCES help_tickets (id) ON DELETE CASCADE,++ author text COLLATE "C" NOT NULL, -- user:usr_… of whoever wrote it++ role text COLLATE "C" NOT NULL, -- person | staff (which side wrote it)++ body text NOT NULL,++ created_at text COLLATE "C" NOT NULL++);++CREATE INDEX help_messages_by_ticket ON help_messages (ticket_id, id);@@@ -42,3 +42,33 @@ form.inline { display: inline; }.page .sc-ic .ov-icon { --ovi-size: 28px !important; }button.sc-btn { background: transparent; }button.sc-btn.sc-primary { border: 0; }++++/* ── The help centre ─────────────────────────────────────── */++/* Plain text a person typed or a manifest states: escaped, and its own line breaks kept. */++.txt { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5rem 0; }++.search-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }++.search-form input[type="search"] { flex: 1 1 24rem; min-width: 0; padding: 10px 12px; font: inherit; color: inherit; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; }++.search-big input[type="search"] { padding: 14px 16px; font-size: 1.1rem; }++.q-list, .site-list, .results, .ticket-list, .pillars, .conversation { list-style: none; padding: 0; margin: 12px 0; }++.q-list li, .site-list li, .results li, .ticket-list li { padding: 7px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.site-list li .muted, .ticket-list li .muted { margin-left: 6px; }++.site-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 6px 16px; list-style: none; padding: 0; margin: 12px 0; }++.results li p { margin: 2px 0; }++.results.net li { border-bottom-style: dotted; }++mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px; }++.faq-item { margin: 0 0 1.6rem; scroll-margin-top: 1rem; }++.faq-item h3 { margin-bottom: .2rem; }++.prov { margin: 10px 0; }++.site-links { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }++.card { background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }++.ticket-form .field, .filters .field { margin: 12px 0; }++.field label, .reply-form label { display: block; font-weight: 600; margin-bottom: 4px; }++.field input[type="text"], .field select, .field textarea, .reply-form textarea { width: 100%; padding: 9px 11px; font: inherit; color: inherit; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; resize: vertical; }++.field select { max-width: 32rem; }++.filters { display: flex; gap: 16px; flex-wrap: wrap; align-items: end; }++.ticket-list li span { display: block; }++.conversation { display: flex; flex-direction: column; gap: 10px; }++.msg { border-left: 3px solid var(--border-light, #2c3d5c); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }++.msg.staff { border-left-color: var(--accent, #3b82f6); }++.msg-meta { margin: 0 0 4px; }++.state-form label.radio { margin-right: 16px; }@@@ -4,14 +4,16 @@* OpenVibe.Help — Express app factory; server/index.js listens, tests build their own instance with a temp database* and a mock Network.*-- * /, /updates the pages (http/pages.js)-- * /api/v1/* the API (http/api.js)-- * /auth/* Network SSO with PKCE (auth/sso.js)-- * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /, /sites, /sites/:id, /a/:site/:slug, /search the help centre (http/pages.js)++ * /tickets, /tickets/:id, /staff, /updates support and the update log (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)*-- * The product fills this in: its routes in http/api.js, its pages in http/pages.js, its capabilities in-- * http/principal.js and its budgets in http/caller-limits.js. The plumbing here (helmet, SSO, legal pages,-- * static assets, the API mount, the 404 and the error handler) stays.++ * The pages and the API are the product; the plumbing (helmet, SSO, legal pages, static assets, the API mount, the++ * 404 and the error handler) is the skeleton's. The help centre's content is generated here, at boot, from++ * OpenVibe.Contracts (server/catalog.js) — no database, nothing hand-written — and the catalog and the++ * OpenVibe.Search client go into ctx for the pages, the API and the crawl artifacts to share.*/const path = require('path');const express = require('express');@@@ -23,6 +25,8 @@ const cache = require('openvibe-shared/cache-policy');const configLib = require('./config');const { openStore } = require('./db');++const { createCatalog } = require('./catalog');++const { createNetworkSearch } = require('./search');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');@@@ -49,7 +53,11 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, now: s.now, log });const principal = createPrincipal({ config, keys });-- const ctx = { config, s, keys, sso, principal, log };++ // The help centre's content is generated at boot from OpenVibe.Contracts (server/catalog.js): no database,++ // nothing hand-written. The search cache lives in this process for its 5 minutes.++ const catalog = createCatalog({ now: s.now });++ const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });++ const ctx = { config, s, keys, sso, principal, catalog, search, log };const app = express();app.disable('x-powered-by');nnew file mode 100644@@@ -0,0 +1,249 @@++'use strict';++++/**++ * The help centre's content, generated at boot — no database, no hand-written copy. Everything here is read++ * verbatim from OpenVibe.Contracts:++ *++ * manifests/products/<domain>.json a product domain of the catalog: name, tagline, description, pillars,++ * faq, keywords, launch, kind++ * manifests/services/<id>.json a service whose `site` block presents it as a site (the same fields as a++ * product), plus `exposure.state` (live, internal, placeholder, …)++ *++ * A domain can appear in both (openvibe.actor is a service site and a product manifest); the two are merged into++ * one site, so /sites lists each OpenVibe address exactly once. Nothing is invented: a question comes from the++ * manifest's own `faq`, a pillar from its `pillars`, and each page names its source and when it was read.++ *++ * createCatalog({ now }) → { generatedAt, source, sites, byId, articles, byArticle, popular }++ *++ * The manifest text is deliberately NOT normalised, reformatted or translated — it is quoted. `site.url` is the++ * address the manifest itself gives (publicOrigin, else https://<domain>).++ */++const contracts = require('openvibe-contracts');++++const PKG = require('openvibe-contracts/package.json');++++/** Where the text came from, to show next to it (the manifest's repository, from the package itself). */++const SOURCE = (() => {++ const raw = typeof PKG.repository === 'string' ? PKG.repository : (PKG.repository && PKG.repository.url) || '';++ const m = raw.match(/github[:/]+([^/\s]+\/[^/\s#]+?)(?:\.git)?$/i);++ return { name: `OpenVibe.Contracts ${PKG.version}`, url: m ? `https://github.com/${m[1]}` : 'https://openvibe.network' };++})();++++/** A URL-safe slug from arbitrary text: lowercase, runs of anything else become one hyphen, clipped. */++function slugify(text, max = 64) {++ return String(text == null ? '' : text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, max).replace(/-+$/, '') || 'item';++}++++/** The first sentence of a description, for a one-line "what" when the manifest has no `what` of its own. */++function firstSentence(text, max = 180) {++ const s = String(text == null ? '' : text).trim();++ if (!s) return '';++ const cut = s.match(/^[\s\S]*?[.!?](?=\s|$)/);++ const one = (cut ? cut[0] : s).trim();++ return one.length > max ? `${one.slice(0, max - 1).replace(/[\s,;:]$/, '')}…` : one;++}++++/** The host a service manifest's site is served on (products.siteDomain keeps one rule for both homes). */++const siteDomain = contracts.products.siteDomain;++++/** The text pairs a site shows as "what you can do". Kept in manifest order; duplicates dropped. */++function textPairs(list) {++ const seen = new Set();++ const out = [];++ for (const p of Array.isArray(list) ? list : []) {++ const title = String((p && p.title) || '').trim();++ const text = String((p && p.text) || '').trim();++ if (!title || seen.has(title)) continue;++ seen.add(title);++ out.push({ title, text });++ }++ return out;++}++++/** The site's own questions, verbatim; duplicates (a product and a service site may share one) dropped. */++function faqPairs(...lists) {++ const seen = new Set();++ const out = [];++ for (const list of lists) {++ for (const q of Array.isArray(list) ? list : []) {++ const question = String((q && q.question) || '').trim();++ const answer = String((q && q.answer) || '').trim();++ if (!question || !answer || seen.has(question)) continue;++ seen.add(question);++ out.push({ question, answer });++ }++ }++ return out;++}++++/** One service manifest as a catalog entry (its `site` block, plus what exposes it). */++function fromService(m, exposure) {++ const s = m.site;++ return {++ domain: siteDomain(m),++ name: s.name || m.name,++ icon: s.icon || null,++ accent: s.accent || null,++ tagline: s.tagline || '',++ what: s.what || '',++ description: s.description || '',++ pillars: textPairs(s.pillars),++ faq: faqPairs(s.faq),++ keywords: s.keywords || '',++ launch: s.launch || '',++ kind: s.kind || null,++ legalProfile: s.legalProfile || null,++ position: s.position == null ? null : s.position,++ service: m.id,++ exposure,++ home: 'service',++ url: m.publicOrigin || `https://${siteDomain(m)}`,++ manifest: `manifests/services/${m.id}.json`,++ };++}++++/** One product manifest as a catalog entry. */++function fromProduct(p) {++ return {++ domain: p.domain,++ name: p.name,++ icon: p.icon || null,++ accent: p.accent || null,++ tagline: p.tagline || '',++ what: '',++ description: p.description || '',++ pillars: textPairs(p.pillars),++ faq: faqPairs(p.faq),++ keywords: p.keywords || '',++ launch: p.launch || '',++ kind: p.kind || null,++ legalProfile: p.legalProfile || null,++ position: null,++ service: null,++ exposure: null,++ home: 'product',++ url: `https://${p.domain}`,++ manifest: `manifests/products/${p.domain}.json`,++ };++}++++/** Merge a domain's two homes: the service site keeps its exposure, the product manifest keeps its name. */++function merge(serviceEntry, productEntry) {++ if (!serviceEntry) return productEntry;++ if (!productEntry) return serviceEntry;++ return {++ ...serviceEntry,++ name: productEntry.name || serviceEntry.name,++ icon: productEntry.icon || serviceEntry.icon,++ accent: productEntry.accent || serviceEntry.accent,++ tagline: productEntry.tagline || serviceEntry.tagline,++ what: serviceEntry.what || productEntry.what,++ description: productEntry.description || serviceEntry.description,++ pillars: serviceEntry.pillars.length ? serviceEntry.pillars : productEntry.pillars,++ faq: faqPairs(productEntry.faq, serviceEntry.faq),++ keywords: productEntry.keywords || serviceEntry.keywords,++ launch: productEntry.launch || serviceEntry.launch,++ kind: productEntry.kind || serviceEntry.kind,++ legalProfile: productEntry.legalProfile || serviceEntry.legalProfile,++ home: 'both',++ manifests: [productEntry.manifest, serviceEntry.manifest],++ };++}++++/**++ * Every OpenVibe site the catalog knows, with its state, its one line and where to go — the union of the product++ * manifests and the service manifests that present a site. Sorted live first, then by name.++ */++function buildSites() {++ const services = contracts.services.manifests;++ // A domain is live when a service manifest exposes it live, wherever it is named (its site or its domains list).++ const live = new Set();++ for (const m of services) {++ if (!m.exposure || m.exposure.state !== 'live') continue;++ for (const d of m.domains || []) live.add(d);++ if (m.site && m.site.tld) live.add(siteDomain(m));++ }++++ const byDomain = new Map();++ for (const m of services) if (m.site) byDomain.set(siteDomain(m), fromService(m, m.exposure ? m.exposure.state : null));++ for (const p of contracts.products.manifests) byDomain.set(p.domain, merge(byDomain.get(p.domain), fromProduct(p)));++++ const sites = [...byDomain.values()].map((e) => {++ const state = live.has(e.domain) ? 'live' : 'coming';++ const manifests = e.manifests || [e.manifest];++ return {++ ...e,++ id: e.domain,++ state,++ live: state === 'live',++ // A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.++ oneLine: e.what || e.tagline || firstSentence(e.description),++ updatesUrl: `${String(e.url).replace(/\/+$/, '')}/updates`,++ provenance: { source: SOURCE.name, url: SOURCE.url, manifests, path: `/sites/${e.domain}` },++ };++ });++ return sites.sort((a, b) => (a.state === b.state ? String(a.name).localeCompare(String(b.name)) : a.live ? -1 : 1));++}++++/**++ * One article per FAQ entry: /a/<site>/<slug>, one question per page, the answer verbatim. The slug is derived++ * from the question; within a site it is made unique, so a reworded manifest never collides with an existing URL.++ */++function buildArticles(sites) {++ const articles = [];++ const bySite = new Map();++ for (const site of sites) {++ const mine = [];++ const used = new Set();++ for (const { question, answer } of site.faq) {++ let slug = slugify(question);++ for (let n = 2; used.has(slug); n++) slug = `${slugify(question, 60)}-${n}`;++ used.add(slug);++ const article = {++ site: site.domain, siteName: site.name, siteState: site.state, siteUrl: site.url,++ slug, question, answer,++ path: `/a/${site.domain}/${slug}`,++ anchor: slug,++ provenance: site.provenance,++ };++ mine.push(article);++ articles.push(article);++ }++ if (mine.length) bySite.set(site.domain, mine);++ }++ return { articles, bySite };++}++++/**++ * The questions the home page shows: real ones, live sites first, in catalog order. `limit` is a display choice,++ * not a ranking claim, and the page says where they come from.++ */++function popularQuestions(sites, articles, limit = 8) {++ const order = new Map(sites.map((s, i) => [s.domain, i]));++ return [...articles].sort((a, b) => order.get(a.site) - order.get(b.site)).slice(0, limit);++}++++/** Build the catalog once per process (and per test boot), at `now`. */++function createCatalog({ now = () => Date.now() } = {}) {++ const sites = buildSites();++ const byId = new Map(sites.map((s) => [s.id, s]));++ const { articles, bySite } = buildArticles(sites);++ const byArticle = new Map(articles.map((a) => [`${a.site}/${a.slug}`, a]));++ const byPath = new Map(articles.map((a) => [a.path, a]));++ return {++ generatedAt: new Date(now()).toISOString(),++ source: SOURCE,++ sites,++ byId,++ articles,++ bySite,++ byArticle,++ byPath,++ popular: popularQuestions(sites, articles),++ /** A site id a caller may pass: the domain, exactly as the catalog lists it. */++ get: (id) => byId.get(String(id || '')) || null,++ article: (site, slug) => byArticle.get(`${String(site || '')}/${String(slug || '')}`) || null,++ };++}++++module.exports = { createCatalog, SOURCE, siteDomain };@@@ -38,6 +38,16 @@ function load(env = process.env) {db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.HELP_PGLITE_DIR || '' },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:help:' },++ // OpenVibe.Search's public query API (GET /api/v1/search?q=&limit=), asked for the "From across OpenVibe"++ // half of /search. This is the only host /search ever calls: a person's query is a parameter, never a URL.++ // Answers are cached in this process for cacheMs; a failure degrades the page to the articles alone.++ search: {++ url: trim(env.HELP_SEARCH_URL || 'https://search.openvibe.network'),++ limit: Math.min(20, Math.max(1, int(env.HELP_SEARCH_LIMIT, 8))),++ timeoutMs: Math.max(200, int(env.HELP_SEARCH_TIMEOUT_MS, 2500)),++ cacheMs: Math.max(0, int(env.HELP_SEARCH_CACHE_MS, 5 * 60_000)),++ },++// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),@@@ -3,27 +3,199 @@/*** /api/v1 — OpenVibe.Help's API.*-- * GET /ping public liveness: { ok: true, service }++ * GET /sites public every OpenVibe site the catalog knows, with its state++ * GET /sites/:id public one site: its pillars, its FAQ (as articles), where it lives++ * GET /articles?q= public the generated articles; ?q= ranks them++ * POST /tickets person open a ticket (its first message is the description)++ * GET /tickets person your tickets, newest first++ * GET /tickets/:id person one ticket with its conversation (staff may read any)++ * POST /tickets/:id/messages person reply (staff reply on any ticket, as staff)++ * GET /staff/tickets staff the queue, filtered by site and state++ * POST /tickets/:id/state staff set open | waiting | solved*-- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it-- * needs (`principal.requireCapability('<help>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes-- * its per-caller numbers (`limits.reads('<help>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).-- * A refusal is an RFC 9457 problem+json with a stable code.++ * A ticket belongs to the person who opened it: anyone else gets 404 — the same as a ticket that does not exist —++ * unless they are staff by their Network role claim. Writes made with the session cookie must come from++ * openvibe.help itself (same-origin), so another site cannot make a signed-in person open or answer a ticket.++ *++ * No route names a capability: openvibe-contracts declares none for help.* yet, and every route here is a person++ * acting for themself (see CAPABILITIES in ./principal.js).*/const express = require('express');++const contracts = require('openvibe-contracts');const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const tickets = require('../tickets/tickets');++const store = require('../tickets/store');++const { isStaff } = require('../tickets/staff');++const { searchArticles } = require('../search');function createApi(ctx) {-- const { config, principal, limits } = ctx;++ const { config, s, catalog, principal, limits } = ctx;const r = asyncRouter();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });r.use(express.json({ limit: '64kb' }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });r.use(principal.middleware);-- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.++ // Liveness: public, and counted per caller with the default read numbers.r.get('/ping', limits.reads('help.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));++ // ── The catalog (public) ────────────────────────────────++ const siteWire = (site) => {++ const articles = catalog.bySite.get(site.id) || [];++ return {++ id: site.id,++ name: site.name,++ tagline: site.tagline,++ what: site.oneLine,++ description: site.description,++ state: site.state,++ url: site.url,++ updates_url: site.updatesUrl,++ pillars: site.pillars.map((p) => ({ title: p.title, text: p.text })),++ faq: articles.map((a) => ({ question: a.question, answer: a.answer, url: a.path, anchor: a.anchor })),++ article_count: articles.length,++ source: { name: site.provenance.source, url: site.provenance.url, manifests: site.provenance.manifests },++ };++ };++++ r.get('/sites', limits.reads('help.site.read'), (_req, res) => res.json({++ generated_at: catalog.generatedAt,++ source: { name: catalog.source.name, url: catalog.source.url },++ count: catalog.sites.length,++ sites: catalog.sites.map(siteWire),++ }));++++ r.get('/sites/:id', limits.reads('help.site.read'), (req, res) => {++ const site = catalog.get(req.params.id);++ if (!site) return problem(req, res, 404, 'help.site.not_found', `No site "${String(req.params.id).slice(0, 80)}" in the catalog; GET /api/v1/sites lists them.`);++ res.json(siteWire(site));++ });++++ // ── The articles ────────────────────────────────────────++ r.get('/articles', limits.reads('help.article.read'), (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 200) : '';++ const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 50));++ if (!q) {++ const all = catalog.articles.slice(0, limit);++ return res.json({ query: '', count: all.length, articles: all.map((a) => ({ question: a.question, answer: a.answer, site: a.site, site_name: a.siteName, url: a.path })) });++ }++ const hits = searchArticles(catalog, q, { limit });++ return res.json({++ query: q,++ count: hits.length,++ articles: hits.map((h) => ({ question: h.article.question, answer: h.article.answer, snippet: h.snippet, score: h.score, site: h.article.site, site_name: h.article.siteName, url: h.article.path })),++ note: 'Ranked by a fixed score over the question, the answer and the site\'s own text; see the source field of each article.',++ });++ });++++ // ── Who may act ─────────────────────────────────────────++ /**++ * A person, signed in. An app or service token is refused: a ticket is somebody's private conversation with++ * support, and no app acts for a person here. A session write must come from this site.++ */++ function requirePerson(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') {++ problem(req, res, 401, 'token.required', 'Sign in at openvibe.help, or send a person\'s Network token as a Bearer.');++ return false;++ }++ if (p.kind !== 'user') {++ problem(req, res, 403, 'help.person_required', 'A ticket belongs to a person; an app, agent or service token cannot open or answer one.');++ return false;++ }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.help itself.');++ return false;++ }++ return true;++ }++ function requireStaff(req, res) {++ if (!requirePerson(req, res)) return false;++ if (!isStaff(req.principal)) {++ problem(req, res, 403, 'staff.forbidden', 'The support queue is for OpenVibe staff (a Network role of admin or global_mod).');++ return false;++ }++ return true;++ }++ const person = (req, res, next) => (requirePerson(req, res) ? next() : undefined);++ const staff = (req, res, next) => (requireStaff(req, res) ? next() : undefined);++++ // ── Tickets ─────────────────────────────────────────────++ const sites = new Set(catalog.sites.map((x) => x.id));++ const page = (query, def = 20) => ({++ limit: Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || def)),++ before: typeof query.before === 'string' && tickets.isTicketId(query.before) ? query.before : null,++ });++++ /** One ticket, only for the person it belongs to (staff may read any) — otherwise 404, as if it did not exist. */++ async function load(req, res) {++ const id = String(req.params.id || '');++ const row = tickets.isTicketId(id) ? await store.get(s, id) : null;++ const mine = row && row.requester === req.principal.requester;++ if (!row || (!mine && !isStaff(req.principal))) {++ problem(req, res, 404, 'ticket.not_found', 'No such ticket.');++ return null;++ }++ return row;++ }++++ r.post('/tickets', person, limits.budget('help.ticket.create'), async (req, res) => {++ const b = req.body || {};++ const check = tickets.validateNew({ site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, { sites });++ if (!check.ok) return problem(req, res, 422, check.code, check.detail);++ const row = await store.create(s, {++ requester: req.principal.requester,++ site: check.fields.site, subject: check.fields.subject,++ // Text only: this service never fetches a URL a caller sent.++ pageUrl: check.fields.pageUrl, body: check.fields.message,++ });++ res.set('Location', `/api/v1/tickets/${row.id}`);++ return res.status(201).json(tickets.toWire(row));++ });++++ r.get('/tickets', person, limits.reads('help.ticket.list'), async (req, res) => {++ const pageOpts = page(req.query);++ const out = await store.listForRequester(s, req.principal.requester, pageOpts);++ res.json({ tickets: out.rows.map((row) => tickets.toWire(row)), next: out.next });++ });++++ r.get('/tickets/:id', person, limits.reads('help.ticket.read'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ const messages = (await store.messages(s, row.id)).map(tickets.toWireMessage);++ return res.json(tickets.toWire(row, { messages }));++ });++++ r.post('/tickets/:id/messages', person, limits.budget('help.ticket.message'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ const check = tickets.validateMessage(req.body || {});++ if (!check.ok) return problem(req, res, 422, check.code, check.detail);++ const staffWriter = isStaff(req.principal);++ const after = await store.addMessage(s, row.id, { author: req.principal.requester, role: staffWriter ? 'staff' : 'person', body: check.fields.body });++ res.set('Location', `/api/v1/tickets/${row.id}`);++ return res.status(201).json(tickets.toWire(after, { messages: (await store.messages(s, row.id)).map(tickets.toWireMessage) }));++ });++++ // ── Staff ───────────────────────────────────────────────++ r.get('/staff/tickets', staff, limits.reads('help.ticket.staff'), async (req, res) => {++ const state = typeof req.query.state === 'string' && (tickets.isState(req.query.state) || req.query.state === 'all') ? req.query.state : tickets.DEFAULT_STATE;++ const site = typeof req.query.site === 'string' && sites.has(req.query.site) ? req.query.site : null;++ const out = await store.listForStaff(s, { site, state, ...page(req.query, 50) });++ res.json({ filter: { site, state }, counts: await store.staffCounts(s), tickets: out.rows.map((row) => tickets.toWire(row)), next: out.next });++ });++++ r.post('/tickets/:id/state', staff, async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ const state = (req.body || {}).state;++ if (!tickets.isState(state)) return problem(req, res, 422, 'ticket.invalid', `state: one of ${tickets.STATES.join(', ')}`);++ const after = await store.setState(s, row.id, state);++ return res.json(tickets.toWire(after));++ });++return r;}@@@ -26,13 +26,21 @@ function caller(req) {}/**-- * The product's expensive routes, each with its numbers per caller (a minute, an hour). A route takes its entry-- * with `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was-- * not declared here. The empty object is the skeleton's starting point:++ * The product's writes, each with its numbers per caller (a minute, an hour). A route takes its entry with++ * `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was not++ * declared here.*-- * 'help.thing.create': { minute: 6, hour: 60 },++ * help.ticket.create a ticket is an email to a person: a handful an hour is plenty, and the first one is++ * the one that matters++ * help.ticket.message replies are cheap but a conversation can be long, so this is the looser number++ *++ * Staff use the same routes (their role, not a different budget, is what they get); a support queue run by a++ * handful of people should never be throttled into silence, which is why the numbers are per caller, not global.*/--const BUDGETS = {};++const BUDGETS = {++ 'help.ticket.create': { minute: 6, hour: 60 },++ 'help.ticket.message': { minute: 30, hour: 300 },++};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {const refused = registry@@@ -2,10 +2,12 @@/*** Crawl artifacts for openvibe.help, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and-- * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and-- * the API are not.++ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in,++ * the API, a person's tickets and the support queue are not.*-- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.++ * The sitemap and llms-full.txt are two renderings of one list (publicPages), so a page can never be in one and++ * missing from the other — including the help centre itself: every site and every question is a page here, which++ * is exactly what makes the answers findable.*/const fs = require('fs');const path = require('path');@@@ -14,13 +16,8 @@ const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Help';--const DESCRIPTION = 'OpenVibe.Help — Answers, support and a way to reach people.';--const DISALLOW = ['/auth/', '/api/'];----const PAGE_TEXT = {-- '/': ['OpenVibe.Help home', 'OpenVibe.Help: Answers, support and a way to reach people.'],-- '/updates': ['What shipped on OpenVibe.Help', 'This site\'s update log, from the network changelog feed.'],--};++const DESCRIPTION = 'OpenVibe.Help is the help and support centre for every OpenVibe site: search the answers each site gives about itself, browse them by site, and open a support ticket that stays one conversation.';++const DISALLOW = ['/auth/', '/api/', '/tickets', '/staff'];function dayOf(ts) {const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);@@@ -33,67 +30,93 @@ function siteUpdated() {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: 'BusinessApplication', keywords: 'openvibe' }),++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION, searchUrl: `${site}/search?q={q}` }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe help, openvibe support, help center, support tickets, troubleshooting' }),seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),];}--const publicPages = () => [-- { path: '/', changefreq: 'weekly', priority: 1.0 },-- { path: '/updates', changefreq: 'daily', priority: 0.5 },--];++/**++ * Every public page, in the order a crawler should read them: the site's own pages, then one per catalog site, then++ * one per question. Each entry carries its own title and one-line text, so the sitemap and llms-full.txt are two++ * renderings of this one list and cannot drift apart.++ */++function publicPages(catalog) {++ const static_ = [++ { path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Help home', text: 'Search the answers of every OpenVibe site, browse the sites one by one, or open a support ticket that stays one conversation.' },++ { path: '/sites', changefreq: 'weekly', priority: 0.9, title: 'Every OpenVibe site', text: `${catalog.sites.length} OpenVibe sites with what each one is, whether it is live yet, and its own questions and answers.` },++ { path: '/search', changefreq: 'weekly', priority: 0.7, title: 'Search the help centre', text: 'Search every OpenVibe site\'s own answers, and the whole network through OpenVibe.Search.' },++ { path: '/updates', changefreq: 'daily', priority: 0.5, title: `What shipped on ${SITE_NAME}`, text: 'This site\'s update log, from the network changelog feed.' },++ ];++ const sites = catalog.sites.map((s) => ({++ path: `/sites/${s.id}`, changefreq: 'weekly', priority: s.live ? 0.8 : 0.6,++ title: s.name, text: `${s.state === 'live' ? 'Live' : 'Coming'}: ${s.oneLine} ${s.description || ''}`.trim(),++ }));++ const articles = catalog.articles.map((a) => ({++ path: a.path, changefreq: 'monthly', priority: 0.5,++ title: a.question, text: `${a.answer} (Answered by ${a.siteName}.)`,++ }));++ return [...static_, ...sites, ...articles];++}function createDiscoveryRoutes(ctx) {-- const { config } = ctx;++ const { config, catalog } = ctx;const r = asyncRouter();const site = String(config.baseUrl).replace(/\/+$/, '');const abs = (p) => `${site}${p}`;const TEXT = cache.htmlHeaders({ maxAge: 3600 });++ const pages = publicPages(catalog);r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(-- '# openvibe.help: the public pages are for search and AI crawlers; sign-in and the API are not.\n'++ '# openvibe.help: the public pages are for search and AI crawlers; sign-in, the API and tickets 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: 'OpenVibe.Help: Answers, support and a way to reach people.',-- details: 'Every page is server-rendered and readable without JavaScript.',++ summary: 'OpenVibe.Help: the help and support centre for every OpenVibe site. The answers are generated from OpenVibe.Contracts at boot — each site\'s own manifest, verbatim — so a page here is the product\'s own words, with a link back to where they were read.',++ details: 'Every site and every question is a page: /sites lists them, /sites/<domain> is one site with its pillars and its questions, and /a/<domain>/<slug> is one question with its answer. /search searches those answers and the network through OpenVibe.Search. Signed in, a person can open a support ticket; the ticket and its conversation are private to them (and to OpenVibe staff). Every page is server-rendered and readable without JavaScript.',sections: [{ title: 'Start here', links: [-- { title: 'OpenVibe.Help', url: abs('/'), note: 'Answers, support and a way to reach people.' },-- { title: 'What shipped on OpenVibe.Help', url: abs('/updates') },++ { title: 'OpenVibe.Help', url: abs('/'), note: 'search the answers, browse the sites, open a ticket' },++ { title: 'Every OpenVibe site', url: abs('/sites'), note: `${catalog.sites.length} sites with their state and their own words` },++ { title: 'Search', url: abs('/search?q=help'), note: 'the help articles and the network, in two sections' },++ { title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },] },++ { title: 'Sites', links: catalog.sites.map((s) => ({ title: s.name, url: abs(`/sites/${s.id}`), note: `${s.live ? 'live' : 'coming'} — ${s.oneLine}` })) },{ title: 'Machine-readable', links: [++ { title: 'Every site (JSON)', url: abs('/api/v1/sites') },++ { title: 'One site (JSON)', url: abs(`/api/v1/sites/${catalog.sites[0] ? catalog.sites[0].id : 'openvibe.network'}`) },++ { title: 'The articles (JSON, ?q= ranks them)', url: abs('/api/v1/articles?q=live') },{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Full text for language models', url: abs('/llms-full.txt') },{ title: 'Release metadata (JSON)', url: abs('/release.json') },] },{ title: 'Elsewhere', links: [-- { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },++ { title: 'OpenVibe.Search', url: 'https://search.openvibe.network', note: 'the network\'s own index, the "From across OpenVibe" half of /search' },{ title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },] },],}));});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] }));++ const entries = pages.map((p) => ({ url: p.path, title: p.title, text: p.text }));res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({site: SITE_NAME,-- summary: 'Every public page of OpenVibe.Help, one line each.',++ summary: 'Every public page of OpenVibe.Help, one line each: the site, and every question each site answers about itself.',base: site,-- maxBytes: 64 * 1024,-- sections: [{ title: 'Pages', pages }],++ maxBytes: 256 * 1024,++ sections: [{ title: 'Pages', pages: entries }],}));});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 }));++ const urls = pages.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));});@@@ -1,70 +1,473 @@'use strict';/**-- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,-- * llms-full.txt, JSON-LD) are http/discovery.js.++ * Public pages: the help centre generated from OpenVibe.Contracts (home, /sites, one site, one question) and the++ * support side (/search, /tickets, one ticket, /staff, the update log). Crawl artifacts are http/discovery.js.*-- * Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js).-- * The product replaces the home page's sections and adds its own routes here.++ * Every page is server-rendered and complete without JavaScript — forms are plain POSTs and the search box is a++ * GET — through openvibe-shared/shell (render/layout.js). Nothing here is hand-written copy: the sites, the one++ * lines, the pillars and every question and answer come from the manifests (server/catalog.js), and each page says++ * which manifest it read. A signed-in write must come from openvibe.help itself, as in the API.*/++const express = require('express');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');++const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');--const { html, raw } = require('../render/html');++const { sameOrigin } = require('./principal');++const { html, raw, table, notice, time, badge } = require('../render/html');const { send } = require('../render/layout');++const { searchArticles, markTerms } = require('../search');++const tickets = require('../tickets/tickets');++const store = require('../tickets/store');++const { isStaff } = require('../tickets/staff');const SITE_NAME = 'OpenVibe.Help';const TAGLINE = 'Answers, support and a way to reach people.';++const PAGE_LIMIT = 50;++++const STATE_KIND = { open: 'info', waiting: 'warn', solved: 'ok' };++const STATE_TEXT = { open: 'open', waiting: 'waiting', solved: 'solved' };++const SITE_STATE_KIND = { live: 'ok', coming: '' };++const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };++++/** Plain text exactly as it was typed: escaped by the template, line breaks kept by the stylesheet. */++const textBlock = (t) => html`<p class="txt">${t}</p>`;++const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));function createPageRoutes(ctx) {-- const { config } = ctx;++ const { config, s, catalog, search } = ctx;const r = asyncRouter();++ // The pages' forms are plain POSTs (no JavaScript): urlencoded only, and small — a ticket is text.++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const staffViewer = (req) => isStaff(req.viewer);++ const requesterOf = (req) => `user:${req.viewer.subject}`;++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++++ // ── Small pieces ─────────────────────────────────────────++ const siteBadge = (state) => badge(SITE_STATE_TEXT[state] || state, SITE_STATE_KIND[state] || '');++ const ticketBadge = (state) => badge(STATE_TEXT[state] || state, STATE_KIND[state] || '');++++ /** Where a page's text came from: the manifests it was read from, and when. */++ const provenance = (p, { compact = false } = {}) => {++ const files = p.manifests || (p.manifest ? [p.manifest] : []);++ return html`<p class="prov small muted">Generated from <a href="${p.url}" rel="noopener nofollow">${p.source}</a>${files.length ? html` · ${files.map((f) => html`<code>${f}</code>`)}` : ''}${compact ? '' : html` · read ${time(catalog.generatedAt)}`}</p>`;++ };++++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;++++ const searchForm = (q = '', { big = false } = {}) => html`<form class="search-form${big ? ' search-big' : ''}" method="get" action="/search" role="search">++<label class="sr-only" for="q">Search the help centre</label>++<input id="q" name="q" type="search" value="${q}" placeholder="Search every OpenVibe site: how a feature works, what a price is, what is not working" maxlength="200" required>++<button class="sc-btn sc-primary" type="submit">Search</button>++</form>`;// ── Home ─────────────────────────────────────────────────++ /** Contact support: the form itself is /tickets/new, and a visitor who is not signed in is asked to sign in. */++ function contactBlock(req) {++ if (!signedIn(req)) {++ return html`<div class="card">${signInPrompt('To open a ticket', '/tickets/new')}++<p class="small muted">A ticket is one conversation with the people who run OpenVibe, kept in your account — not an inbox you lose. Signing in also lets you see and answer it later.</p></div>`;++ }++ return html`<div class="card"><p class="small muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>.</p>++<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a> <a class="sc-btn" href="/tickets">Your tickets</a></p></div>`;++ }++r.get('/', (req, res) => {++ const live = catalog.sites.filter((x) => x.live);const hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,-- title: SITE_NAME,-- accent: TAGLINE,-- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,++ title: 'How can we help?',++ accent: 'Every OpenVibe site in one place.',++ lede: `The answers come from the sites themselves: each product's own manifest, kept word for word, with a link back to where it was read. Search it, or open a ticket and it stays one conversation.`,actions: signedIn(req)-- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],-- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',++ ? [{ label: 'Your tickets', href: '/tickets' }, { label: 'Open a ticket', href: '/tickets/new', primary: true }]++ : [{ label: 'Search the answers', href: '#search', primary: true }, { label: 'Sign in to open a ticket', href: '/auth/login?next=%2Ftickets%2Fnew' }],++ note: `Open source (AGPL-3.0). ${catalog.sites.length} sites in the catalog: ${live.length} live, ${catalog.sites.length - live.length} still coming.`,});page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,jsonLd: homeJsonLd(config),++ description: `Search the answers of every OpenVibe site, browse them one by one, or open a support ticket that stays one conversation.`,styles: [showcase.STYLESHEET],body: html`${raw(hero)}--${raw(showcase.features({-- title: 'What is already here',-- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',-- items: [-- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },-- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },-- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },-- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },-- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },-- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },-- ],-- }))}--${raw(showcase.steps({-- title: 'How a new service starts',-- items: [-- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },-- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },-- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },-- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },-- ],-- }))}--${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,++<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the help centre</h2>++<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>++${searchForm()}</section>++<section class="sc-sec" aria-labelledby="h-popular"><h2 id="h-popular">Popular questions</h2>++<p class="sc-lede">Every line below is a question an OpenVibe product answers about itself in its own manifest.</p>++<ul class="q-list">${catalog.popular.map((a) => html`<li><a href="${a.path}">${a.question}</a> <span class="muted small">${a.siteName}</span></li>`)}</ul>++<p class="small"><a href="/sites">All ${catalog.sites.length} sites and their questions</a></p></section>++<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>++<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>++<ul class="site-grid">${catalog.sites.map((x) => html`<li><a href="/sites/${x.id}">${x.name}</a></li>`)}</ul></section>++<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>++<p class="sc-lede">Nothing here answered it? Open a ticket: you and the people who run OpenVibe, in one thread.</p>++${contactBlock(req)}</section>++${raw(showcase.cta({ title: 'What shipped on OpenVibe.Help', text: 'The update log is the network changelog, filtered to this service.', actions: [{ label: 'The update log', href: '/updates' }, { label: 'The API', href: '/api/v1/sites' }] }))}`,++ });++ });++++ // ── The sites ────────────────────────────────────────────++ r.get('/sites', (req, res) => {++ const group = (state, title, lede) => {++ const list = catalog.sites.filter((x) => x.state === state);++ if (!list.length) return '';++ return html`<section class="sc-sec" aria-labelledby="h-${state}"><h2 id="h-${state}">${title}</h2><p class="sc-lede">${lede}</p>++<ul class="site-list">${list.map((x) => html`<li><a href="/sites/${x.id}"><b>${x.name}</b></a> <span class="muted">${x.oneLine}</span> <span class="small"><a href="${x.url}" rel="noopener nofollow">${x.domain}</a></span></li>`)}</ul></section>`;++ };++ page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Every OpenVibe site',++ description: `${catalog.sites.length} OpenVibe sites with what each one is, whether it is live yet, and its own questions and answers.`,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites' }],++ jsonLd: [++ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: 'Sites', url: `${base()}/sites` }]),++ seo.jsonLd.itemList('OpenVibe sites', catalog.sites.map((x) => ({ name: x.name, url: `${base()}/sites/${x.id}`, description: x.oneLine })), { url: `${base()}/sites` }),++ ],++ body: html`<h1>Every OpenVibe site</h1>++<p class="sc-lede">${catalog.sites.length} addresses, ${catalog.sites.filter((x) => x.live).length} of them live. The state is the service manifest's own <code>exposure.state</code>: a site is live when a service exposes it live. Everything else on this page is that site's manifest, word for word.</p>++${provenance(catalog.source)}++${group('live', 'Live', 'These answer today.')}++${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serving something else for now.')}`,++ });++ });++++ r.get('/sites/:id', (req, res) => {++ const site = catalog.get(req.params.id);++ if (!site) return page(req, res, {++ title: 'No such site', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: 'Not found' }],++ body: html`<h1>No such site</h1><p class="muted">The catalog has no site <code>${truncate(String(req.params.id), 80)}</code>. <a href="/sites">See all ${catalog.sites.length} sites</a>.</p>`,++ }, 404);++ const articles = catalog.bySite.get(site.id) || [];++ const faqPairs = articles.map((a) => ({ q: a.question, a: a.answer }));++ page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: site.name,++ description: truncate(site.oneLine || site.description, 160),++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: site.name }],++ jsonLd: [++ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: 'Sites', url: `${base()}/sites` }, { name: site.name, url: `${base()}/sites/${site.id}` }]),++ faqPairs.length ? seo.jsonLd.faq(faqPairs) : null,++ ],++ body: html`<h1>${site.name} ${siteBadge(site.state)}</h1>++${site.tagline ? html`<p class="sc-lede">${site.tagline}</p>` : ''}++${site.description ? textBlock(site.description) : ''}++<p class="site-links">${site.state === 'live'++ ? html`<a class="sc-btn sc-primary" href="${site.url}" rel="noopener nofollow">Open ${site.domain}</a>`++ : html`<span class="muted">Not serving yet — ${site.url} is its address.</span>`}++<a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped on ${site.name}</a></p>++${provenance(site.provenance)}++++${site.pillars.length ? html`<section class="sc-sec" aria-labelledby="h-pillars"><h2 id="h-pillars">What you can do</h2>++<ul class="pillars">${site.pillars.map((p) => html`<li><b>${p.title}</b>${p.text ? html` <span class="muted">${p.text}</span>` : ''}</li>`)}</ul></section>` : ''}++++<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>++${articles.length++ ? html`<p class="sc-lede">${articles.length} question${articles.length === 1 ? '' : 's'} this site answers about itself. Each one has its own page, so it can be linked and found on its own.</p>++${articles.map((a) => html`<article class="faq-item" id="${a.anchor}">++<h3><a href="${a.path}">${a.question}</a></h3>++${textBlock(a.answer)}++<p class="small"><a href="${a.path}">Open this question on its own page</a></p></article>`)}`++ : html`<p class="muted">This site's manifest has no questions yet.${site.state === 'coming' ? ' It is still coming.' : ''} <a href="/tickets/new">Ask us instead</a> or read the site's own pages at <a href="${site.url}" rel="noopener nofollow">${site.domain}</a>.</p>`}++</section>`,++ });++ });++++ // ── One question, one page (SEO title = the question) ────++ r.get('/a/:site/:slug', (req, res) => {++ const article = catalog.article(req.params.site, req.params.slug);++ if (!article) return page(req, res, {++ title: 'No such question', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: 'Not found' }],++ body: html`<h1>No such question</h1><p class="muted">No answer here for <code>${truncate(`${req.params.site}/${req.params.slug}`, 90)}</code>. <a href="/sites">See every site</a>.</p>`,++ }, 404);++ const site = catalog.get(article.site);++ page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: article.question,++ description: truncate(article.answer, 160),++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],++ jsonLd: [++ seo.jsonLd.article({ headline: article.question, url: `${base()}${article.path}`, description: truncate(article.answer, 160) }),++ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: article.siteName, url: `${base()}/sites/${article.site}` }, { name: article.question, url: `${base()}${article.path}` }]),++ ],++ body: html`<article class="answer">++<h1>${article.question}</h1>++<p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>++${textBlock(article.answer)}++<p class="site-links"><a class="sc-btn" href="/sites/${article.site}">Everything ${article.siteName} answers</a>++${site ? html`<a class="sc-btn" href="${site.url}" rel="noopener nofollow">${site.domain}</a><a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped</a>` : ''}</p>++${provenance(article.provenance)}++</article>++<section class="sc-sec" aria-labelledby="h-more"><h2 id="h-more">Other questions from ${article.siteName}</h2>++<ul class="q-list">${(catalog.bySite.get(article.site) || []).filter((a) => a.slug !== article.slug).map((a) => html`<li><a href="${a.path}">${a.question}</a></li>`)}</ul></section>`,++ });++ });++++ // ── Search ───────────────────────────────────────────────++ r.get('/search', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 200) : '';++ const hits = q ? searchArticles(catalog, q, { limit: 20 }) : [];++ const network = q ? await search.search(q) : { ok: false, reason: 'no query', results: [] };++ page(req, res, {++ // Indexable, and in the sitemap: every query canonicalises to /search (the layout drops the query), so a++ // crawler keeps one page, not one per query.++ index: true, cache: PUBLIC_CACHE,++ title: q ? `Search: ${truncate(q, 60)}` : 'Search',++ description: 'Search every OpenVibe site\'s own answers, and the whole network through OpenVibe.Search.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],++ body: html`<h1>Search</h1>++${searchForm(q, { big: true })}++${!q ? html`<p class="sc-lede">Type something above. The first half of the answer comes from each site's own manifest; the second from <a href="${search.url}" rel="noopener nofollow">OpenVibe.Search</a>, the network's own index.</p>` : ''}++${q ? html`<section class="sc-sec" aria-labelledby="h-help"><h2 id="h-help">Help articles</h2>++${hits.length++ ? html`<p class="sc-lede">${hits.length} article${hits.length === 1 ? '' : 's'} from the sites' own manifests, best match first.</p>++<ol class="results">${hits.map((h) => html`<li><a href="${h.article.path}">${markTerms(h.article.question, q)}</a>++<p class="small muted">${markTerms(h.snippet, q)}</p>++<p class="small"><a href="/sites/${h.article.site}">${h.article.siteName}</a></p></li>`)}</ol>`++ : html`<p class="muted">No article matches “${q}”. Try fewer or different words, <a href="/sites">browse the sites</a>, or <a href="/tickets/new">open a ticket</a>.</p>`}++</section>` : ''}++${q ? html`<section class="sc-sec" aria-labelledby="h-net"><h2 id="h-net">From across OpenVibe</h2>++${network.ok++ ? (network.results.length++ ? html`<p class="sc-lede">From <a href="${search.url}" rel="noopener nofollow">OpenVibe.Search</a>, the network's index of streams, clips, pastes, posts and wiki pages.</p>++<ul class="results net">${network.results.map((x) => html`<li><a href="${x.url}" rel="noopener nofollow">${x.title}</a>${x.summary ? html`<p class="small muted">${x.summary}</p>` : ''}<p class="small">${x.type ? html`<span class="badge">${x.type}</span> ` : ''}${x.owner ? html`<span class="muted">${x.owner}</span>` : ''}</p></li>`)}</ul>`++ : html`<p class="muted">OpenVibe.Search answered, with nothing for “${q}”.</p>`)++ : html`<p class="muted">OpenVibe.Search could not be reached just now (<span class="badge warn">${network.reason}</span>), so this page shows the help articles only. Try again in a moment.</p>`}++</section>` : ''}`,++ });++ });++++ // ── Tickets ──────────────────────────────────────────────++ /** A page write must come from this site, exactly as in the API: a cross-site form must not act as the person. */++ function sameSite(req, res, back) {++ if (sameOrigin(req, config.baseUrl)) return true;++ page(req, res, {++ title: 'Not from this site',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Not from this site' }],++ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.help itself. <a href="${back}">Go back</a> and try again.</p>`,++ }, 403);++ return false;++ }++++ /**++ * The same per-caller budget the API applies to opening a ticket and replying, given an HTML face. The limiter++ * writes its refusal straight to the response, so it writes to a shim and the page renders its own 429 — a person++ * using the form should not be handed problem+json. The counters, the windows and the metric are the API's: the++ * form is the same action through another door, so it cannot be a way round the limit.++ */++ const budgeted = (name) => {++ const limit = ctx.limits.budget(name);++ return (req, res, next) => {++ let settled = false;++ const refusal = (body) => {++ let problem = null;++ try { problem = JSON.parse(String(body)); } catch { problem = null; }++ if (problem && problem.retry_after_seconds) res.set('Retry-After', String(problem.retry_after_seconds));++ return page(req, res, {++ title: 'Too many requests',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Too many requests' }],++ body: html`<h1>Too many requests just now</h1>++${notice(problem && problem.detail ? problem.detail : 'You are over the limit for this', 'warn')}++<p class="muted">Wait a moment and send it again — nothing was lost. Everything you already opened is under <a href="/tickets">your tickets</a>.</p>`,++ }, 429);++ };++ limit(req, {++ statusCode: 0,++ setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},++ end: (body) => { if (!settled) { settled = true; refusal(body); } },++ }, (err) => {++ if (settled) return undefined;++ settled = true;++ return err ? next(err) : next();++ });++ return undefined;++ };++ };++++ /** The site picker: every catalog site, live ones first (the catalog is already in that order). */++ function siteSelect(selected = '') {++ const group = (state, label) => {++ const list = catalog.sites.filter((x) => x.state === state);++ if (!list.length) return '';++ return html`<optgroup label="${label}">${list.map((x) => html`<option value="${x.id}"${x.id === selected ? raw(' selected') : ''}>${x.name}${x.live ? '' : ' (coming)'}</option>`)}</optgroup>`;++ };++ return html`<select id="site" name="site"><option value="">Not about one site</option>${group('live', 'Live')}${group('coming', 'Coming')}</select>`;++ }++++ const ticketForm = ({ values = {}, problem = null } = {}) => html`<form class="card ticket-form" method="post" action="/tickets">++${problem ? notice(problem, 'warn') : ''}++<div class="field"><label for="site">Which site is this about?</label>${siteSelect(values.site || '')}++<p class="small muted">Optional. Pick the OpenVibe site you were using, if it was one.</p></div>++<div class="field"><label for="subject">Subject</label><input id="subject" name="subject" type="text" maxlength="${tickets.LIMITS.subject}" required value="${values.subject || ''}"></div>++<div class="field"><label for="message">What happened?</label><textarea id="message" name="message" rows="8" maxlength="${tickets.LIMITS.body}" required placeholder="What you did, what you expected, what happened instead.">${values.message || ''}</textarea>++<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters.</p></div>++<div class="field"><label for="page_url">Page you were on</label><input id="page_url" name="page_url" type="text" maxlength="${tickets.LIMITS.pageUrl}" value="${values.pageUrl || ''}" placeholder="https://openvibe.example/some/page">++<p class="small muted">Optional, and only useful if you have it: it is stored as text and never opened by us.</p></div>++<p><button class="sc-btn sc-primary" type="submit">Open the ticket</button> <a class="sc-btn" href="/tickets">Your tickets</a></p>++</form>`;++++ r.get('/tickets/new', (req, res) => {++ if (!signedIn(req)) return page(req, res, {++ title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],++ body: html`<h1>Open a ticket</h1>${signInPrompt('A ticket belongs to your OpenVibe account, so you can see and answer it later.', '/tickets/new')}`,++ }, 401);++ page(req, res, {++ title: 'Open a ticket',++ description: 'Tell OpenVibe support what happened; the ticket stays one conversation.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],++ body: html`<h1>Open a ticket</h1>++<p class="sc-lede">One conversation with the people who run OpenVibe. You will see it under <a href="/tickets">your tickets</a> and can reply there.</p>++${ticketForm()}`,++ });++ });++++ const mustSignIn = (back) => (req, res, next) => (signedIn(req) ? next()++ : page(req, res, { title: 'Sign in', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sign in' }], body: html`<h1>Sign in first</h1>${signInPrompt('A ticket belongs to an account, so you can see and answer it later.', back)}` }, 401));++++ r.post('/tickets', mustSignIn('/tickets/new'), budgeted('help.ticket.create'), async (req, res) => {++ if (!sameSite(req, res, '/tickets/new')) return;++ const b = req.body || {};++ const check = tickets.validateNew({ site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, { sites: new Set(catalog.sites.map((x) => x.id)) });++ if (!check.ok) return page(req, res, {++ title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],++ body: html`<h1>Open a ticket</h1>${ticketForm({ values: { site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, problem: check.detail })}`,++ }, 422);++ const row = await store.create(s, {++ requester: requesterOf(req), site: check.fields.site, subject: check.fields.subject,++ // Text only: this service never fetches a URL a caller sent.++ pageUrl: check.fields.pageUrl, body: check.fields.message,++ });++ return res.redirect(303, `/tickets/${row.id}`);++ });++++ const canSee = (req, row) => Boolean(row) && (row.requester === requesterOf(req) || staffViewer(req));++++ r.get('/tickets', async (req, res) => {++ if (!signedIn(req)) return page(req, res, {++ title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],++ body: html`<h1>Your tickets</h1>${signInPrompt('Your tickets belong to your OpenVibe account.', '/tickets')}`,++ }, 401);++ const out = await store.listForRequester(s, requesterOf(req), { limit: PAGE_LIMIT });++ return page(req, res, {++ title: 'Your tickets',++ description: 'The support tickets you opened, and where each conversation stands.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],++ body: html`<h1>Your tickets</h1>++<p class="sc-lede">Everything you opened, newest first. A ticket stays one conversation — reply on its page and it carries on there.</p>++${staffViewer(req) ? notice(html`You are OpenVibe staff, so you can also <a href="/staff">read the whole support queue</a>.`, 'info') : ''}++<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a></p>++${out.rows.length++ ? html`<ul class="ticket-list">${out.rows.map((t) => html`<li><a href="/tickets/${t.id}"><b>${t.subject}</b></a> ${ticketBadge(t.state)}++<span class="muted small">${t.site || 'not about one site'} · ${t.message_count} message${Number(t.message_count) === 1 ? '' : 's'} · updated ${time(t.updated_at)}</span></li>`)}</ul>`++ : html`<p class="muted">No tickets yet. <a href="/tickets/new">Open one</a> if a site did not answer your question.</p>`}++${out.next ? html`<p><a href="/tickets?before=${out.next}">Older tickets</a></p>` : ''}`,++ });++ });++++ r.get('/tickets/:id', async (req, res) => {++ if (!signedIn(req)) return page(req, res, {++ title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Tickets', href: '/tickets' }, { label: 'Ticket' }],++ body: html`<h1>A ticket</h1>${signInPrompt('A ticket belongs to an OpenVibe account.', `/tickets/${encodeURIComponent(String(req.params.id).slice(0, 40))}`)}`,++ }, 401);++ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;++ if (!canSee(req, row)) return page(req, res, {++ title: 'No such ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'Not found' }],++ body: html`<h1>No such ticket</h1><p class="muted">There is no ticket here for you. <a href="/tickets">Your tickets</a>.</p>`,++ }, 404);++ const messages = await store.messages(s, row.id);++ const isMine = row.requester === requesterOf(req);++ const link = tickets.pageLink(row.page_url);++ // Who wrote a message, read from where the viewer sits: "You" for your own, the staff by their side, and for++ // staff a stranger is "the person who opened it" (their subject id is on the page, not their name).++ const whoLabel = (m) => (m.role === 'staff' ? 'OpenVibe support'++ : (m.author === requesterOf(req) ? 'You' : (m.author === row.requester ? 'The person who opened it' : m.author)));++ page(req, res, {++ title: row.subject,++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: truncate(row.subject, 50) }],++ body: html`<h1>${row.subject}</h1>++<p>${ticketBadge(row.state)} <span class="muted small">${row.site ? html`about <a href="/sites/${row.site}">${row.site}</a>` : 'not about one site'} · opened ${time(row.created_at)}${isMine ? '' : html` · by <code>${row.requester}</code>`}</span></p>++${row.page_url ? html`<p class="small muted">Page they were on: ${link ? html`<a href="${link}" rel="noopener nofollow">${row.page_url}</a>` : row.page_url} <span title="stored as text, never fetched">(text only)</span></p>` : ''}++<ol class="conversation">${messages.map((m) => html`<li class="msg ${m.role}">++<p class="msg-meta small muted"><b>${whoLabel(m)}</b> · ${time(m.created_at)}</p>++${textBlock(m.body)}</li>`)}</ol>++${staffViewer(req) ? html`<form class="card state-form" method="post" action="/tickets/${row.id}/state">++<fieldset><legend>State</legend>++${tickets.STATES.map((st) => html`<label class="radio"><input type="radio" name="state" value="${st}"${st === row.state ? raw(' checked') : ''}> ${STATE_TEXT[st]}</label>`)}++<button class="sc-btn" type="submit">Set the state</button></fieldset>++<p class="small muted">Changing the state does not send a message. Reply below if the person needs to hear something.</p>++</form>` : ''}++<form class="card reply-form" method="post" action="/tickets/${row.id}">++<label for="message">${staffViewer(req) ? 'Reply as OpenVibe support' : 'Reply'}</label>++<textarea id="message" name="message" rows="6" maxlength="${tickets.LIMITS.body}" required placeholder="${staffViewer(req) ? 'Answer the question, or ask for what you still need.' : 'Anything else we should know?'}"></textarea>++<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters. Everything here stays in this one ticket.</p>++<p><button class="sc-btn sc-primary" type="submit">Send the reply</button></p>++</form>`,++ });++ });++++ r.post('/tickets/:id', mustSignIn('/tickets'), budgeted('help.ticket.message'), async (req, res) => {++ if (!sameSite(req, res, '/tickets')) return;++ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;++ if (!canSee(req, row)) return page(req, res, { title: 'No such ticket', body: html`<h1>No such ticket</h1><p class="muted"><a href="/tickets">Your tickets</a>.</p>` }, 404);++ const check = tickets.validateMessage(req.body || {});++ if (!check.ok) return res.redirect(303, `/tickets/${row.id}`);++ await store.addMessage(s, row.id, { author: requesterOf(req), role: staffViewer(req) ? 'staff' : 'person', body: check.fields.body });++ return res.redirect(303, `/tickets/${row.id}`);++ });++++ r.post('/tickets/:id/state', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return page(req, res, { title: 'Staff only', body: html`<h1>Staff only</h1><p class="muted">Setting a ticket's state is for OpenVibe staff.</p>` }, 403);++ if (!sameSite(req, res, '/staff')) return;++ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;++ if (!row) return page(req, res, { title: 'No such ticket', body: html`<h1>No such ticket</h1><p class="muted"><a href="/staff">The support queue</a>.</p>` }, 404);++ const state = (req.body || {}).state;++ if (tickets.isState(state)) await store.setState(s, row.id, state);++ return res.redirect(303, `/tickets/${row.id}`);++ });++++ // ── The support queue (staff) ────────────────────────────++ r.get('/staff', async (req, res) => {++ if (!signedIn(req)) return page(req, res, {++ title: 'Support queue', crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],++ body: html`<h1>The support queue</h1>${signInPrompt('The queue is for OpenVibe staff.', '/staff')}`,++ }, 401);++ if (!staffViewer(req)) return page(req, res, {++ title: 'Staff only', crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],++ body: html`<h1>Staff only</h1><p class="muted">The support queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/tickets">Your own tickets</a>.</p>`,++ }, 403);++ const state = tickets.isState(req.query.state) || req.query.state === 'all' ? String(req.query.state) : 'open';++ const site = catalog.byId.has(String(req.query.site)) ? String(req.query.site) : null;++ const out = await store.listForStaff(s, { site, state, limit: PAGE_LIMIT });++ const counts = await store.staffCounts(s);++ return page(req, res, {++ title: 'Support queue',++ description: 'Every OpenVibe support ticket, filtered by site and state.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],++ body: html`<h1>The support queue</h1>++<p class="sc-lede">Every ticket, newest first. ${counts.open} open, ${counts.waiting} waiting on the person, ${counts.solved} solved.</p>++<form class="card filters" method="get" action="/staff">++<div class="field"><label for="state">State</label><select id="state" name="state">++${[...tickets.STATES, 'all'].map((st) => html`<option value="${st}"${st === state ? raw(' selected') : ''}>${st === 'all' ? 'all' : STATE_TEXT[st]}</option>`)}++</select></div>++<div class="field"><label for="site">Site</label><select id="site" name="site"><option value="">any site</option>${catalog.sites.map((x) => html`<option value="${x.id}"${x.id === site ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>++<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>++</form>++${table(['Ticket', 'Who', 'Site', 'State', 'Messages', 'Updated'], out.rows.map((t) => [++ html`<a href="/tickets/${t.id}">${truncate(t.subject, 70)}</a>`,++ html`<code>${t.requester}</code>`,++ t.site ? html`<a href="/sites/${t.site}">${t.site}</a>` : '—',++ ticketBadge(t.state),++ String(t.message_count),++ time(t.updated_at),++ ]), { empty: 'No tickets with that filter.' })}++${out.next ? html`<p><a href="/staff?state=${encodeURIComponent(state)}${site ? `&site=${encodeURIComponent(site)}` : ''}&before=${out.next}">Older tickets</a></p>` : ''}`,});});@@@ -3,8 +3,11 @@/*** Who is calling /api/v1 — req.principal:*-- * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,-- * or this site's session cookie++ * { kind: 'user', requester: 'user:usr_…', project: null, role, viaSession } a person: their Network token as a++ * Bearer, or this site's session cookie.++ * `role` is the Network role claim++ * (user | streamer | global_mod | admin),++ * which is what decides staff here* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }* a Network app, agent or service token for* audience openvibe.help; each route names@@@ -20,9 +23,12 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--// The product fills this in: one entry per capability its routes name, in openvibe-contracts--// manifests/capabilities/<help>.*. requireCapability('<name>') refuses a name that is not listed here, so a--// route can never be guarded by a capability the service does not declare.++// One entry per capability the API's routes name, from openvibe-contracts manifests/capabilities/help.*.++// Empty on purpose: openvibe-contracts declares no `help.*` capability yet, and OpenVibe.Help's routes are all a++// person acting for themself (their own tickets) or staff judged by their role claim — neither needs one. The++// moment a route exists for an app (a support widget a site embeds, say), its capability is added here and in++// openvibe-contracts together. requireCapability('<name>') refuses a name that is not listed here, so a route can++// never be guarded by a capability the service does not declare.const CAPABILITIES = [];function decodePayload(token) {@@@ -31,6 +37,14 @@ function decodePayload(token) {try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }}++/** The person's Network role claim, as a plain string (an unlisted or missing one is the lowest role). */++function roleClaim(claims) {++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';++}++function usernameClaim(claims) {++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++}++function requesterOfSub(sub) {if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;return sub; // app:app_…, agent:agt_…, mod:mod_…@@@ -63,11 +77,11 @@ function createPrincipal({ config, keys }) {if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };-- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };++ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), viaSession: false } };}const viewer = req.viewer;if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {-- return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };++ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, viaSession: true } };}return { principal: { kind: 'anonymous' } };}@@@ -22,8 +22,12 @@ const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Help';const TAGLINE = 'Answers, support and a way to reach people.';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');--// The product's own navigation: one entry per public page it serves.++// The product's own navigation: one entry per public page it serves. The support queue (/staff) is not here — it++// is for staff only, and a link in everyone's navbar would be an invitation to a 403. It is linked from /tickets.const NAV = [++ { label: 'Answers', href: '/sites' },++ { label: 'Search', href: '/search' },++ { label: 'Your tickets', href: '/tickets' },{ label: 'What shipped', href: '/updates' },];nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * Search for /search and GET /api/v1/articles?q=, in two halves:++ *++ * searchArticles(catalog, q) the help centre's own articles — a small, explainable score over the question,++ * the answer and the site's own text (name, one line, keywords). No index, no++ * stemming, no ranking claim: the same query always answers the same order.++ * createNetworkSearch(...) OpenVibe.Search's public API (GET {HELP_SEARCH_URL}/api/v1/search?q=&limit=),++ * the network's own index of streams, clips, pastes, posts and wiki pages.++ *++ * Only the fixed host named by config.search.url is ever fetched, never a URL a person typed; the call carries a++ * User-Agent, a timeout, and a 5-minute in-memory cache of the answers. A failure is reported as a failure, and++ * the page shows the articles alone.++ */++const { html } = require('./render/html');++++/** Lower-case, accent-folded, punctuation-free — the text both sides of a comparison are reduced to. */++function norm(text) {++ return String(text == null ? '' : text).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');++}++++const STOP = new Set(['the', 'a', 'an', 'to', 'of', 'in', 'on', 'and', 'or', 'is', 'it', 'for', 'with', 'my', 'your', 'you', 'do', 'does', 'can', 'i', 'we', 'at', 'by', 'from', 'that', 'this', 'be', 'as', 'if']);++const isTerm = (t) => t.length >= 2 && !STOP.has(t);++const termList = (q) => [...new Set((norm(q).match(/[a-z0-9]+/g) || []).filter(isTerm))];++const phraseOf = (q) => norm(q).replace(/\s+/g, ' ').trim();++++/** The site's own words, as one haystack: what /sites shows of it. */++const siteText = (site) => [site.name, site.oneLine, site.tagline, site.keywords, site.domain].filter(Boolean).join(' ');++++/**++ * How well one article answers `q`. Phrase hits beat term hits, the question beats the answer, the answer beats++ * the site's blurb. Every rule is here, so a surprising order can be explained rather than tuned.++ */++function scoreArticle(article, q) {++ const phrase = phraseOf(q);++ const terms = termList(q);++ if (!terms.length) return 0;++ const question = norm(article.question);++ const answer = norm(article.answer);++ const site = norm(article.siteText || '');++ let score = 0;++ if (phrase.length >= 3) {++ if (question.includes(phrase)) score += 10;++ if (answer.includes(phrase)) score += 5;++ if (site.includes(phrase)) score += 3;++ }++ let inQuestion = 0;++ for (const t of terms) {++ if (question.includes(t)) { score += 3; inQuestion++; }++ if (answer.includes(t)) score += 2;++ if (site.includes(t)) score += 1;++ }++ // Every term in the question itself: this is the question they meant to ask.++ if (inQuestion === terms.length && terms.length > 1) score += 4;++ return score;++}++++/**++ * The best window of `text` around the first term, for the result list. Plain text: the page escapes it, and++ * highlights with `markTerms` (which escapes too).++ */++function snippetOf(text, terms, max = 220) {++ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ if (s.length <= max) return s;++ const hay = norm(s);++ let at = -1;++ for (const t of terms) { const i = hay.indexOf(t); if (i !== -1 && (at === -1 || i < at)) at = i; }++ if (at === -1) return `${s.slice(0, max - 1).replace(/[\s,;:.]$/, '')}…`;++ const start = Math.max(0, at - Math.floor(max / 3));++ const end = Math.min(s.length, start + max);++ return `${start ? '…' : ''}${s.slice(start, end).replace(/^[\s,;:.]|[\s,;:.]$/g, '')}${end < s.length ? '…' : ''}`;++}++++/** Rank the catalog's articles for `q` (an empty query ranks nothing). */++function searchArticles(catalog, q, { limit = 20 } = {}) {++ const terms = termList(q);++ if (!terms.length) return [];++ const out = [];++ for (const article of catalog.articles) {++ const site = catalog.byId.get(article.site);++ const withSite = site ? { ...article, siteText: siteText(site) } : article;++ const score = scoreArticle(withSite, q);++ if (score <= 0) continue;++ out.push({ article, score, snippet: snippetOf(article.answer, terms) });++ }++ return out++ .sort((a, b) => (b.score - a.score)++ || String(a.article.siteName).localeCompare(String(b.article.siteName))++ || String(a.article.slug).localeCompare(String(b.article.slug)))++ .slice(0, limit);++}++++/** Wrap the query's terms in <mark> — the text is matched raw and escaped piece by piece, never concatenated. */++function markTerms(text, q) {++ const terms = termList(q).sort((a, b) => b.length - a.length);++ if (!terms.length) return html`${text}`;++ const re = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');++ const parts = String(text == null ? '' : text).split(re);++ return html`${parts.map((p, i) => (i % 2 ? html`<mark>${p}</mark>` : p))}`;++}++++const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);++/** Only an absolute http(s) URL becomes a link — never javascript:, data:, or a relative guess. */++function safeHttp(url) {++ try {++ const u = new URL(String(url));++ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;++ } catch { return null; }++}++++/** One OpenVibe.Search result, as its API defines it, with anything unexpected dropped. */++function pickResult(x) {++ if (!x || typeof x !== 'object') return null;++ const title = str(x.title, 200);++ const url = safeHttp(x.canonical_url);++ if (!title || !url) return null;++ return { title, url, summary: str(x.summary, 400) || '', type: str(x.type, 40) || null, owner: str(x.owner, 60) || null };++}++++/**++ * OpenVibe.Search's public query API, cached. Returns { ok, results, cached } or { ok: false, reason } — the reason++ * is shown to the reader, so it never pretends a failure was an empty index.++ */++function createNetworkSearch({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {++ const cache = new Map();++ const url = String(config.search.url || '').replace(/\/+$/, '');++ const ua = `OpenVibeHelp/0.1 (+${config.baseUrl})`;++ const limit = config.search.limit;++++ async function fetchResults(q) {++ const target = new URL(`${url}/api/v1/search`);++ target.searchParams.set('q', q);++ target.searchParams.set('limit', String(limit));++ let res;++ try {++ res = await fetchImpl(target.toString(), {++ headers: { accept: 'application/json', 'user-agent': ua },++ signal: AbortSignal.timeout(config.search.timeoutMs),++ });++ } catch (err) {++ const reason = err && err.name === 'TimeoutError' ? 'timed out' : 'did not answer';++ throw Object.assign(new Error(`OpenVibe.Search ${reason}`), { reason });++ }++ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Search answered ${res.status}`), { reason: `answered ${res.status}` });++ const data = await res.json().catch(() => null);++ if (!data || !Array.isArray(data.results)) throw Object.assign(new Error('OpenVibe.Search sent no result list'), { reason: 'sent no result list' });++ return data.results.map(pickResult).filter(Boolean).slice(0, limit);++ }++++ /** Search the network; a hit is cached for the cache window, a failure is never cached as a hit. */++ async function search(q) {++ const query = String(q == null ? '' : q).trim().slice(0, 200);++ if (!query || !url) return { ok: false, reason: 'not configured', results: [] };++ const key = `${query.toLowerCase()}|${limit}`;++ const hit = cache.get(key);++ if (hit && now() - hit.at < config.search.cacheMs) return { ok: true, cached: true, results: hit.results };++ try {++ const results = await fetchResults(query);++ cache.set(key, { at: now(), results });++ if (cache.size > 500) cache.delete(cache.keys().next().value);++ return { ok: true, cached: false, results };++ } catch (err) {++ log.warn(`[OpenVibe.Help] search: ${err && err.reason ? err.reason : 'failed'} (OpenVibe.Search at ${url})`);++ return { ok: false, reason: (err && err.reason) || 'failed', results: [] };++ }++ }++++ return { search, url };++}++++module.exports = { searchArticles, scoreArticle, markTerms, termList, createNetworkSearch };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * Who is staff at OpenVibe.Help: the Network role claim on the person's session (server/auth/sso.js++ * viewerFromClaims puts `role` on req.viewer, and http/principal.js puts it on req.principal). The roles are++ * OpenVibe.Network's (manifests/policy/staff-roles.json): `global_mod` and `admin` may read the support queue.++ *++ * When the Network issues the staff capability claims it already defines (`staff.console.access` covers the staff++ * console for both roles), this is where `contracts.staff.can(claims, …)` replaces the role comparison — one place++ * decides staff, so the pages and the API cannot drift apart.++ */++const STAFF_ROLES = ['admin', 'global_mod'];++++const roleOf = (who) => (typeof who === 'string' ? who : (who && typeof who.role === 'string' ? who.role : null));++++/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */++function isStaff(who) {++ if (who && typeof who === 'object' && who.kind && who.kind !== 'user') return false;++ return STAFF_ROLES.includes(roleOf(who));++}++++module.exports = { isStaff, STAFF_ROLES };nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++++/**++ * Tickets and their conversation in OpenVibe.Help's own database (migrations/0002_tickets.sql). Every function++ * takes the store (server/db.js createStore) first, so the clock and the id generator are the store's.++ *++ * A ticket is one conversation: it is created with its first message, and every later message is a reply.++ * `updated_at` moves on a reply, so a list can be read as "what is waiting on us".++ */++++const { DEFAULT_STATE } = require('./tickets');++++const LIMIT_MAX = 100;++const clampLimit = (n, def = 20) => Math.min(LIMIT_MAX, Math.max(1, Number.parseInt(n, 10) || def));++++const COLS = 'id, requester, site, subject, state, page_url, created_at, updated_at';++++/** Create a ticket and its first message in one transaction: a ticket is never empty. */++async function create(s, { requester, site, subject, pageUrl, body, role = 'person' }) {++ return s.tx(async () => {++ const at = s.iso();++ const id = s.newId('tkt');++ await s.db.query(++ `INSERT INTO help_tickets (id, requester, site, subject, state, page_url, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,++ [id, requester, site || '', subject, DEFAULT_STATE, pageUrl || null, at]);++ await s.db.query(++ 'INSERT INTO help_messages (id, ticket_id, author, role, body, created_at) VALUES ($1, $2, $3, $4, $5, $6)',++ [s.newId('msg'), id, requester, role, body, at]);++ return get(s, id);++ });++}++++const get = (s, id) => s.db.maybe(`SELECT ${COLS} FROM help_tickets WHERE id = $1`, [String(id)]);++++/** One page of one person's tickets, newest first. */++async function listForRequester(s, requester, { limit = 20, before = null } = {}) {++ const n = clampLimit(limit);++ const rows = before++ ? await s.db.many(`SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count FROM help_tickets WHERE requester = $1 AND id < $2 ORDER BY id DESC LIMIT $3`, [requester, before, n + 1])++ : await s.db.many(`SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count FROM help_tickets WHERE requester = $1 ORDER BY id DESC LIMIT $2`, [requester, n + 1]);++ const page = rows.slice(0, n);++ return { rows: page, next: rows.length > n ? page[page.length - 1].id : null };++}++++/** Staff: every ticket, optionally one site and/or one state, newest first (state 'all' means no filter). */++async function listForStaff(s, { site = null, state = null, limit = 20, before = null } = {}) {++ const n = clampLimit(limit);++ const where = [];++ const args = [];++ if (site) { args.push(site); where.push(`site = $${args.length}`); }++ if (state && state !== 'all') { args.push(state); where.push(`state = $${args.length}`); }++ if (before) { args.push(before); where.push(`id < $${args.length}`); }++ const rows = await s.db.many(++ `SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count++ FROM help_tickets ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT $${args.length + 1}`,++ [...args, n + 1]);++ const page = rows.slice(0, n);++ return { rows: page, next: rows.length > n ? page[page.length - 1].id : null };++}++++/** What staff are looking at: how many tickets are open, waiting or solved per state (and in total). */++async function staffCounts(s) {++ const rows = await s.db.many('SELECT state, count(*) AS n FROM help_tickets GROUP BY state');++ const out = { open: 0, waiting: 0, solved: 0, all: 0 };++ for (const r of rows) { out[r.state] = Number(r.n); out.all += Number(r.n); }++ return out;++}++++const messages = (s, ticketId) => s.db.many('SELECT id, author, role, body, created_at FROM help_messages WHERE ticket_id = $1 ORDER BY id', [String(ticketId)]);++++/** Append a reply and move updated_at. `role` is person or staff, decided by the caller's session. */++async function addMessage(s, ticketId, { author, role, body }) {++ return s.tx(async () => {++ const at = s.iso();++ await s.db.query(++ 'INSERT INTO help_messages (id, ticket_id, author, role, body, created_at) VALUES ($1, $2, $3, $4, $5, $6)',++ [s.newId('msg'), ticketId, author, role, body, at]);++ await s.db.query('UPDATE help_tickets SET updated_at = $2 WHERE id = $1', [ticketId, at]);++ return get(s, ticketId);++ });++}++++/** Staff set the state (open | waiting | solved). Returns the ticket, or null when it is gone. */++async function setState(s, ticketId, state) {++ const at = s.iso();++ await s.db.query('UPDATE help_tickets SET state = $2, updated_at = $3 WHERE id = $1', [ticketId, state, at]);++ return get(s, ticketId);++}++++module.exports = { create, get, listForRequester, listForStaff, staffCounts, messages, addMessage, setState };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++++/**++ * What a ticket is, on both sides of the wire — the rules shared by the pages (http/pages.js) and the API++ * (http/api.js), so a form and a POST cannot disagree:++ *++ * id tkt_<ULID> (msg_<ULID> for a message)++ * state open | waiting | solved++ * role person | staff — which side of the conversation wrote a message++ * body plain text, at most 10 000 characters, shown escaped with its line breaks++ *++ * A page URL a person pastes is kept as text only: this service never fetches it (a caller's URL must never++ * decide what this service requests). It is shown as a link only when it parses as http(s).++ */++++const STATES = ['open', 'waiting', 'solved'];++const DEFAULT_STATE = 'open';++++const LIMITS = { subject: 160, body: 10000, pageUrl: 500, site: 120 };++++const TICKET_ID = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;++++const isTicketId = (id) => TICKET_ID.test(String(id || ''));++++/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */++function cleanText(value, max) {++ const s = String(value == null ? '' : value)++ .replace(/\r\n?/g, '\n')++ // eslint-disable-next-line no-control-regex++ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')++ .trim();++ return max != null ? s.slice(0, max) : s;++}++++/**++ * Check a new ticket. Answers { ok, fields } or { ok: false, code, detail } — one shape for the form (which++ * re-renders with the message) and the API (which sends it as problem+json).++ */++function validateNew({ site, subject, message, pageUrl }, { sites = null } = {}) {++ const fields = {++ site: cleanText(site, LIMITS.site),++ subject: cleanText(subject, LIMITS.subject + 1),++ message: cleanText(message, LIMITS.body + 1),++ pageUrl: cleanText(pageUrl, LIMITS.pageUrl + 1),++ };++ if (fields.subject.length > LIMITS.subject) return { ok: false, code: 'ticket.invalid', detail: `subject: at most ${LIMITS.subject} characters` };++ if (!fields.subject) return { ok: false, code: 'ticket.invalid', detail: 'subject: what is this about? (1 to 160 characters)' };++ if (fields.message.length > LIMITS.body) return { ok: false, code: 'ticket.invalid', detail: `message: at most ${LIMITS.body} characters` };++ if (!fields.message) return { ok: false, code: 'ticket.invalid', detail: 'message: tell us what happened (1 to 10000 characters)' };++ if (fields.pageUrl.length > LIMITS.pageUrl) return { ok: false, code: 'ticket.invalid', detail: `page_url: at most ${LIMITS.pageUrl} characters` };++ if (sites && fields.site && !sites.has(fields.site)) return { ok: false, code: 'ticket.invalid', detail: 'site: pick one of the OpenVibe sites, or leave it empty' };++ fields.pageUrl = fields.pageUrl || null;++ return { ok: true, fields };++}++++/** Check a reply. `role` is decided by the server (staff or person), never by the caller. */++function validateMessage({ message }) {++ const body = cleanText(message, LIMITS.body + 1);++ if (body.length > LIMITS.body) return { ok: false, code: 'ticket.invalid', detail: `message: at most ${LIMITS.body} characters` };++ if (!body) return { ok: false, code: 'ticket.invalid', detail: 'message: write something first (1 to 10000 characters)' };++ return { ok: true, fields: { body } };++}++++const isState = (s) => STATES.includes(String(s || ''));++++/** This service's own view of the page URL: a link only when it is an absolute http(s) URL (never javascript:). */++function pageLink(pageUrl) {++ try {++ const u = new URL(String(pageUrl));++ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;++ } catch { return null; }++}++++const subjectRef = (requester) => {++ const i = String(requester).indexOf(':');++ return { type: requester.slice(0, i), id: requester.slice(i + 1) };++};++++/** A ticket row → the JSON the API answers with. Messages are only included when they were asked for. */++function toWire(row, { messages = null } = {}) {++ if (!row) return null;++ const ticket = {++ id: row.id,++ requester: subjectRef(row.requester),++ site: row.site || null,++ subject: row.subject,++ state: row.state,++ page_url: row.page_url || null,++ created_at: row.created_at,++ updated_at: row.updated_at,++ };++ if (row.message_count != null) ticket.message_count = Number(row.message_count);++ if (messages) ticket.messages = messages;++ return ticket;++}++++const toWireMessage = (row) => ({++ id: row.id,++ author: subjectRef(row.author),++ role: row.role,++ body: row.body,++ created_at: row.created_at,++});++++module.exports = { STATES, DEFAULT_STATE, LIMITS, isTicketId, cleanText, validateNew, validateMessage, isState, pageLink, toWire, toWireMessage };@@@ -20,8 +20,11 @@ const READ = 'help.api.read';const rosa = t.network.addUser('rosa');const sam = t.network.addUser('sam');-- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {-- assert.deepStrictEqual(BUDGETS, {});++ await check('the product\'s own budgets are pinned: opening a ticket and replying', () => {++ assert.deepStrictEqual(BUDGETS, {++ 'help.ticket.create': { minute: 6, hour: 60 },++ 'help.ticket.message': { minute: 30, hour: 300 },++ });});await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++/**++ * The help centre is generated, not written: /sites, /sites/:id and /a/:site/:slug are built at boot from++ * OpenVibe.Contracts, and this test checks them against the manifests themselves — every catalog domain has a++ * page, every product or service site that carries an `faq` gets one page per question, the text is the manifest's++ * own, the state is the manifest's own exposure.state, and nothing read from a manifest or a URL is rendered raw.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { esc } = require('../server/render/html');++const { boot, check, done } = require('./helpers/boot');++++const siteDomain = contracts.products.siteDomain;++const PRODUCTS = contracts.products.manifests;++const SERVICE_SITES = contracts.services.manifests.filter((m) => m.site);++const WITH_FAQ = [++ ...PRODUCTS.filter((p) => Array.isArray(p.faq) && p.faq.length).map((p) => ({ domain: p.domain, faq: p.faq, name: p.name })),++ ...SERVICE_SITES.filter((m) => Array.isArray(m.site.faq) && m.site.faq.length).map((m) => ({ domain: siteDomain(m), faq: m.site.faq, name: m.site.name })),++];++++(async () => {++ const t = await boot();++ const catalog = t.ctx.catalog;++++ await check('the catalog is exactly the manifests: every product domain and every service site, once each', () => {++ const expected = new Set([...PRODUCTS.map((p) => p.domain), ...SERVICE_SITES.map(siteDomain)]);++ const got = catalog.sites.map((s) => s.id);++ assert.deepStrictEqual([...new Set(got)].length, got.length, 'a domain appears twice in the catalog');++ assert.deepStrictEqual([...got].sort(), [...expected].sort(), 'the catalog and the manifests disagree');++ assert.ok(catalog.sites.length > 30, `only ${catalog.sites.length} sites`);++ assert.ok(catalog.sites.some((s) => s.id === 'openvibe.help'), 'the help centre itself is a site');++ });++++ await check('a live site is one a service manifest exposes live; everything else is coming', () => {++ const live = new Set();++ for (const m of contracts.services.manifests) {++ if (!m.exposure || m.exposure.state !== 'live') continue;++ for (const d of m.domains || []) live.add(d);++ if (m.site && m.site.tld) live.add(siteDomain(m));++ }++ for (const s of catalog.sites) assert.strictEqual(s.state, live.has(s.id) ? 'live' : 'coming', `${s.id}: ${s.state}`);++ assert.ok(catalog.sites.some((s) => s.state === 'live') && catalog.sites.some((s) => s.state === 'coming'));++ // Ordered live first, so a reader meets the working sites first.++ const first = catalog.sites.findIndex((s) => s.state === 'coming');++ if (first !== -1) assert.ok(catalog.sites.slice(first).every((s) => s.state === 'coming'), 'live and coming sites are interleaved');++ });++++ await check('every site has a page at /sites/<domain>, in the manifest\'s own words', async () => {++ for (const site of catalog.sites) {++ const r = await t.get(`/sites/${site.id}`);++ assert.strictEqual(r.status, 200, `${site.id} → ${r.status}`);++ assert.ok(r.text.includes(site.name), `${site.id}: the name is not on the page`);++ if (site.oneLine) assert.ok(r.text.includes(esc(site.oneLine.slice(0, 40))), `${site.id}: the one line is not on the page`);++ assert.ok(r.text.includes('OpenVibe.Contracts'), `${site.id}: no provenance`);++ }++ });++++ await check('every question in every faq is a page of its own, with the question as the SEO title and the answer verbatim', async () => {++ assert.ok(WITH_FAQ.length >= 3, `only ${WITH_FAQ.length} sites answer questions`);++ let articles = 0;++ for (const { domain, faq } of WITH_FAQ) {++ const site = catalog.byId.get(domain);++ assert.ok(site, `${domain} is not in the catalog`);++ assert.strictEqual(site.faq.length, faq.length, `${domain}: the catalog lost a question`);++ for (const entry of faq) {++ const article = (catalog.bySite.get(domain) || []).find((a) => a.question === entry.question);++ assert.ok(article, `${domain}: no article for "${entry.question}"`);++ const r = await t.get(article.path);++ assert.strictEqual(r.status, 200, `${article.path} → ${r.status}`);++ // The page's own heading is the question, verbatim; the <title> is the question too — openvibe-shared/clip++ // shortens it to 60 characters and only appends the site name when both fit.++ assert.ok(r.text.includes(`<h1>${esc(entry.question)}</h1>`), `${article.path}: the heading is not the question`);++ const title = (r.text.match(/<title>([^<]*)<\/title>/) || [])[1] || '';++ assert.ok(title.startsWith(esc(entry.question.slice(0, 40))), `${article.path}: the title is not the question (${title})`);++ if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);++ assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);++ assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.ok(ld.some((n) => n['@type'] === 'Article' && n.headline && n.headline.startsWith(entry.question.slice(0, 40))), `${article.path}: no Article JSON-LD`);++ articles++;++ }++ const page = await t.get(`/sites/${domain}`);++ for (const a of catalog.bySite.get(domain)) {++ assert.ok(page.text.includes(`id="${a.slug}"`), `${domain}: ${a.slug} has no anchor on the site page`);++ }++ }++ assert.ok(articles >= 20, `only ${articles} question pages`);++ });++++ await check('a service manifest\'s own site faq is used, not only a product manifest\'s', () => {++ const openre = catalog.byId.get('openre.stream');++ assert.ok(openre && openre.faq.length === 5, `openre.stream has ${openre ? openre.faq.length : 'no'} questions`);++ assert.ok((catalog.bySite.get('openre.stream') || []).length === 5, 'no article pages for openre.stream');++ });++++ await check('the link back to the site and to its /updates is on the site page', async () => {++ const r = await t.get('/sites/openvibe.actor');++ assert.ok(r.text.includes('href="https://openvibe.actor"'), 'no link to the site itself');++ assert.ok(r.text.includes('href="https://openvibe.actor/updates"'), 'no link to its /updates');++ });++++ await check('a site the catalog does not know is a 404 page, and the URL it was asked for is escaped', async () => {++ const r = await t.get('/sites/nope');++ assert.strictEqual(r.status, 404);++ const bad = await t.get(`/sites/${encodeURIComponent('"<script>alert(1)</script>')}`);++ assert.strictEqual(bad.status, 404);++ assert.ok(!bad.text.includes('<script>alert(1)</script>'), 'the path was rendered raw');++ assert.ok(bad.text.includes('<script>'), 'the path is not shown escaped');++ });++++ await check('/api/v1/sites lists the catalog, and one site answers with its pillars, questions and source', async () => {++ const all = (await t.get('/api/v1/sites')).json();++ assert.strictEqual(all.count, catalog.sites.length);++ assert.strictEqual(all.sites.length, catalog.sites.length);++ assert.ok(all.source.name.startsWith('OpenVibe.Contracts'), all.source.name);++ const one = (await t.get('/api/v1/sites/openvibe.actor')).json();++ assert.strictEqual(one.id, 'openvibe.actor');++ assert.strictEqual(one.state, 'live');++ assert.strictEqual(one.faq.length, 8);++ assert.ok(one.pillars.length === 4);++ assert.ok(one.url === 'https://openvibe.actor');++ assert.ok(Array.isArray(one.source.manifests) && one.source.manifests.length === 2, 'both manifests are named');++ assert.strictEqual((await t.get('/api/v1/sites/nope')).status, 404);++ assert.strictEqual((await t.get('/api/v1/sites/nope')).json().code, 'help.site.not_found');++ });++++ await check('/api/v1/articles?q= ranks the articles and names where each came from', async () => {++ const all = (await t.get('/api/v1/articles')).json();++ assert.strictEqual(all.count, catalog.articles.length);++ const hits = (await t.get('/api/v1/articles?q=agent')).json();++ assert.ok(hits.count > 0, 'no hits for "agent"');++ assert.ok(hits.articles.every((a) => a.url.startsWith('/a/')), 'an article without a URL');++ assert.ok(hits.articles[0].score >= hits.articles[hits.articles.length - 1].score, 'not ranked');++ assert.ok(hits.articles[0].question.toLowerCase().includes('agent') || hits.articles[0].site_name, 'the top hit is unrelated');++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * /search and GET /api/v1/articles?q=: the help centre's own articles ranked by a fixed, explainable score, and++ * OpenVibe.Search's public API for the "From across OpenVibe" half. The network half is a local stand-in++ * (test/helpers/search-provider.js) — the real search.openvibe.network is never called from a test — and its++ * failure is shown as a failure, with the articles still there.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { startSearch } = require('./helpers/search-provider');++const { searchArticles, scoreArticle, termList } = require('../server/search');++const { createCatalog } = require('../server/catalog');++++/** A tiny catalog with known overlaps, for the scoring rules themselves. */++const FIXTURE = (() => {++ const mk = (site, siteName, slug, question, answer) => ({ site, siteName, slug, question, answer, path: `/a/${site}/${slug}` });++ const articles = [++ mk('s', 'Stream', 'go-live', 'Can I go live from a browser?', 'Yes. Press Go Live and allow your camera and microphone.'),++ mk('s', 'Stream', 'restream', 'Can I restream to Twitch and YouTube?', 'Yes, ingest once and send the stream to every platform at once.'),++ mk('o', 'Other', 'price', 'What does it cost?', 'It is free within the published limits.'),++ ];++ return {++ articles,++ byId: new Map([['s', { id: 's', name: 'Stream', oneLine: 'Streams, clips and chat', tagline: 'Streams', keywords: 'live stream', domain: 's.example' }], ['o', { id: 'o', name: 'Other', oneLine: 'Something else', tagline: '', keywords: '', domain: 'o.example' }]]),++ };++})();++++(async () => {++ const search = await startSearch();++ let clock = Date.UTC(2026, 9, 8, 12, 0, 0);++ const t = await boot({ env: search.env, now: () => clock });++++ await check('the score: the question beats the answer, the answer beats the site blurb, and nothing unrelated', () => {++ assert.deepStrictEqual(termList('a b of the restream'), ['restream'], 'one-letter words and stop words are not terms');++ assert.deepStrictEqual(termList('Restream restream RESTREAM'), ['restream'], 'the same word counts once');++ const onQuestion = scoreArticle({ question: 'Can I restream?', answer: '', siteText: '' }, 'restream');++ const onAnswer = scoreArticle({ question: 'Can I do it?', answer: 'You can restream.', siteText: '' }, 'restream');++ const onSite = scoreArticle({ question: 'What is this?', answer: 'It is a tool.', siteText: 'OpenVibe.Restream' }, 'restream');++ assert.ok(onQuestion > onAnswer && onAnswer > onSite, `${onQuestion} ${onAnswer} ${onSite}`);++ assert.strictEqual(scoreArticle({ question: 'What is this?', answer: 'It is a tool.', siteText: '' }, 'banana'), 0);++ assert.strictEqual(scoreArticle({ question: 'What is this?', answer: '', siteText: '' }, 'a'), 0, 'a one-letter query ranks nothing');++ const phrase = scoreArticle({ question: 'Can I go live from a browser?', answer: '', siteText: '' }, 'go live from a browser');++ const terms = scoreArticle({ question: 'Can I go live from a browser?', answer: '', siteText: '' }, 'go browser live');++ assert.ok(phrase > terms, 'a phrase hit is worth more than the same words scattered');++ });++++ await check('the ranking over the fixture: the best question first, and only matches', () => {++ const hits = searchArticles(FIXTURE, 'restream twitch', { limit: 10 });++ assert.deepStrictEqual(hits.map((h) => h.article.slug), ['restream'], 'the wrong article matched');++ // "live" is in the Stream site's own keywords, so both of its questions match and the unrelated site does not.++ const live = searchArticles(FIXTURE, 'live', { limit: 10 });++ assert.deepStrictEqual(live.map((h) => h.article.site), ['s', 's']);++ assert.deepStrictEqual(searchArticles(FIXTURE, 'zzz', { limit: 10 }), []);++ assert.deepStrictEqual(searchArticles(FIXTURE, '', { limit: 10 }), [], 'an empty query ranks nothing');++ });++++ await check('the ranking over the real catalog: a real question comes back with a snippet', () => {++ const catalog = createCatalog({ now: () => clock });++ const hits = searchArticles(catalog, 'take control of my computer', { limit: 5 });++ assert.ok(hits.length > 0, 'no hits for a real question');++ assert.match(hits[0].article.question, /control/i);++ assert.ok(hits[0].snippet.length > 0 && hits[0].snippet.length <= 240);++ });++++ await check('/search?q= shows both sections: the help articles and, through the stand-in, OpenVibe.Search', async () => {++ search.requests.length = 0;++ const r = await t.get('/search?q=agent');++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.ok(r.text.includes('Help articles'), 'no help-articles section');++ assert.ok(r.text.includes('From across OpenVibe'), 'no network section');++ assert.ok(r.text.includes('/a/openvibe.actor/'), 'no article link in the results');++ assert.ok(r.text.includes('https://openvibe.live/@openvibe/actor-explained'), 'the network result is missing');++ assert.ok(r.text.includes('OpenVibe.Actor explained'), 'the network result title is missing');++ assert.ok(r.text.includes('<mark>agent</mark>') || /<mark>Agent<\/mark>/.test(r.text), 'the query is not marked in the results');++ assert.strictEqual(search.requests.length, 1, 'the stand-in was called once');++ assert.strictEqual(search.requests[0].query, 'agent');++ assert.strictEqual(search.requests[0].limit, '8');++ assert.strictEqual(search.requests[0].userAgent, 'OpenVibeHelp/0.1 (+https://openvibe.help)');++ });++++ await check('the network answer is cached for five minutes: the same query is asked once', async () => {++ search.requests.length = 0;++ await t.get('/search?q=agent');++ assert.strictEqual(search.requests.length, 0, 'a cached query called the network again');++ clock += 4 * 60_000;++ await t.get('/search?q=agent');++ assert.strictEqual(search.requests.length, 0, 'the cache expired early');++ clock += 2 * 60_000;++ await t.get('/search?q=agent');++ assert.strictEqual(search.requests.length, 1, 'the cache did not expire after five minutes');++ });++++ await check('a query with no article match still shows the network section, and says so honestly', async () => {++ search.requests.length = 0;++ const r = await t.get('/search?q=fluxcapacitor');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('No article matches'), 'the articles section did not say it found nothing');++ assert.ok(r.text.includes('OpenVibe.Actor explained'), 'the network section is missing');++ });++++ await check('OpenVibe.Search failing degrades to the articles alone, and the page says what happened', async () => {++ search.state.fail = 'error';++ try {++ const r = await t.get('/search?q=computer');++ assert.strictEqual(r.status, 200, 'a failed network search must not fail the page');++ assert.ok(r.text.includes('Help articles'), 'the articles are gone');++ assert.ok(r.text.includes('/a/openvibe.actor/'), 'no article results');++ assert.ok(!r.text.includes('OpenVibe.Actor explained'), 'a stale network result was shown');++ assert.ok(/could not be reached|answered 500/.test(r.text), 'the page does not say the network search failed');++ } finally { search.state.fail = null; }++ const back = await t.get('/search?q=computer');++ assert.ok(back.text.includes('OpenVibe.Actor explained'), 'the network did not recover');++ });++++ await check('a timeout is a failure too, not a hang', async () => {++ search.state.fail = 'timeout';++ try {++ const r = await t.get('/search?q=browser');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Help articles'));++ assert.ok(/could not be reached/.test(r.text), 'no failure notice');++ } finally { search.state.fail = null; }++ });++++ await check('an answer without a results list is treated as a failure, not as an empty index', async () => {++ search.state.fail = 'garbage';++ try {++ const r = await t.get('/search?q=stream');++ assert.ok(/could not be reached/.test(r.text), 'a malformed answer was shown as "nothing found"');++ } finally { search.state.fail = null; }++ });++++ await check('an empty search asks nobody, and reflects the query escaped', async () => {++ search.requests.length = 0;++ const empty = await t.get('/search');++ assert.strictEqual(empty.status, 200);++ assert.strictEqual(search.requests.length, 0, 'an empty query still called the network');++ assert.ok(!empty.text.includes('From across OpenVibe'), 'sections rendered without a query');++ const nasty = await t.get(`/search?q=${encodeURIComponent('"<script>alert(1)</script>')}`);++ assert.strictEqual(nasty.status, 200);++ assert.ok(!nasty.text.includes('<script>alert(1)</script>'), 'the query was reflected raw');++ });++++ await check('GET /api/v1/articles?q= answers the same ranking as JSON', async () => {++ const r = await t.get('/api/v1/articles?q=computer');++ assert.strictEqual(r.status, 200);++ const body = r.json();++ assert.ok(body.count > 0 && body.articles.every((a) => a.snippet && a.url.startsWith('/a/')));++ assert.ok(body.articles[0].score >= (body.articles[1] ? body.articles[1].score : 0));++ });++++ await t.close();++ await search.close();++ done();++})().catch(async (err) => { console.error(err); try { await search.close(); } catch { /* gone */ } process.exit(1); });nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * A stand-in for OpenVibe.Search's public query API, in one local HTTP server:++ *++ * GET /api/v1/search?q=&limit= → { results: [{ title, summary, canonical_url, type, owner }] }++ *++ * Behaviour is set per test on `s.state`:++ * results what to answer with (default: one stream and one wiki page)++ * fail null (answer) | 'error' (HTTP 500) | 'timeout' (never answers) | 'garbage' (not JSON, no results)++ * `s.requests` lists { query, limit, userAgent } for every call, so a test can prove what was asked, how often++ * (the 5-minute cache), and with which User-Agent. Tests never call the real search.openvibe.network.++ */++const http = require('http');++++const DEFAULT_RESULTS = [++ { title: 'OpenVibe.Actor explained', summary: 'A stream on how a task is routed and checked.', canonical_url: 'https://openvibe.live/@openvibe/actor-explained', type: 'stream', owner: 'openvibe' },++ { title: 'Agent', summary: 'What an agent is on OpenVibe, and how grants bound it.', canonical_url: 'https://openvibe.wiki/agent', type: 'wiki', owner: 'openvibe' },++];++++async function startSearch({ results = DEFAULT_RESULTS, fail = null } = {}) {++ const state = { results: [...results], fail };++ const requests = [];++ const hanging = new Set();++++ const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://x');++ if (url.pathname !== '/api/v1/search') { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{}'); }++ requests.push({ query: url.searchParams.get('q'), limit: url.searchParams.get('limit'), userAgent: req.headers['user-agent'] || null });++ if (state.fail === 'error') { res.writeHead(500, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: 'index unavailable' })); }++ if (state.fail === 'garbage') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ note: 'no results key at all' })); }++ if (state.fail === 'timeout') { hanging.add(res); return undefined; }++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ query: url.searchParams.get('q'), results: state.results }));++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${server.address().port}`;++ return {++ url, state, requests,++ env: { HELP_SEARCH_URL: url, HELP_SEARCH_TIMEOUT_MS: '500' },++ close: () => new Promise((r) => { for (const res of hanging) { try { res.destroy(); } catch { /* gone */ } } server.close(r); }),++ };++}++++module.exports = { startSearch };@@@ -13,15 +13,20 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.++//++// The CSS budget moved with the product: the home page is now the help centre (a search box, the questions the++// sites answer, the site grid, the ticket form), so app.css grew by the styles those need — 6.0 KB of app.css++// against 11.6 KB of the shared showcase.css. Raised 2026-10-08 from 16.5/4.1 to 18.5/4.5 for a measured++// 17.5/4.3. The HTML budget did NOT move: those pages fit in the skeleton home page's 24.9 KB budget with room.const BUDGETS = {-- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)-- htmlBrotliKB: 6.9, // 6.2++ htmlRawKB: 27.5, // measured 25.3 (the home page: hero, search, popular questions, the site grid, contact)++ htmlBrotliKB: 6.9, // 6.0jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)-- cssRawKB: 16.5, // 15.0-- cssBrotliKB: 4.1, // 3.7++ cssRawKB: 18.5, // 17.5++ cssBrotliKB: 4.5, // 4.3externalFiles: 0, // 0};@@@ -45,7 +45,18 @@ function walk(dir, out = []) {const rel = (p, dir) => path.relative(dir, p).split(path.sep).join('/');const text = (p) => fs.readFileSync(p, 'utf8');++// This file travels into every service the generator makes — and there the placeholders it exercises are already++// substituted, so scripts/new-service.js, which copies the tree it lives in, can only produce another copy of this++// service rather than the sample. That is a skip, not a pass (openvibe-shared/test-runner: a skipped file is listed++// with ○, is not counted as passed, and does not fail the run); it still runs in the skeleton itself, where the++// placeholders exist.++const TEMPLATE = fs.readFileSync(path.join(ROOT, 'server', 'config.js'), 'utf8').includes(token('ID'));++(async () => {++ if (!TEMPLATE) {++ process.stdout.write("skeleton.test.js: skipped (this tree is already a generated service: the skeleton's placeholders are gone, so the generator cannot rebuild the sample here)\n");++ return;++ }const base = tmpDir('ov-new-service-');const dir = path.join(base, 'openvibe-sample');const printed = runGenerator(dir);nnew file mode 100644@@@ -0,0 +1,280 @@++'use strict';++/**++ * Support tickets end to end, through the API and through the pages' plain forms: a signed-in person opens one and++ * it is a single conversation (migrations/0002_tickets.sql); only they (and staff) can read it; anyone else gets++ * 404; anonymous is refused; a write made with the session cookie must come from openvibe.help itself; staff read++ * the queue and set the state; bodies are escaped and capped at 10 000 characters; opening tickets is limited per++ * caller. Tokens are never in a response, and a page URL is stored as text and never fetched.++ */++const assert = require('assert');++const { signJwt } = require('./helpers/mocks');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const TKT = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT = 'Nothing happens when I press Go Live';++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const ada = t.network.addUser('ada', { role: 'admin' });++ const gil = t.network.addUser('gil', { role: 'global_mod' });++ const sam = t.network.addUser('sam', { role: 'streamer' });++++ const create = (body, o = {}) => t.get('/api/v1/tickets', { as: o.as || kim, json: body, headers: { ...SAME, ...(o.headers || {}) } });++ /** Every write from this site: the session cookie is only accepted for a same-origin request. */++ const write = (path, body, o = {}) => t.get(path, { as: o.as || kim, json: body || {}, headers: { ...SAME, ...(o.headers || {}) } });++ const ticket = { subject: SUBJECT, message: 'I press Go Live on OpenVibe.Live and nothing happens.', site: 'openvibe.live', page_url: 'https://openvibe.live/broadcast' };++ let id = null;++++ await check('a signed-in person opens a ticket; its description is the first message of one conversation', async () => {++ const r = await create(ticket);++ assert.strictEqual(r.status, 201, r.text);++ const made = r.json();++ assert.match(made.id, TKT);++ id = made.id;++ assert.strictEqual(r.headers.get('location'), `/api/v1/tickets/${id}`);++ assert.strictEqual(made.state, 'open');++ assert.strictEqual(made.site, 'openvibe.live');++ assert.strictEqual(made.page_url, 'https://openvibe.live/broadcast');++ assert.strictEqual(made.requester.type, 'user');++ assert.ok(!('messages' in made), 'the create answer stays small');++ const got = (await t.get(`/api/v1/tickets/${id}`, { as: kim })).json();++ assert.strictEqual(got.subject, SUBJECT);++ assert.strictEqual(got.messages.length, 1, 'a ticket is never empty');++ assert.strictEqual(got.messages[0].role, 'person');++ assert.strictEqual(got.messages[0].body, ticket.message);++ assert.strictEqual(got.messages[0].author.type, 'user');++ });++++ await check('a ticket is private: another person gets 404, staff may read it', async () => {++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: lee })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: lee })).json().code, 'ticket.not_found');++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: ada })).status, 200, 'an admin reads it');++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: gil })).status, 200, 'a global mod reads it');++ const deep = (await t.get('/api/v1/tickets?limit=100', { as: lee })).json();++ assert.ok(!deep.tickets.some((x) => x.id === id), "lee's list carries kim's ticket");++ const mine = (await t.get('/api/v1/tickets', { as: kim })).json();++ assert.ok(mine.tickets.some((x) => x.id === id), 'kim cannot see her own ticket');++ });++++ await check('anonymous is refused on every ticket route; a non-staff role is not staff', async () => {++ assert.strictEqual((await t.get('/api/v1/tickets')).status, 401);++ assert.strictEqual((await t.get('/api/v1/tickets')).json().code, 'token.required');++ assert.strictEqual((await t.get('/api/v1/tickets', { json: ticket })).status, 401);++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`)).status, 401);++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}/messages`, { json: { message: 'hello' } })).status, 401);++ assert.strictEqual((await t.get('/api/v1/staff/tickets')).status, 401);++ assert.strictEqual((await t.get('/api/v1/staff/tickets', { as: sam })).status, 403, 'a streamer is not staff');++ assert.strictEqual((await t.get('/api/v1/staff/tickets', { as: sam })).json().code, 'staff.forbidden');++ assert.strictEqual((await write(`/api/v1/tickets/${id}/state`, { state: 'solved' }, { as: sam })).status, 403);++ });++++ await check('an app token cannot act as a person', async () => {++ const now = Math.floor(Date.now() / 1000);++ const app = signJwt({++ iss: t.network.url, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.help'],++ cap: ['help.ticket.create'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production', jti: 'tok_test_app',++ iat: now, exp: now + 300,++ }, t.network.privatePem);++ const r = await t.get('/api/v1/tickets', { bearer: app, json: ticket });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'help.person_required');++ assert.strictEqual((await t.get('/api/v1/tickets', { bearer: app })).status, 403);++ });++++ await check('a session write must come from this site; a Bearer write need not', async () => {++ const cross = await t.get('/api/v1/tickets', { as: kim, json: ticket, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'request.cross_site');++ const bare = await t.get('/api/v1/tickets', { as: kim, json: ticket });++ assert.strictEqual(bare.status, 403, 'no fetch metadata and no Origin: refused, as for every other write here');++ const before = (await t.get('/api/v1/tickets?limit=100', { as: kim })).json().tickets.length;++ const bearer = await t.get('/api/v1/tickets', { bearer: t.network.userToken(kim), json: { ...ticket, subject: 'Through a Bearer token' } });++ assert.strictEqual(bearer.status, 201, bearer.text);++ assert.strictEqual((await t.get('/api/v1/tickets?limit=100', { as: kim })).json().tickets.length, before + 1);++ // A GET never needs the same-origin header: nothing changes.++ assert.strictEqual((await t.get('/api/v1/tickets', { as: kim })).status, 200);++ });++++ await check('replying: the person, staff, and nobody else', async () => {++ const mine = await write(`/api/v1/tickets/${id}/messages`, { message: 'It happens in Chrome and Firefox both.' });++ assert.strictEqual(mine.status, 201, mine.text);++ assert.strictEqual(mine.json().messages.length, 2);++ assert.strictEqual(mine.json().messages[1].role, 'person');++ const theirs = await write(`/api/v1/tickets/${id}/messages`, { message: 'me too' }, { as: lee });++ assert.strictEqual(theirs.status, 404, 'a stranger cannot reply');++ const staff = await write(`/api/v1/tickets/${id}/messages`, { message: 'Which browser version?' }, { as: ada });++ assert.strictEqual(staff.status, 201, staff.text);++ assert.strictEqual(staff.json().messages[2].role, 'staff', 'a staff reply is marked as staff');++ const after = (await t.get(`/api/v1/tickets/${id}`, { as: kim })).json();++ assert.strictEqual(after.messages.length, 3);++ assert.ok(after.updated_at >= after.created_at);++ });++++ await check('a body is plain text: capped at 10 000 characters, and empty is refused', async () => {++ const long = await create({ ...ticket, subject: 'x'.repeat(161) });++ assert.strictEqual(long.status, 422);++ assert.match(long.json().detail, /subject/);++ const huge = await create({ subject: 'Big', message: 'x'.repeat(10_001) });++ assert.strictEqual(huge.status, 422);++ assert.match(huge.json().detail, /message/);++ const empty = await create({ subject: 'Empty', message: ' ' });++ assert.strictEqual(empty.status, 422);++ const tooLongReply = await write(`/api/v1/tickets/${id}/messages`, { message: 'y'.repeat(10_001) });++ assert.strictEqual(tooLongReply.status, 422);++ const exactly = await create({ subject: 'Exactly ten thousand', message: 'z'.repeat(10_000) });++ assert.strictEqual(exactly.status, 201, 'exactly the limit is allowed');++ assert.strictEqual((await t.get('/api/v1/sites/not-a-site', { as: kim })).status, 404);++ const unknownSite = await create({ ...ticket, site: 'evil.example' });++ assert.strictEqual(unknownSite.status, 422, 'a site must come from the catalog');++ assert.match(unknownSite.json().detail, /site/);++ });++++ await check('staff set the state and read the queue, filtered', async () => {++ const bad = await write(`/api/v1/tickets/${id}/state`, { state: 'closed' }, { as: ada });++ assert.strictEqual(bad.status, 422);++ const solved = await write(`/api/v1/tickets/${id}/state`, { state: 'solved' }, { as: ada });++ assert.strictEqual(solved.status, 200);++ assert.strictEqual(solved.json().state, 'solved');++ const open = (await t.get('/api/v1/staff/tickets?state=open', { as: ada })).json();++ assert.ok(!open.tickets.some((x) => x.id === id), 'a solved ticket is not in the open queue');++ assert.deepStrictEqual(open.filter, { site: null, state: 'open' });++ assert.ok(open.counts.all >= open.counts.open + open.counts.solved, JSON.stringify(open.counts));++ const all = (await t.get('/api/v1/staff/tickets?state=all', { as: ada })).json();++ assert.ok(all.tickets.some((x) => x.id === id && x.state === 'solved'));++ const bySite = (await t.get('/api/v1/staff/tickets?state=all&site=openvibe.live', { as: gil })).json();++ assert.ok(bySite.tickets.length > 0 && bySite.tickets.every((x) => x.site === 'openvibe.live'));++ const nobody = (await t.get('/api/v1/staff/tickets?state=all&site=openvibe.food', { as: gil })).json();++ assert.deepStrictEqual(nobody.tickets, []);++ });++++ await check('the pages: /tickets lists yours only, /staff is for staff, /tickets/:id is the conversation', async () => {++ const mine = await t.get('/tickets', { as: kim });++ assert.strictEqual(mine.status, 200);++ assert.ok(mine.text.includes(SUBJECT), 'the ticket is not in the list');++ const theirs = await t.get('/tickets', { as: lee });++ assert.ok(!theirs.text.includes(SUBJECT), "lee's page shows kim's ticket");++ assert.strictEqual((await t.get(`/tickets/${id}`)).status, 401, 'anonymous');++ assert.strictEqual((await t.get(`/tickets/${id}`, { as: lee })).status, 404);++ const conversation = await t.get(`/tickets/${id}`, { as: kim });++ assert.strictEqual(conversation.status, 200);++ assert.ok(conversation.text.includes('I press Go Live'), 'the description is not on the page');++ assert.ok(conversation.text.includes('Which browser version?'), 'the staff reply is not on the page');++ assert.strictEqual((await t.get('/staff', { as: ada })).status, 200);++ assert.strictEqual((await t.get('/staff', { as: kim })).status, 403);++ assert.strictEqual((await t.get('/staff')).status, 401);++ const queue = await t.get('/staff?state=all&site=openvibe.live', { as: gil });++ assert.ok(queue.text.includes(`/tickets/${id}`), 'the queue does not link the ticket');++ });++++ await check('the page form opens a ticket and lands on it; a cross-site form does not', async () => {++ const form = await t.get('/tickets/new', { as: kim });++ assert.strictEqual(form.status, 200);++ assert.ok(form.text.includes('<select id="site" name="site"'), 'no site picker');++ for (const site of t.ctx.catalog.sites.slice(0, 5)) assert.ok(form.text.includes(`value="${site.id}"`), `${site.id} is not in the picker`);++ const made = await t.get('/tickets', { as: kim, form: { site: 'openvibe.wiki', subject: 'A wiki question', message: 'How do I cite a source?', page_url: '' }, headers: SAME });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ assert.match(made.headers.get('location'), /^\/tickets\/tkt_/);++ const created = await t.get(made.headers.get('location'), { as: kim });++ assert.ok(created.text.includes('How do I cite a source?'));++ const cross = await t.get('/tickets', { as: kim, form: { subject: 'From elsewhere', message: 'nope' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 403);++ const bad = await t.get('/tickets', { as: kim, form: { subject: '', message: '' }, headers: SAME });++ assert.strictEqual(bad.status, 422);++ assert.ok(bad.text.includes('<select id="site" name="site"'), 'the form is not shown again');++ });++++ await check('a reply from the page goes back to the ticket, and only from this site', async () => {++ const r = await t.get(`/tickets/${id}`, { as: kim, form: { message: 'Still happening today.' }, headers: SAME });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/tickets/${id}`);++ assert.ok((await t.get(`/tickets/${id}`, { as: kim })).text.includes('Still happening today.'));++ const cross = await t.get(`/tickets/${id}`, { as: kim, form: { message: 'x' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual((await t.get(`/tickets/${id}`, { as: lee, form: { message: 'x' }, headers: SAME })).status, 404);++ const state = await t.get(`/tickets/${id}/state`, { as: ada, form: { state: 'waiting' }, headers: SAME });++ assert.strictEqual(state.status, 303);++ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: kim })).json().state, 'waiting');++ });++++ await check('a body is rendered escaped, with its line breaks kept', async () => {++ const nasty = 'First line\n\n<script>alert(1)</script> & <b>bold</b>';++ const r = await create({ subject: 'Escaping <script>', message: nasty });++ assert.strictEqual(r.status, 201, r.text);++ const page = await t.get(`/tickets/${r.json().id}`, { as: kim });++ assert.ok(!page.text.includes('<script>alert(1)</script>'), 'the body was rendered raw');++ assert.ok(page.text.includes('<script>alert(1)</script>'), 'the body is not shown escaped');++ assert.ok(page.text.includes('& <b>bold</b>'), 'ampersands and tags');++ assert.ok(!page.text.includes('<h1>Escaping <script>'), 'the subject was rendered raw');++ assert.ok(page.text.includes('<script></h1>'), 'the subject is not shown escaped');++ assert.strictEqual(r.json().subject, 'Escaping <script>', 'the stored text is what was typed');++ assert.ok((await t.get(`/api/v1/tickets/${r.json().id}`, { as: kim })).json().messages[0].body.includes('First line\n\n<script>'), 'the stored body lost its line breaks');++ });++++ await check('a page URL is stored and shown as text; only http(s) becomes a link', async () => {++ const r = await create({ subject: 'With a strange page URL', message: 'Where I was.', page_url: 'javascript:alert(1)' });++ assert.strictEqual(r.status, 201);++ const stored = (await t.get(`/api/v1/tickets/${r.json().id}`, { as: kim })).json();++ assert.strictEqual(stored.page_url, 'javascript:alert(1)', 'the text is kept as typed');++ const page = await t.get(`/tickets/${r.json().id}`, { as: kim });++ assert.ok(page.text.includes('javascript:alert(1)'), 'the text is not shown');++ assert.ok(!page.text.includes('href="javascript:'), 'a javascript: URL became a link');++ const good = await create({ subject: 'With a real page URL', message: 'Where I was.', page_url: 'https://openvibe.help/sites' });++ const goodPage = await t.get(`/tickets/${good.json().id}`, { as: kim });++ assert.ok(goodPage.text.includes('href="https://openvibe.help/sites"'), 'an https URL is not a link');++ });++++ await check('no token ever comes back on a ticket page or in the database', async () => {++ const token = t.network.userToken(kim);++ const page = await t.get(`/tickets/${id}`, { as: kim });++ assert.ok(!page.text.includes(token) && !page.text.includes('eyJ'), 'a token is on the page');++ const dump = await t.dbDump();++ assert.ok(!dump.includes(token), 'a token is in the database');++ });++++ await check('the tables are help_tickets and help_messages, with tkt_ and msg_ ids', async () => {++ const tables = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ for (const name of ['help_tickets', 'help_messages']) assert.ok(tables.includes(name), `${name} is missing (have ${tables.join(', ')})`);++ const rows = await t.ctx.s.db.many('SELECT * FROM help_tickets WHERE subject = $1', [SUBJECT]);++ assert.strictEqual(rows.length, 1);++ assert.deepStrictEqual(Object.keys(rows[0]).sort(), ['created_at', 'id', 'page_url', 'requester', 'site', 'state', 'subject', 'updated_at']);++ assert.match(rows[0].id, TKT);++ assert.match(rows[0].requester, /^user:usr_/);++ assert.ok(['open', 'waiting', 'solved'].includes(rows[0].state), `unexpected state ${rows[0].state}`);++ const msgs = await t.ctx.s.db.many('SELECT id, role FROM help_messages WHERE ticket_id = $1 ORDER BY id', [rows[0].id]);++ assert.ok(msgs.length >= 4, `only ${msgs.length} messages`);++ assert.ok(msgs.every((m) => /^msg_[0-9A-HJKMNP-TV-Z]{26}$/.test(m.id)), 'a message id is not msg_<ULID>');++ assert.deepStrictEqual([...new Set(msgs.map((m) => m.role))].sort(), ['person', 'staff']);++ });++++ await check('opening tickets is limited per caller: 6 a minute, then 429 with Retry-After', async () => {++ let clock = Date.UTC(2026, 9, 8, 9, 0, 15);++ const tl = await boot({ callerLimits: true, limitsNow: () => clock });++ const rosa = tl.network.addUser('rosa');++ const sam2 = tl.network.addUser('sam2');++ try {++ for (let i = 0; i < 6; i++) {++ const r = await tl.get('/api/v1/tickets', { as: rosa, json: { subject: `One ${i}`, message: 'body' }, headers: SAME });++ assert.strictEqual(r.status, 201, `request ${i + 1}: ${r.text.slice(0, 120)}`);++ }++ const over = await tl.get('/api/v1/tickets', { as: rosa, json: { subject: 'Seven', message: 'body' }, headers: SAME });++ assert.strictEqual(over.status, 429);++ assert.strictEqual(over.json().code, 'rate_limited');++ assert.ok(Number(over.headers.get('retry-after')) > 0, 'no Retry-After');++ assert.ok(over.json().detail.includes('help.ticket.create'), over.json().detail);++ assert.strictEqual((await tl.get('/api/v1/tickets', { as: sam2, json: { subject: 'Mine', message: 'body' }, headers: SAME })).status, 201, 'another person still opens one');++ // The page form is the same action through another door: it shares the counter and answers HTML.++ const form = await tl.get('/tickets', { as: rosa, form: { subject: 'From the form', message: 'body' }, headers: SAME });++ assert.strictEqual(form.status, 429, 'the form is a way round the limit');++ assert.match(form.headers.get('content-type'), /^text\/html/);++ assert.ok(form.text.includes('Too many requests'), 'the form got JSON instead of a page');++ clock += 60_000;++ assert.strictEqual((await tl.get('/api/v1/tickets', { as: rosa, json: { subject: 'Next minute', message: 'body' }, headers: SAME })).status, 201);++ } finally { await tl.close(); }++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -24,5 +24,13 @@ OV_OAUTH_CLIENT_SECRET=
@@ -24,5 +24,13 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback
# HELP_AUDIENCE=openvibe.help
# HELP_AUDIENCE=openvibe.help
+# OpenVibe.Search: the public query API /search asks for the "From across OpenVibe" half of a search. This is the
+# only host /search ever calls; a person's query is a parameter, never a URL. Answers are cached in this process
+# for HELP_SEARCH_CACHE_MS, and a failure degrades the page to the help articles alone.
+#HELP_SEARCH_URL=https://search.openvibe.network
+#HELP_SEARCH_LIMIT=8
+#HELP_SEARCH_TIMEOUT_MS=2500
+#HELP_SEARCH_CACHE_MS=300000
+
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
@@ -2,6 +2,44 @@
@@ -2,6 +2,44 @@
What changed in OpenVibe.Help, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Help, newest first. Each site also publishes its patch notes at /updates.
+## 0.2.0 — 2026-10-08
+
+The product: a help centre generated from OpenVibe.Contracts, search over it and over the network, and support
+tickets that stay one conversation.
+
+- **Help articles from real content, generated at boot.** `server/catalog.js` reads every product manifest and every
+ service manifest whose `site` block presents a site, and builds one page per site (`/sites`, `/sites/<domain>`) and
+ one page per FAQ entry (`/a/<domain>/<slug>`, one question per page, the question as the title). Every text is the
+ manifest's, verbatim; each page names the manifests it read and links back to them.
+- **Search** (`server/search.js`, `/search`, `GET /api/v1/articles?q=`): a fixed, explainable score over the question,
+ the answer and the site's own text, plus OpenVibe.Search's public API in a second section. The network answer is
+ cached in-process for 5 minutes; a failure is shown as a failure and the page falls back to the articles alone.
+- **Support tickets** (`migrations/0002_tickets.sql`, `server/tickets/`, `/tickets`, `/staff`): a signed-in person
+ opens one (site from the catalog, subject, message, an optional page URL stored as text and never fetched), sees
+ only their own, and replies; staff (`admin` / `global_mod` on the session's role claim) read the queue, filter it by
+ site and state, reply, and set `open | waiting | solved`. Bodies are plain text, escaped with their line breaks,
+ capped at 10 000 characters.
+- **API:** `GET /sites`, `GET /sites/:id`, `GET /articles?q=`, `POST|GET /tickets`, `GET /tickets/:id`,
+ `POST /tickets/:id/messages`, and for staff `GET /staff/tickets` and `POST /tickets/:id/state`. Writes made with the
+ session cookie must come from openvibe.help itself; another person's ticket is a 404; an app token cannot act as a
+ person. `http/principal.js` now carries the Network role claim so a route can tell staff from everyone else.
+- **Per-caller limits:** `help.ticket.create` 6 a minute / 60 an hour, `help.ticket.message` 30 / 300. The skeleton's
+ `caller-limits.test.js` pinned an empty `BUDGETS`; it now pins these.
+- **Pages:** the home page is the help centre (a search box, the questions the sites answer, the site grid, contact
+ support), plus `/sites`, `/sites/:id`, `/a/:site/:slug`, `/search`, `/tickets`, `/tickets/new`, `/tickets/:id`,
+ `/staff` and `/updates`. All server-rendered, all working without JavaScript. The navbar gained Answers, Search and
+ Your tickets.
+- **Discovery:** the sitemap and `llms-full.txt` are two renderings of one page list, so every site and every question
+ is crawlable in both; `llms.txt` lists the sites; `/tickets` and `/staff` are disallowed. The home page's JSON-LD
+ gained the sitelinks search action.
+- **CSS budget raised** (16.5 → 18.5 KB raw, 4.1 → 4.5 KB brotli) for the help centre's own styles: measured
+ 17.5/4.3 KB. The HTML budget did not move — the home page is 25.3 KB against 27.5.
+- **Tests:** `test/help-articles.test.js` (the pages against the manifests, escaping), `test/help-search.test.js` (the
+ score and the OpenVibe.Search stand-in, `test/helpers/search-provider.js`), `test/tickets.test.js` (the whole
+ lifecycle, privacy, cross-site writes, limits). `test/skeleton.test.js` now skips itself in a generated service
+ (the skeleton's placeholders are already substituted here, so the generator cannot rebuild the sample) — a skip, not
+ a pass.
+
## 0.1.0 — 2026-10-08
## 0.1.0 — 2026-10-08
- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
@@ -2,48 +2,132 @@
@@ -2,48 +2,132 @@
> Answers, support and a way to reach people.
> Answers, support and a way to reach people.
-**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.
+**Status:** the product is written and tested; it is not deployed yet (see [STATUS.json](STATUS.json)).
**Domain:** `openvibe.help` · **Port:** 5020 · **Service id:** `help` · **Env prefix:** `HELP`
**Domain:** `openvibe.help` · **Port:** 5020 · **Service id:** `help` · **Env prefix:** `HELP`
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
-## What is already here
+The help and support centre for every OpenVibe site: the answers each site gives about itself, search over those and
+over the whole network, and a support ticket that stays one conversation.
-| Piece | Where | What it does |
-|---|---|---|
-| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
-| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
-| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `help_at` / `help_rt` cookies; `/auth/me` for the shared navbar |
-| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |
-| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |
-| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |
-| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |
-| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |
-| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |
-| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
-| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
-| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |
-| Deploy | [deploy/nginx/openvibe.help.conf](deploy/nginx/openvibe.help.conf), [deploy/systemd/openvibe-help.service](deploy/systemd/openvibe-help.service) | nginx vhost and systemd unit (port 5020, `/opt/openvibe.help`, `/etc/openvibe/help.env`) |
-| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |
+## What it does
-## API
+### 1. Help articles, generated from real content — no database, no hand-written copy
-| Route | Who | |
-|---|---|---|
-| `GET /api/v1/ping` | anyone | `{ ok: true, service: "help" }` |
+`server/catalog.js` reads, at boot, from `openvibe-contracts`:
+
+- **every product manifest** (`manifests/products/<domain>.json`) — `name`, `tagline`, `description`, `pillars`, `faq`,
+ `keywords`, `launch`, `kind`;
+- **every service manifest whose `site` block presents a site** (`manifests/services/<id>.json`) — the same fields,
+ plus `exposure.state`.
+
+It builds one site per domain (a domain that is both a product and a service site is merged into one) and one article
+per FAQ entry. Every text is the manifest's, verbatim — this service quotes, it does not write. Each page shows where
+its text came from (the manifest paths, a link back, and when it was read).
+
+| Page | What it is |
+|---|---|
+| `/sites` | every OpenVibe site: its one-line what, its state (`live` / `coming`), and links |
+| `/sites/:id` | one site: its pillars as "what you can do", its FAQ as anchored articles, links to the site and to its `/updates` |
+| `/a/:site/:slug` | one question, one page (SEO title = the question), the answer verbatim, links back |
+
+`live` is the manifest's own `exposure.state === 'live'` on a service that exposes that domain; everything else is
+`coming`. Today: 45 sites, 22 live, 20 questions across the 3 sites that carry an `faq`.
+
+### 2. Search
+
+`/search?q=` shows two sections:
+
+- **Help articles** — `server/search.js` scores the generated articles over the question, the answer and the site's own
+ text (a phrase hit beats term hits; the question beats the answer; the answer beats the site's blurb). The same
+ ranking answers `GET /api/v1/articles?q=`. There is no index and no stemming: the same query always answers the same
+ order, and every rule is in one function so a surprising order can be explained instead of tuned.
+- **From across OpenVibe** — OpenVibe.Search's public API,
+ `GET {HELP_SEARCH_URL}/api/v1/search?q=&limit=8` → `results[{title, summary, canonical_url, type, owner}]`. Answers
+ are cached in this process for 5 minutes. A failure (timeout, an error, an answer without a result list) is shown as
+ a failure and the page degrades to the articles alone — never to a fake empty index.
+
+OpenVibe.Search at `search.openvibe.network` is the **only** host `/search` calls; a person's query travels as a query
+parameter, never as a URL, and a `canonical_url` becomes a link only when it parses as absolute http(s).
+
+### 3. Support tickets
+
+A signed-in person opens a ticket — the site it is about (picked from the catalog), a subject, a message, and
+optionally the page they were on. The page URL is **stored as text only and never fetched**; it is rendered as a link
+only when it parses as absolute http(s). The first message is the description, so a ticket is never empty; the ticket
+page is the conversation; a reply stays there.
+
+- **A person sees only their own tickets** — anyone else gets 404, exactly like a ticket that does not exist.
+- **Staff** — the Network role claim on the session (`admin` or `global_mod`, `server/tickets/staff.js`) — see
+ `/staff`: every ticket, filterable by site and state, and they may reply and set `open | waiting | solved`.
+- Bodies are plain text rendered escaped with line breaks kept, at most 10 000 characters.
+
+Tables (`migrations/0002_tickets.sql`): `help_tickets` (`tkt_<ULID>`, requester subject, site, subject, state,
+`page_url`, created/updated) and `help_messages` (`ticket id, author subject, role person|staff, body, created_at`).
+Neither is ever a credential, key or token.
+
+### 4. Discovery
-Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming
-each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in
-[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).
+`sitemap.xml` and `llms-full.txt` are two renderings of one page list, so every site and every question is crawlable in
+both and they cannot drift apart. `llms.txt` lists the sites with their state. `/tickets`, `/staff`, `/auth/` and
+`/api/` are disallowed; the home page's JSON-LD carries the sitelinks search action.
-**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service
-with a Network token for audience `openvibe.help` that holds the route's capability. A write made with the session cookie
-must come from `openvibe.help` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.
+## Pages
+
+| Route | What it is |
+|---|---|
+| `/` | the help centre: a search box, "Popular questions" (the questions the sites answer), "Browse by site", and "Contact support" |
+| `/sites`, `/sites/:id`, `/a/:site/:slug` | the generated help centre |
+| `/search?q=` | the help articles and the network, in two sections |
+| `/tickets/new`, `/tickets`, `/tickets/:id` | open a ticket, your tickets, one conversation (sign in required) |
+| `/staff` | the support queue (staff only) |
+| `/updates` | the network changelog for this service |
+
+All of it is server-rendered through the OpenVibe Frame and works without JavaScript: the search box is a GET, the
+forms are plain POSTs. A signed-in write must come from openvibe.help itself (as in the API).
+
+## API
+
+Everything under `/api/v1`. A person needs no capability (their own tickets, their own session); staff are judged by
+their Network role claim. Writes made with the session cookie must be same-origin. Errors are RFC 9457
+`application/problem+json` with a stable `code`.
+
+| Route | Who | What |
+|---|---|---|
+| `GET /ping` | anyone | `{ ok: true, service: "help" }` |
+| `GET /sites` | anyone | the whole catalog with each site's state, one line and source |
+| `GET /sites/:id` | anyone | one site: pillars, FAQ (with article URLs), where it lives, its source |
+| `GET /articles?q=` | anyone | the generated articles; `?q=` ranks them |
+| `POST /tickets` | a person | open a ticket (`{ site?, subject, message, page_url? }`) → 201 `tkt_<ULID>` |
+| `GET /tickets` | a person | your tickets, newest first (`?limit=&before=`) |
+| `GET /tickets/:id` | a person | one ticket and its conversation (staff may read any; anyone else 404) |
+| `POST /tickets/:id/messages` | a person | reply (`{ message }`); a staff reply is marked `staff` |
+| `GET /staff/tickets` | staff | the queue (`?site=&state=open\|waiting\|solved\|all`) with counts |
+| `POST /tickets/:id/state` | staff | `{ state: "open" \| "waiting" \| "solved" }` |
+
+Problem codes: `token.required` (401), `help.person_required` (403 — an app token cannot act as a person),
+`request.cross_site` (403), `staff.forbidden` (403), `ticket.not_found` (404), `help.site.not_found` (404),
+`ticket.invalid` (422), `rate_limited` (429).
+
+`http/principal.js`'s `CAPABILITIES` is deliberately empty: `openvibe-contracts` declares no `help.*` capability yet,
+and every route here is a person acting for themself or staff by their role claim. The day a route exists for an app
+(a support widget a site embeds, say), its capability is added here and in `openvibe-contracts` together.
## Configuration
## Configuration
See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `help` OAuth client on the
See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `help` OAuth client on the
-Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
-Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
+Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. Optional: `HELP_SEARCH_URL` (default
+`https://search.openvibe.network`), `HELP_SEARCH_LIMIT`, `HELP_SEARCH_TIMEOUT_MS`, `HELP_SEARCH_CACHE_MS`, the
+per-caller limits and Valkey. The database is the only required readiness check; the Network signing key, the OAuth
+client and Valkey are optional (the service says so, per check, on `/api/ready`).
+
+## Data sources and their terms
+
+| Source | What is taken | Terms as applied |
+|---|---|---|
+| [OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts) `manifests/products/*.json` and `manifests/services/*.json` (pinned `v0.115.0`, read at boot from `node_modules`) | site names, taglines, descriptions, pillars, FAQ, keywords, launch notes, `exposure.state` | Read-only, from the pinned release the service already depends on; quoted verbatim with the manifest path, a link back and the time it was read shown on the page. No text is invented, rewritten or translated. |
+| [OpenVibe.Search](https://search.openvibe.network) (`GET /api/v1/search`) | the network half of `/search`: `title`, `summary`, `canonical_url`, `type`, `owner` | Public API (anonymous queries need no token). Fetched only from the configured host, with a `User-Agent` and a timeout, cached 5 minutes, never on a URL a person typed. Failures are shown as failures. |
+
+Nothing else is fetched. No user-supplied URL is ever requested by this service.
## Development
## Development
@@ -56,6 +140,22 @@ fnm exec --using=22 npm run dev # http://localhost:5020
@@ -56,6 +140,22 @@ fnm exec --using=22 npm run dev # http://localhost:5020
Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
+## Tests
+
+`npm test` runs every `test/*.test.js` in its own process, on a temp PGlite database, with an in-process mock of
+OpenVibe.Network; outbound HTTP is stubbed (`test/helpers/search-provider.js` stands in for OpenVibe.Search), so
+nothing calls the real internet.
+
+| File | What it proves |
+|---|---|
+| `test/help-articles.test.js` | the catalog is exactly the manifests (once per domain), every site and every question has a page, the text and the state are the manifest's, escaping |
+| `test/help-search.test.js` | the score and its rules, the ranking over the real catalog, both sections, the 5-minute cache, and failures (error, timeout, malformed) degrading to the articles alone |
+| `test/tickets.test.js` | the whole lifecycle: create, list own only, reply, staff state change, another person 404, anonymous 401, app tokens refused, cross-site session writes refused, escaping, the 10 000-character cap, per-caller limits |
+
+`test/skeleton.test.js` skips itself in a generated service: the skeleton's `__ID__`-style placeholders are already
+substituted here, so `scripts/new-service.js` (which copies the tree it lives in) cannot rebuild the sample. A skip is
+listed with ○ and is not counted as a pass.
+
## Deploy (for the lead)
## Deploy (for the lead)
- **Deploy:** `sudo ovhost deploy help` on the host (git checkout at `/opt/openvibe.help`, unit
- **Deploy:** `sudo ovhost deploy help` on the host (git checkout at `/opt/openvibe.help`, unit
@@ -63,17 +163,23 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work
@@ -63,17 +163,23 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work
- **nginx:** [deploy/nginx/openvibe.help.conf](deploy/nginx/openvibe.help.conf), installed with `ov-vhost-install`.
- **nginx:** [deploy/nginx/openvibe.help.conf](deploy/nginx/openvibe.help.conf), installed with `ov-vhost-install`.
- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: help` in CI) and with
- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: help` in CI) and with
- **OpenVibe.Services** before the first deploy.
+ **OpenVibe.Services** before the first deploy. No new environment variable is required to run: `HELP_SEARCH_URL` has
+ a default.
## Security (threat notes)
## Security (threat notes)
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
+- A ticket belongs to the person who opened it: another person gets 404, and staff are decided by the Network role
+ claim on the session, never by anything in the request body.
+- Nothing in a request may decide a URL this service fetches. A page URL a person pastes is stored as text and never
+ opened; `/search` calls only the fixed `HELP_SEARCH_URL` host, with the query as a parameter. A `canonical_url` from
+ OpenVibe.Search becomes a link only when it parses as absolute http(s).
+- Every dynamic value in HTML goes through the `html` tagged template; bodies are escaped and shown with their line
+ breaks.
- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
-- Request bodies are never logged.
-- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard
- decides what may be fetched.
+- Request bodies are never logged (a ticket body can be personal).
---
---
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
- "stage": "skeleton",
+ "stage": "product",
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
- "plan": "OpenVibe.Help starts from the OpenVibe service skeleton; the product's own plan is written here when it is",
+ "plan": "OpenVibe.Help: a help centre generated from OpenVibe.Contracts at boot, search over it and over OpenVibe.Search, and support tickets that stay one conversation",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5020",
"port": "5020",
"domain": "openvibe.help",
"domain": "openvibe.help",
@@ -14,32 +14,42 @@
@@ -14,32 +14,42 @@
"openvibe-sdk v0.35.2",
"openvibe-sdk v0.35.2",
"openvibe-shared v2.14.0"
"openvibe-shared v2.14.0"
],
],
- "summary": "OpenVibe.Help — Answers, support and a way to reach people. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",
+ "summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",
"works": [
"works": [
+ "Help articles generated at boot from openvibe-contracts: 45 sites (22 live, 23 coming) and 20 questions across the 3 sites whose manifest carries an faq, quoted verbatim with provenance (manifest path, link back, read time)",
+ "/sites, /sites/:id (pillars as \"what you can do\", the FAQ as anchored articles, links to the site and its /updates) and /a/:site/:slug (one question per page, the question as the title)",
+ "Search: a fixed score over question/answer/site text for the help articles, plus OpenVibe.Search's public API in a second section; a 5-minute in-process cache, and a failure shown as a failure",
+ "Support tickets: create, list your own, one conversation per ticket, reply, staff queue with filters and open|waiting|solved, all in help_tickets and help_messages",
+ "Privacy: another person's ticket is a 404, anonymous is 401, an app token cannot act as a person, and a write with the session cookie must come from openvibe.help",
+ "Bodies are plain text, escaped with line breaks kept, capped at 10 000 characters; a page URL is stored as text and never fetched",
+ "Per-caller limits: help.ticket.create 6 a minute / 60 an hour, help.ticket.message 30 / 300",
+ "The API at /api/v1 with the problem+json error shape: sites, articles, tickets, staff",
+ "Crawl artifacts: a sitemap and llms-full.txt built from one page list (every site, every question), llms.txt listing the sites, JSON-LD for the home page, a site and a question",
"Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
"Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",
- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",
- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",
- "Capability verification for app, agent and service tokens (audience openvibe.help) and for a person's own token",
"Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
"Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",
- "Deploy files: deploy/nginx/openvibe.help.conf and deploy/systemd/openvibe-help.service",
- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"
+ "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits"
],
],
"notYet": [
"notYet": [
- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",
- "Its service and capability manifests in openvibe-contracts (contracts-service is help)",
- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"
+ "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host",
+ "Its own service manifest and capability manifests in openvibe-contracts (contracts-service is help): CAPABILITIES in server/http/principal.js is empty until they exist",
+ "An email side to a ticket: today one conversation on the web, not yet the same thread by email",
+ "A \"Report a problem\" button on every other OpenVibe site (the product manifest's own launch note); a ticket can name a site but no site embeds the form yet",
+ "Answers drawn from the docs, wiki, blog and service status (the product manifest's description): today the articles are the sites' own manifests, and the network half of /search is what covers the rest"
],
],
"capabilitiesRegistered": false,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
"deployed": false,
"placeholderInSites": false,
"placeholderInSites": false,
"features": [
"features": [
+ "generated help articles",
+ "search (articles + OpenVibe.Search)",
+ "support tickets",
+ "staff queue",
"Network sign-in",
"Network sign-in",
"server-rendered pages",
"server-rendered pages",
"api mount",
"api mount",
"limits and readiness",
"limits and readiness",
+ "discovery",
"deploy files"
"deploy files"
],
],
"updated": "2026-10-08"
"updated": "2026-10-08"
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+-- OpenVibe.Help: support tickets and their conversation. A ticket is one conversation from the moment it is
+-- opened: the first message is the person's description, every later one is a reply from either side. Nothing
+-- written here is ever a credential, a key or a token, and a page URL a person pastes is stored as text only —
+-- this service never fetches it.
+
+CREATE TABLE help_tickets (
+ id text COLLATE "C" PRIMARY KEY, -- tkt_<ULID>
+ requester text COLLATE "C" NOT NULL, -- user:usr_…, the person who opened it
+ site text COLLATE "C" NOT NULL, -- a catalog domain, or '' for "not about one site"
+ subject text NOT NULL,
+ state text COLLATE "C" NOT NULL, -- open | waiting | solved
+ page_url text, -- where they were, as they typed it; text only
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX help_tickets_by_requester ON help_tickets (requester, id DESC);
+CREATE INDEX help_tickets_by_state ON help_tickets (state, id DESC);
+CREATE INDEX help_tickets_by_site ON help_tickets (site, id DESC);
+
+CREATE TABLE help_messages (
+ id text COLLATE "C" PRIMARY KEY, -- msg_<ULID>
+ ticket_id text COLLATE "C" NOT NULL REFERENCES help_tickets (id) ON DELETE CASCADE,
+ author text COLLATE "C" NOT NULL, -- user:usr_… of whoever wrote it
+ role text COLLATE "C" NOT NULL, -- person | staff (which side wrote it)
+ body text NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX help_messages_by_ticket ON help_messages (ticket_id, id);
@@ -42,3 +42,33 @@ form.inline { display: inline; }
@@ -42,3 +42,33 @@ form.inline { display: inline; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
button.sc-btn.sc-primary { border: 0; }
+
+/* ── The help centre ─────────────────────────────────────── */
+/* Plain text a person typed or a manifest states: escaped, and its own line breaks kept. */
+.txt { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5rem 0; }
+.search-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
+.search-form input[type="search"] { flex: 1 1 24rem; min-width: 0; padding: 10px 12px; font: inherit; color: inherit; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; }
+.search-big input[type="search"] { padding: 14px 16px; font-size: 1.1rem; }
+.q-list, .site-list, .results, .ticket-list, .pillars, .conversation { list-style: none; padding: 0; margin: 12px 0; }
+.q-list li, .site-list li, .results li, .ticket-list li { padding: 7px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.site-list li .muted, .ticket-list li .muted { margin-left: 6px; }
+.site-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 6px 16px; list-style: none; padding: 0; margin: 12px 0; }
+.results li p { margin: 2px 0; }
+.results.net li { border-bottom-style: dotted; }
+mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px; }
+.faq-item { margin: 0 0 1.6rem; scroll-margin-top: 1rem; }
+.faq-item h3 { margin-bottom: .2rem; }
+.prov { margin: 10px 0; }
+.site-links { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
+.card { background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }
+.ticket-form .field, .filters .field { margin: 12px 0; }
+.field label, .reply-form label { display: block; font-weight: 600; margin-bottom: 4px; }
+.field input[type="text"], .field select, .field textarea, .reply-form textarea { width: 100%; padding: 9px 11px; font: inherit; color: inherit; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; resize: vertical; }
+.field select { max-width: 32rem; }
+.filters { display: flex; gap: 16px; flex-wrap: wrap; align-items: end; }
+.ticket-list li span { display: block; }
+.conversation { display: flex; flex-direction: column; gap: 10px; }
+.msg { border-left: 3px solid var(--border-light, #2c3d5c); padding: 8px 12px; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
+.msg.staff { border-left-color: var(--accent, #3b82f6); }
+.msg-meta { margin: 0 0 4px; }
+.state-form label.radio { margin-right: 16px; }
@@ -4,14 +4,16 @@
@@ -4,14 +4,16 @@
* OpenVibe.Help — Express app factory; server/index.js listens, tests build their own instance with a temp database
* OpenVibe.Help — Express app factory; server/index.js listens, tests build their own instance with a temp database
* and a mock Network.
* and a mock Network.
*
*
- * /, /updates the pages (http/pages.js)
- * /api/v1/* the API (http/api.js)
- * /auth/* Network SSO with PKCE (auth/sso.js)
- * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /, /sites, /sites/:id, /a/:site/:slug, /search the help centre (http/pages.js)
+ * /tickets, /tickets/:id, /staff, /updates support and the update log (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
*
*
- * The product fills this in: its routes in http/api.js, its pages in http/pages.js, its capabilities in
- * http/principal.js and its budgets in http/caller-limits.js. The plumbing here (helmet, SSO, legal pages,
- * static assets, the API mount, the 404 and the error handler) stays.
+ * The pages and the API are the product; the plumbing (helmet, SSO, legal pages, static assets, the API mount, the
+ * 404 and the error handler) is the skeleton's. The help centre's content is generated here, at boot, from
+ * OpenVibe.Contracts (server/catalog.js) — no database, nothing hand-written — and the catalog and the
+ * OpenVibe.Search client go into ctx for the pages, the API and the crawl artifacts to share.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -23,6 +25,8 @@ const cache = require('openvibe-shared/cache-policy');
@@ -23,6 +25,8 @@ const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
+const { createCatalog } = require('./catalog');
+const { createNetworkSearch } = require('./search');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
@@ -49,7 +53,11 @@ async function createApp(opts = {}) {
@@ -49,7 +53,11 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
- const ctx = { config, s, keys, sso, principal, log };
+ // The help centre's content is generated at boot from OpenVibe.Contracts (server/catalog.js): no database,
+ // nothing hand-written. The search cache lives in this process for its 5 minutes.
+ const catalog = createCatalog({ now: s.now });
+ const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });
+ const ctx = { config, s, keys, sso, principal, catalog, search, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+'use strict';
+
+/**
+ * The help centre's content, generated at boot — no database, no hand-written copy. Everything here is read
+ * verbatim from OpenVibe.Contracts:
+ *
+ * manifests/products/<domain>.json a product domain of the catalog: name, tagline, description, pillars,
+ * faq, keywords, launch, kind
+ * manifests/services/<id>.json a service whose `site` block presents it as a site (the same fields as a
+ * product), plus `exposure.state` (live, internal, placeholder, …)
+ *
+ * A domain can appear in both (openvibe.actor is a service site and a product manifest); the two are merged into
+ * one site, so /sites lists each OpenVibe address exactly once. Nothing is invented: a question comes from the
+ * manifest's own `faq`, a pillar from its `pillars`, and each page names its source and when it was read.
+ *
+ * createCatalog({ now }) → { generatedAt, source, sites, byId, articles, byArticle, popular }
+ *
+ * The manifest text is deliberately NOT normalised, reformatted or translated — it is quoted. `site.url` is the
+ * address the manifest itself gives (publicOrigin, else https://<domain>).
+ */
+const contracts = require('openvibe-contracts');
+
+const PKG = require('openvibe-contracts/package.json');
+
+/** Where the text came from, to show next to it (the manifest's repository, from the package itself). */
+const SOURCE = (() => {
+ const raw = typeof PKG.repository === 'string' ? PKG.repository : (PKG.repository && PKG.repository.url) || '';
+ const m = raw.match(/github[:/]+([^/\s]+\/[^/\s#]+?)(?:\.git)?$/i);
+ return { name: `OpenVibe.Contracts ${PKG.version}`, url: m ? `https://github.com/${m[1]}` : 'https://openvibe.network' };
+})();
+
+/** A URL-safe slug from arbitrary text: lowercase, runs of anything else become one hyphen, clipped. */
+function slugify(text, max = 64) {
+ return String(text == null ? '' : text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, max).replace(/-+$/, '') || 'item';
+}
+
+/** The first sentence of a description, for a one-line "what" when the manifest has no `what` of its own. */
+function firstSentence(text, max = 180) {
+ const s = String(text == null ? '' : text).trim();
+ if (!s) return '';
+ const cut = s.match(/^[\s\S]*?[.!?](?=\s|$)/);
+ const one = (cut ? cut[0] : s).trim();
+ return one.length > max ? `${one.slice(0, max - 1).replace(/[\s,;:]$/, '')}…` : one;
+}
+
+/** The host a service manifest's site is served on (products.siteDomain keeps one rule for both homes). */
+const siteDomain = contracts.products.siteDomain;
+
+/** The text pairs a site shows as "what you can do". Kept in manifest order; duplicates dropped. */
+function textPairs(list) {
+ const seen = new Set();
+ const out = [];
+ for (const p of Array.isArray(list) ? list : []) {
+ const title = String((p && p.title) || '').trim();
+ const text = String((p && p.text) || '').trim();
+ if (!title || seen.has(title)) continue;
+ seen.add(title);
+ out.push({ title, text });
+ }
+ return out;
+}
+
+/** The site's own questions, verbatim; duplicates (a product and a service site may share one) dropped. */
+function faqPairs(...lists) {
+ const seen = new Set();
+ const out = [];
+ for (const list of lists) {
+ for (const q of Array.isArray(list) ? list : []) {
+ const question = String((q && q.question) || '').trim();
+ const answer = String((q && q.answer) || '').trim();
+ if (!question || !answer || seen.has(question)) continue;
+ seen.add(question);
+ out.push({ question, answer });
+ }
+ }
+ return out;
+}
+
+/** One service manifest as a catalog entry (its `site` block, plus what exposes it). */
+function fromService(m, exposure) {
+ const s = m.site;
+ return {
+ domain: siteDomain(m),
+ name: s.name || m.name,
+ icon: s.icon || null,
+ accent: s.accent || null,
+ tagline: s.tagline || '',
+ what: s.what || '',
+ description: s.description || '',
+ pillars: textPairs(s.pillars),
+ faq: faqPairs(s.faq),
+ keywords: s.keywords || '',
+ launch: s.launch || '',
+ kind: s.kind || null,
+ legalProfile: s.legalProfile || null,
+ position: s.position == null ? null : s.position,
+ service: m.id,
+ exposure,
+ home: 'service',
+ url: m.publicOrigin || `https://${siteDomain(m)}`,
+ manifest: `manifests/services/${m.id}.json`,
+ };
+}
+
+/** One product manifest as a catalog entry. */
+function fromProduct(p) {
+ return {
+ domain: p.domain,
+ name: p.name,
+ icon: p.icon || null,
+ accent: p.accent || null,
+ tagline: p.tagline || '',
+ what: '',
+ description: p.description || '',
+ pillars: textPairs(p.pillars),
+ faq: faqPairs(p.faq),
+ keywords: p.keywords || '',
+ launch: p.launch || '',
+ kind: p.kind || null,
+ legalProfile: p.legalProfile || null,
+ position: null,
+ service: null,
+ exposure: null,
+ home: 'product',
+ url: `https://${p.domain}`,
+ manifest: `manifests/products/${p.domain}.json`,
+ };
+}
+
+/** Merge a domain's two homes: the service site keeps its exposure, the product manifest keeps its name. */
+function merge(serviceEntry, productEntry) {
+ if (!serviceEntry) return productEntry;
+ if (!productEntry) return serviceEntry;
+ return {
+ ...serviceEntry,
+ name: productEntry.name || serviceEntry.name,
+ icon: productEntry.icon || serviceEntry.icon,
+ accent: productEntry.accent || serviceEntry.accent,
+ tagline: productEntry.tagline || serviceEntry.tagline,
+ what: serviceEntry.what || productEntry.what,
+ description: productEntry.description || serviceEntry.description,
+ pillars: serviceEntry.pillars.length ? serviceEntry.pillars : productEntry.pillars,
+ faq: faqPairs(productEntry.faq, serviceEntry.faq),
+ keywords: productEntry.keywords || serviceEntry.keywords,
+ launch: productEntry.launch || serviceEntry.launch,
+ kind: productEntry.kind || serviceEntry.kind,
+ legalProfile: productEntry.legalProfile || serviceEntry.legalProfile,
+ home: 'both',
+ manifests: [productEntry.manifest, serviceEntry.manifest],
+ };
+}
+
+/**
+ * Every OpenVibe site the catalog knows, with its state, its one line and where to go — the union of the product
+ * manifests and the service manifests that present a site. Sorted live first, then by name.
+ */
+function buildSites() {
+ const services = contracts.services.manifests;
+ // A domain is live when a service manifest exposes it live, wherever it is named (its site or its domains list).
+ const live = new Set();
+ for (const m of services) {
+ if (!m.exposure || m.exposure.state !== 'live') continue;
+ for (const d of m.domains || []) live.add(d);
+ if (m.site && m.site.tld) live.add(siteDomain(m));
+ }
+
+ const byDomain = new Map();
+ for (const m of services) if (m.site) byDomain.set(siteDomain(m), fromService(m, m.exposure ? m.exposure.state : null));
+ for (const p of contracts.products.manifests) byDomain.set(p.domain, merge(byDomain.get(p.domain), fromProduct(p)));
+
+ const sites = [...byDomain.values()].map((e) => {
+ const state = live.has(e.domain) ? 'live' : 'coming';
+ const manifests = e.manifests || [e.manifest];
+ return {
+ ...e,
+ id: e.domain,
+ state,
+ live: state === 'live',
+ // A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
+ oneLine: e.what || e.tagline || firstSentence(e.description),
+ updatesUrl: `${String(e.url).replace(/\/+$/, '')}/updates`,
+ provenance: { source: SOURCE.name, url: SOURCE.url, manifests, path: `/sites/${e.domain}` },
+ };
+ });
+ return sites.sort((a, b) => (a.state === b.state ? String(a.name).localeCompare(String(b.name)) : a.live ? -1 : 1));
+}
+
+/**
+ * One article per FAQ entry: /a/<site>/<slug>, one question per page, the answer verbatim. The slug is derived
+ * from the question; within a site it is made unique, so a reworded manifest never collides with an existing URL.
+ */
+function buildArticles(sites) {
+ const articles = [];
+ const bySite = new Map();
+ for (const site of sites) {
+ const mine = [];
+ const used = new Set();
+ for (const { question, answer } of site.faq) {
+ let slug = slugify(question);
+ for (let n = 2; used.has(slug); n++) slug = `${slugify(question, 60)}-${n}`;
+ used.add(slug);
+ const article = {
+ site: site.domain, siteName: site.name, siteState: site.state, siteUrl: site.url,
+ slug, question, answer,
+ path: `/a/${site.domain}/${slug}`,
+ anchor: slug,
+ provenance: site.provenance,
+ };
+ mine.push(article);
+ articles.push(article);
+ }
+ if (mine.length) bySite.set(site.domain, mine);
+ }
+ return { articles, bySite };
+}
+
+/**
+ * The questions the home page shows: real ones, live sites first, in catalog order. `limit` is a display choice,
+ * not a ranking claim, and the page says where they come from.
+ */
+function popularQuestions(sites, articles, limit = 8) {
+ const order = new Map(sites.map((s, i) => [s.domain, i]));
+ return [...articles].sort((a, b) => order.get(a.site) - order.get(b.site)).slice(0, limit);
+}
+
+/** Build the catalog once per process (and per test boot), at `now`. */
+function createCatalog({ now = () => Date.now() } = {}) {
+ const sites = buildSites();
+ const byId = new Map(sites.map((s) => [s.id, s]));
+ const { articles, bySite } = buildArticles(sites);
+ const byArticle = new Map(articles.map((a) => [`${a.site}/${a.slug}`, a]));
+ const byPath = new Map(articles.map((a) => [a.path, a]));
+ return {
+ generatedAt: new Date(now()).toISOString(),
+ source: SOURCE,
+ sites,
+ byId,
+ articles,
+ bySite,
+ byArticle,
+ byPath,
+ popular: popularQuestions(sites, articles),
+ /** A site id a caller may pass: the domain, exactly as the catalog lists it. */
+ get: (id) => byId.get(String(id || '')) || null,
+ article: (site, slug) => byArticle.get(`${String(site || '')}/${String(slug || '')}`) || null,
+ };
+}
+
+module.exports = { createCatalog, SOURCE, siteDomain };
@@ -38,6 +38,16 @@ function load(env = process.env) {
@@ -38,6 +38,16 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.HELP_PGLITE_DIR || '' },
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.HELP_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:help:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:help:' },
+ // OpenVibe.Search's public query API (GET /api/v1/search?q=&limit=), asked for the "From across OpenVibe"
+ // half of /search. This is the only host /search ever calls: a person's query is a parameter, never a URL.
+ // Answers are cached in this process for cacheMs; a failure degrades the page to the articles alone.
+ search: {
+ url: trim(env.HELP_SEARCH_URL || 'https://search.openvibe.network'),
+ limit: Math.min(20, Math.max(1, int(env.HELP_SEARCH_LIMIT, 8))),
+ timeoutMs: Math.max(200, int(env.HELP_SEARCH_TIMEOUT_MS, 2500)),
+ cacheMs: Math.max(0, int(env.HELP_SEARCH_CACHE_MS, 5 * 60_000)),
+ },
+
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
@@ -3,27 +3,199 @@
@@ -3,27 +3,199 @@
/**
/**
* /api/v1 — OpenVibe.Help's API.
* /api/v1 — OpenVibe.Help's API.
*
*
- * GET /ping public liveness: { ok: true, service }
+ * GET /sites public every OpenVibe site the catalog knows, with its state
+ * GET /sites/:id public one site: its pillars, its FAQ (as articles), where it lives
+ * GET /articles?q= public the generated articles; ?q= ranks them
+ * POST /tickets person open a ticket (its first message is the description)
+ * GET /tickets person your tickets, newest first
+ * GET /tickets/:id person one ticket with its conversation (staff may read any)
+ * POST /tickets/:id/messages person reply (staff reply on any ticket, as staff)
+ * GET /staff/tickets staff the queue, filtered by site and state
+ * POST /tickets/:id/state staff set open | waiting | solved
*
*
- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it
- * needs (`principal.requireCapability('<help>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes
- * its per-caller numbers (`limits.reads('<help>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).
- * A refusal is an RFC 9457 problem+json with a stable code.
+ * A ticket belongs to the person who opened it: anyone else gets 404 — the same as a ticket that does not exist —
+ * unless they are staff by their Network role claim. Writes made with the session cookie must come from
+ * openvibe.help itself (same-origin), so another site cannot make a signed-in person open or answer a ticket.
+ *
+ * No route names a capability: openvibe-contracts declares none for help.* yet, and every route here is a person
+ * acting for themself (see CAPABILITIES in ./principal.js).
*/
*/
const express = require('express');
const express = require('express');
+const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const tickets = require('../tickets/tickets');
+const store = require('../tickets/store');
+const { isStaff } = require('../tickets/staff');
+const { searchArticles } = require('../search');
function createApi(ctx) {
function createApi(ctx) {
- const { config, principal, limits } = ctx;
+ const { config, s, catalog, principal, limits } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
r.use(principal.middleware);
- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.
+ // Liveness: public, and counted per caller with the default read numbers.
r.get('/ping', limits.reads('help.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('help.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
+ // ── The catalog (public) ────────────────────────────────
+ const siteWire = (site) => {
+ const articles = catalog.bySite.get(site.id) || [];
+ return {
+ id: site.id,
+ name: site.name,
+ tagline: site.tagline,
+ what: site.oneLine,
+ description: site.description,
+ state: site.state,
+ url: site.url,
+ updates_url: site.updatesUrl,
+ pillars: site.pillars.map((p) => ({ title: p.title, text: p.text })),
+ faq: articles.map((a) => ({ question: a.question, answer: a.answer, url: a.path, anchor: a.anchor })),
+ article_count: articles.length,
+ source: { name: site.provenance.source, url: site.provenance.url, manifests: site.provenance.manifests },
+ };
+ };
+
+ r.get('/sites', limits.reads('help.site.read'), (_req, res) => res.json({
+ generated_at: catalog.generatedAt,
+ source: { name: catalog.source.name, url: catalog.source.url },
+ count: catalog.sites.length,
+ sites: catalog.sites.map(siteWire),
+ }));
+
+ r.get('/sites/:id', limits.reads('help.site.read'), (req, res) => {
+ const site = catalog.get(req.params.id);
+ if (!site) return problem(req, res, 404, 'help.site.not_found', `No site "${String(req.params.id).slice(0, 80)}" in the catalog; GET /api/v1/sites lists them.`);
+ res.json(siteWire(site));
+ });
+
+ // ── The articles ────────────────────────────────────────
+ r.get('/articles', limits.reads('help.article.read'), (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 200) : '';
+ const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 50));
+ if (!q) {
+ const all = catalog.articles.slice(0, limit);
+ return res.json({ query: '', count: all.length, articles: all.map((a) => ({ question: a.question, answer: a.answer, site: a.site, site_name: a.siteName, url: a.path })) });
+ }
+ const hits = searchArticles(catalog, q, { limit });
+ return res.json({
+ query: q,
+ count: hits.length,
+ articles: hits.map((h) => ({ question: h.article.question, answer: h.article.answer, snippet: h.snippet, score: h.score, site: h.article.site, site_name: h.article.siteName, url: h.article.path })),
+ note: 'Ranked by a fixed score over the question, the answer and the site\'s own text; see the source field of each article.',
+ });
+ });
+
+ // ── Who may act ─────────────────────────────────────────
+ /**
+ * A person, signed in. An app or service token is refused: a ticket is somebody's private conversation with
+ * support, and no app acts for a person here. A session write must come from this site.
+ */
+ function requirePerson(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') {
+ problem(req, res, 401, 'token.required', 'Sign in at openvibe.help, or send a person\'s Network token as a Bearer.');
+ return false;
+ }
+ if (p.kind !== 'user') {
+ problem(req, res, 403, 'help.person_required', 'A ticket belongs to a person; an app, agent or service token cannot open or answer one.');
+ return false;
+ }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.help itself.');
+ return false;
+ }
+ return true;
+ }
+ function requireStaff(req, res) {
+ if (!requirePerson(req, res)) return false;
+ if (!isStaff(req.principal)) {
+ problem(req, res, 403, 'staff.forbidden', 'The support queue is for OpenVibe staff (a Network role of admin or global_mod).');
+ return false;
+ }
+ return true;
+ }
+ const person = (req, res, next) => (requirePerson(req, res) ? next() : undefined);
+ const staff = (req, res, next) => (requireStaff(req, res) ? next() : undefined);
+
+ // ── Tickets ─────────────────────────────────────────────
+ const sites = new Set(catalog.sites.map((x) => x.id));
+ const page = (query, def = 20) => ({
+ limit: Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || def)),
+ before: typeof query.before === 'string' && tickets.isTicketId(query.before) ? query.before : null,
+ });
+
+ /** One ticket, only for the person it belongs to (staff may read any) — otherwise 404, as if it did not exist. */
+ async function load(req, res) {
+ const id = String(req.params.id || '');
+ const row = tickets.isTicketId(id) ? await store.get(s, id) : null;
+ const mine = row && row.requester === req.principal.requester;
+ if (!row || (!mine && !isStaff(req.principal))) {
+ problem(req, res, 404, 'ticket.not_found', 'No such ticket.');
+ return null;
+ }
+ return row;
+ }
+
+ r.post('/tickets', person, limits.budget('help.ticket.create'), async (req, res) => {
+ const b = req.body || {};
+ const check = tickets.validateNew({ site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, { sites });
+ if (!check.ok) return problem(req, res, 422, check.code, check.detail);
+ const row = await store.create(s, {
+ requester: req.principal.requester,
+ site: check.fields.site, subject: check.fields.subject,
+ // Text only: this service never fetches a URL a caller sent.
+ pageUrl: check.fields.pageUrl, body: check.fields.message,
+ });
+ res.set('Location', `/api/v1/tickets/${row.id}`);
+ return res.status(201).json(tickets.toWire(row));
+ });
+
+ r.get('/tickets', person, limits.reads('help.ticket.list'), async (req, res) => {
+ const pageOpts = page(req.query);
+ const out = await store.listForRequester(s, req.principal.requester, pageOpts);
+ res.json({ tickets: out.rows.map((row) => tickets.toWire(row)), next: out.next });
+ });
+
+ r.get('/tickets/:id', person, limits.reads('help.ticket.read'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ const messages = (await store.messages(s, row.id)).map(tickets.toWireMessage);
+ return res.json(tickets.toWire(row, { messages }));
+ });
+
+ r.post('/tickets/:id/messages', person, limits.budget('help.ticket.message'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ const check = tickets.validateMessage(req.body || {});
+ if (!check.ok) return problem(req, res, 422, check.code, check.detail);
+ const staffWriter = isStaff(req.principal);
+ const after = await store.addMessage(s, row.id, { author: req.principal.requester, role: staffWriter ? 'staff' : 'person', body: check.fields.body });
+ res.set('Location', `/api/v1/tickets/${row.id}`);
+ return res.status(201).json(tickets.toWire(after, { messages: (await store.messages(s, row.id)).map(tickets.toWireMessage) }));
+ });
+
+ // ── Staff ───────────────────────────────────────────────
+ r.get('/staff/tickets', staff, limits.reads('help.ticket.staff'), async (req, res) => {
+ const state = typeof req.query.state === 'string' && (tickets.isState(req.query.state) || req.query.state === 'all') ? req.query.state : tickets.DEFAULT_STATE;
+ const site = typeof req.query.site === 'string' && sites.has(req.query.site) ? req.query.site : null;
+ const out = await store.listForStaff(s, { site, state, ...page(req.query, 50) });
+ res.json({ filter: { site, state }, counts: await store.staffCounts(s), tickets: out.rows.map((row) => tickets.toWire(row)), next: out.next });
+ });
+
+ r.post('/tickets/:id/state', staff, async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ const state = (req.body || {}).state;
+ if (!tickets.isState(state)) return problem(req, res, 422, 'ticket.invalid', `state: one of ${tickets.STATES.join(', ')}`);
+ const after = await store.setState(s, row.id, state);
+ return res.json(tickets.toWire(after));
+ });
+
return r;
return r;
}
}
@@ -26,13 +26,21 @@ function caller(req) {
@@ -26,13 +26,21 @@ function caller(req) {
}
}
/**
/**
- * The product's expensive routes, each with its numbers per caller (a minute, an hour). A route takes its entry
- * with `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was
- * not declared here. The empty object is the skeleton's starting point:
+ * The product's writes, each with its numbers per caller (a minute, an hour). A route takes its entry with
+ * `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was not
+ * declared here.
*
*
- * 'help.thing.create': { minute: 6, hour: 60 },
+ * help.ticket.create a ticket is an email to a person: a handful an hour is plenty, and the first one is
+ * the one that matters
+ * help.ticket.message replies are cheap but a conversation can be long, so this is the looser number
+ *
+ * Staff use the same routes (their role, not a different budget, is what they get); a support queue run by a
+ * handful of people should never be throttled into silence, which is why the numbers are per caller, not global.
*/
*/
-const BUDGETS = {};
+const BUDGETS = {
+ 'help.ticket.create': { minute: 6, hour: 60 },
+ 'help.ticket.message': { minute: 30, hour: 300 },
+};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
const refused = registry
@@ -2,10 +2,12 @@
@@ -2,10 +2,12 @@
/**
/**
* Crawl artifacts for openvibe.help, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
* Crawl artifacts for openvibe.help, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
- * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
- * the API are not.
+ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in,
+ * the API, a person's tickets and the support queue are not.
*
*
- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.
+ * The sitemap and llms-full.txt are two renderings of one list (publicPages), so a page can never be in one and
+ * missing from the other — including the help centre itself: every site and every question is a page here, which
+ * is exactly what makes the answers findable.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -14,13 +16,8 @@ const cache = require('openvibe-shared/cache-policy');
@@ -14,13 +16,8 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Help';
const SITE_NAME = 'OpenVibe.Help';
-const DESCRIPTION = 'OpenVibe.Help — Answers, support and a way to reach people.';
-const DISALLOW = ['/auth/', '/api/'];
-
-const PAGE_TEXT = {
- '/': ['OpenVibe.Help home', 'OpenVibe.Help: Answers, support and a way to reach people.'],
- '/updates': ['What shipped on OpenVibe.Help', 'This site\'s update log, from the network changelog feed.'],
-};
+const DESCRIPTION = 'OpenVibe.Help is the help and support centre for every OpenVibe site: search the answers each site gives about itself, browse them by site, and open a support ticket that stays one conversation.';
+const DISALLOW = ['/auth/', '/api/', '/tickets', '/staff'];
function dayOf(ts) {
function dayOf(ts) {
const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
@@ -33,67 +30,93 @@ function siteUpdated() {
@@ -33,67 +30,93 @@ function siteUpdated() {
function homeJsonLd(config) {
function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
return [
- seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION, searchUrl: `${site}/search?q={q}` }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe help, openvibe support, help center, support tickets, troubleshooting' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
];
}
}
-const publicPages = () => [
- { path: '/', changefreq: 'weekly', priority: 1.0 },
- { path: '/updates', changefreq: 'daily', priority: 0.5 },
-];
+/**
+ * Every public page, in the order a crawler should read them: the site's own pages, then one per catalog site, then
+ * one per question. Each entry carries its own title and one-line text, so the sitemap and llms-full.txt are two
+ * renderings of this one list and cannot drift apart.
+ */
+function publicPages(catalog) {
+ const static_ = [
+ { path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Help home', text: 'Search the answers of every OpenVibe site, browse the sites one by one, or open a support ticket that stays one conversation.' },
+ { path: '/sites', changefreq: 'weekly', priority: 0.9, title: 'Every OpenVibe site', text: `${catalog.sites.length} OpenVibe sites with what each one is, whether it is live yet, and its own questions and answers.` },
+ { path: '/search', changefreq: 'weekly', priority: 0.7, title: 'Search the help centre', text: 'Search every OpenVibe site\'s own answers, and the whole network through OpenVibe.Search.' },
+ { path: '/updates', changefreq: 'daily', priority: 0.5, title: `What shipped on ${SITE_NAME}`, text: 'This site\'s update log, from the network changelog feed.' },
+ ];
+ const sites = catalog.sites.map((s) => ({
+ path: `/sites/${s.id}`, changefreq: 'weekly', priority: s.live ? 0.8 : 0.6,
+ title: s.name, text: `${s.state === 'live' ? 'Live' : 'Coming'}: ${s.oneLine} ${s.description || ''}`.trim(),
+ }));
+ const articles = catalog.articles.map((a) => ({
+ path: a.path, changefreq: 'monthly', priority: 0.5,
+ title: a.question, text: `${a.answer} (Answered by ${a.siteName}.)`,
+ }));
+ return [...static_, ...sites, ...articles];
+}
function createDiscoveryRoutes(ctx) {
function createDiscoveryRoutes(ctx) {
- const { config } = ctx;
+ const { config, catalog } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+ const pages = publicPages(catalog);
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
- '# openvibe.help: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
+ '# openvibe.help: the public pages are for search and AI crawlers; sign-in, the API and tickets are not.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
});
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
- summary: 'OpenVibe.Help: Answers, support and a way to reach people.',
- details: 'Every page is server-rendered and readable without JavaScript.',
+ summary: 'OpenVibe.Help: the help and support centre for every OpenVibe site. The answers are generated from OpenVibe.Contracts at boot — each site\'s own manifest, verbatim — so a page here is the product\'s own words, with a link back to where they were read.',
+ details: 'Every site and every question is a page: /sites lists them, /sites/<domain> is one site with its pillars and its questions, and /a/<domain>/<slug> is one question with its answer. /search searches those answers and the network through OpenVibe.Search. Signed in, a person can open a support ticket; the ticket and its conversation are private to them (and to OpenVibe staff). Every page is server-rendered and readable without JavaScript.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
- { title: 'OpenVibe.Help', url: abs('/'), note: 'Answers, support and a way to reach people.' },
- { title: 'What shipped on OpenVibe.Help', url: abs('/updates') },
+ { title: 'OpenVibe.Help', url: abs('/'), note: 'search the answers, browse the sites, open a ticket' },
+ { title: 'Every OpenVibe site', url: abs('/sites'), note: `${catalog.sites.length} sites with their state and their own words` },
+ { title: 'Search', url: abs('/search?q=help'), note: 'the help articles and the network, in two sections' },
+ { title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },
] },
] },
+ { title: 'Sites', links: catalog.sites.map((s) => ({ title: s.name, url: abs(`/sites/${s.id}`), note: `${s.live ? 'live' : 'coming'} — ${s.oneLine}` })) },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
+ { title: 'Every site (JSON)', url: abs('/api/v1/sites') },
+ { title: 'One site (JSON)', url: abs(`/api/v1/sites/${catalog.sites[0] ? catalog.sites[0].id : 'openvibe.network'}`) },
+ { title: 'The articles (JSON, ?q= ranks them)', url: abs('/api/v1/articles?q=live') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
] },
] },
{ title: 'Elsewhere', links: [
{ title: 'Elsewhere', links: [
- { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
+ { title: 'OpenVibe.Search', url: 'https://search.openvibe.network', note: 'the network\'s own index, the "From across OpenVibe" half of /search' },
{ title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },
{ title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
] },
] },
],
],
}));
}));
});
});
r.get('/llms-full.txt', (_req, res) => {
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] }));
+ const entries = pages.map((p) => ({ url: p.path, title: p.title, text: p.text }));
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
- summary: 'Every public page of OpenVibe.Help, one line each.',
+ summary: 'Every public page of OpenVibe.Help, one line each: the site, and every question each site answers about itself.',
base: site,
base: site,
- maxBytes: 64 * 1024,
- sections: [{ title: 'Pages', pages }],
+ maxBytes: 256 * 1024,
+ sections: [{ title: 'Pages', pages: entries }],
}));
}));
});
});
r.get('/sitemap.xml', (_req, res) => {
r.get('/sitemap.xml', (_req, res) => {
const lastmod = siteUpdated();
const lastmod = siteUpdated();
- const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ const urls = pages.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));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
@@ -1,70 +1,473 @@
@@ -1,70 +1,473 @@
'use strict';
'use strict';
/**
/**
- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,
- * llms-full.txt, JSON-LD) are http/discovery.js.
+ * Public pages: the help centre generated from OpenVibe.Contracts (home, /sites, one site, one question) and the
+ * support side (/search, /tickets, one ticket, /staff, the update log). Crawl artifacts are http/discovery.js.
*
*
- * Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js).
- * The product replaces the home page's sections and adds its own routes here.
+ * Every page is server-rendered and complete without JavaScript — forms are plain POSTs and the search box is a
+ * GET — through openvibe-shared/shell (render/layout.js). Nothing here is hand-written copy: the sites, the one
+ * lines, the pillars and every question and answer come from the manifests (server/catalog.js), and each page says
+ * which manifest it read. A signed-in write must come from openvibe.help itself, as in the API.
*/
*/
+const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
-const { html, raw } = require('../render/html');
+const { sameOrigin } = require('./principal');
+const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
+const { searchArticles, markTerms } = require('../search');
+const tickets = require('../tickets/tickets');
+const store = require('../tickets/store');
+const { isStaff } = require('../tickets/staff');
const SITE_NAME = 'OpenVibe.Help';
const SITE_NAME = 'OpenVibe.Help';
const TAGLINE = 'Answers, support and a way to reach people.';
const TAGLINE = 'Answers, support and a way to reach people.';
+const PAGE_LIMIT = 50;
+
+const STATE_KIND = { open: 'info', waiting: 'warn', solved: 'ok' };
+const STATE_TEXT = { open: 'open', waiting: 'waiting', solved: 'solved' };
+const SITE_STATE_KIND = { live: 'ok', coming: '' };
+const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
+
+/** Plain text exactly as it was typed: escaped by the template, line breaks kept by the stylesheet. */
+const textBlock = (t) => html`<p class="txt">${t}</p>`;
+const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config } = ctx;
+ const { config, s, catalog, search } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ // The pages' forms are plain POSTs (no JavaScript): urlencoded only, and small — a ticket is text.
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const staffViewer = (req) => isStaff(req.viewer);
+ const requesterOf = (req) => `user:${req.viewer.subject}`;
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+
+ // ── Small pieces ─────────────────────────────────────────
+ const siteBadge = (state) => badge(SITE_STATE_TEXT[state] || state, SITE_STATE_KIND[state] || '');
+ const ticketBadge = (state) => badge(STATE_TEXT[state] || state, STATE_KIND[state] || '');
+
+ /** Where a page's text came from: the manifests it was read from, and when. */
+ const provenance = (p, { compact = false } = {}) => {
+ const files = p.manifests || (p.manifest ? [p.manifest] : []);
+ return html`<p class="prov small muted">Generated from <a href="${p.url}" rel="noopener nofollow">${p.source}</a>${files.length ? html` · ${files.map((f) => html`<code>${f}</code>`)}` : ''}${compact ? '' : html` · read ${time(catalog.generatedAt)}`}</p>`;
+ };
+
+ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
+
+ const searchForm = (q = '', { big = false } = {}) => html`<form class="search-form${big ? ' search-big' : ''}" method="get" action="/search" role="search">
+<label class="sr-only" for="q">Search the help centre</label>
+<input id="q" name="q" type="search" value="${q}" placeholder="Search every OpenVibe site: how a feature works, what a price is, what is not working" maxlength="200" required>
+<button class="sc-btn sc-primary" type="submit">Search</button>
+</form>`;
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
+ /** Contact support: the form itself is /tickets/new, and a visitor who is not signed in is asked to sign in. */
+ function contactBlock(req) {
+ if (!signedIn(req)) {
+ return html`<div class="card">${signInPrompt('To open a ticket', '/tickets/new')}
+<p class="small muted">A ticket is one conversation with the people who run OpenVibe, kept in your account — not an inbox you lose. Signing in also lets you see and answer it later.</p></div>`;
+ }
+ return html`<div class="card"><p class="small muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>.</p>
+<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a> <a class="sc-btn" href="/tickets">Your tickets</a></p></div>`;
+ }
+
r.get('/', (req, res) => {
r.get('/', (req, res) => {
+ const live = catalog.sites.filter((x) => x.live);
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
- title: SITE_NAME,
- accent: TAGLINE,
- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,
+ title: 'How can we help?',
+ accent: 'Every OpenVibe site in one place.',
+ lede: `The answers come from the sites themselves: each product's own manifest, kept word for word, with a link back to where it was read. Search it, or open a ticket and it stays one conversation.`,
actions: signedIn(req)
actions: signedIn(req)
- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]
- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],
- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',
+ ? [{ label: 'Your tickets', href: '/tickets' }, { label: 'Open a ticket', href: '/tickets/new', primary: true }]
+ : [{ label: 'Search the answers', href: '#search', primary: true }, { label: 'Sign in to open a ticket', href: '/auth/login?next=%2Ftickets%2Fnew' }],
+ note: `Open source (AGPL-3.0). ${catalog.sites.length} sites in the catalog: ${live.length} live, ${catalog.sites.length - live.length} still coming.`,
});
});
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
+ description: `Search the answers of every OpenVibe site, browse them one by one, or open a support ticket that stays one conversation.`,
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
-${raw(showcase.features({
- title: 'What is already here',
- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',
- items: [
- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },
- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },
- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },
- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },
- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },
- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },
- ],
- }))}
-${raw(showcase.steps({
- title: 'How a new service starts',
- items: [
- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },
- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },
- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },
- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },
- ],
- }))}
-${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,
+<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the help centre</h2>
+<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>
+${searchForm()}</section>
+<section class="sc-sec" aria-labelledby="h-popular"><h2 id="h-popular">Popular questions</h2>
+<p class="sc-lede">Every line below is a question an OpenVibe product answers about itself in its own manifest.</p>
+<ul class="q-list">${catalog.popular.map((a) => html`<li><a href="${a.path}">${a.question}</a> <span class="muted small">${a.siteName}</span></li>`)}</ul>
+<p class="small"><a href="/sites">All ${catalog.sites.length} sites and their questions</a></p></section>
+<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
+<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>
+<ul class="site-grid">${catalog.sites.map((x) => html`<li><a href="/sites/${x.id}">${x.name}</a></li>`)}</ul></section>
+<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>
+<p class="sc-lede">Nothing here answered it? Open a ticket: you and the people who run OpenVibe, in one thread.</p>
+${contactBlock(req)}</section>
+${raw(showcase.cta({ title: 'What shipped on OpenVibe.Help', text: 'The update log is the network changelog, filtered to this service.', actions: [{ label: 'The update log', href: '/updates' }, { label: 'The API', href: '/api/v1/sites' }] }))}`,
+ });
+ });
+
+ // ── The sites ────────────────────────────────────────────
+ r.get('/sites', (req, res) => {
+ const group = (state, title, lede) => {
+ const list = catalog.sites.filter((x) => x.state === state);
+ if (!list.length) return '';
+ return html`<section class="sc-sec" aria-labelledby="h-${state}"><h2 id="h-${state}">${title}</h2><p class="sc-lede">${lede}</p>
+<ul class="site-list">${list.map((x) => html`<li><a href="/sites/${x.id}"><b>${x.name}</b></a> <span class="muted">${x.oneLine}</span> <span class="small"><a href="${x.url}" rel="noopener nofollow">${x.domain}</a></span></li>`)}</ul></section>`;
+ };
+ page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Every OpenVibe site',
+ description: `${catalog.sites.length} OpenVibe sites with what each one is, whether it is live yet, and its own questions and answers.`,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites' }],
+ jsonLd: [
+ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: 'Sites', url: `${base()}/sites` }]),
+ seo.jsonLd.itemList('OpenVibe sites', catalog.sites.map((x) => ({ name: x.name, url: `${base()}/sites/${x.id}`, description: x.oneLine })), { url: `${base()}/sites` }),
+ ],
+ body: html`<h1>Every OpenVibe site</h1>
+<p class="sc-lede">${catalog.sites.length} addresses, ${catalog.sites.filter((x) => x.live).length} of them live. The state is the service manifest's own <code>exposure.state</code>: a site is live when a service exposes it live. Everything else on this page is that site's manifest, word for word.</p>
+${provenance(catalog.source)}
+${group('live', 'Live', 'These answer today.')}
+${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serving something else for now.')}`,
+ });
+ });
+
+ r.get('/sites/:id', (req, res) => {
+ const site = catalog.get(req.params.id);
+ if (!site) return page(req, res, {
+ title: 'No such site', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: 'Not found' }],
+ body: html`<h1>No such site</h1><p class="muted">The catalog has no site <code>${truncate(String(req.params.id), 80)}</code>. <a href="/sites">See all ${catalog.sites.length} sites</a>.</p>`,
+ }, 404);
+ const articles = catalog.bySite.get(site.id) || [];
+ const faqPairs = articles.map((a) => ({ q: a.question, a: a.answer }));
+ page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: site.name,
+ description: truncate(site.oneLine || site.description, 160),
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: site.name }],
+ jsonLd: [
+ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: 'Sites', url: `${base()}/sites` }, { name: site.name, url: `${base()}/sites/${site.id}` }]),
+ faqPairs.length ? seo.jsonLd.faq(faqPairs) : null,
+ ],
+ body: html`<h1>${site.name} ${siteBadge(site.state)}</h1>
+${site.tagline ? html`<p class="sc-lede">${site.tagline}</p>` : ''}
+${site.description ? textBlock(site.description) : ''}
+<p class="site-links">${site.state === 'live'
+ ? html`<a class="sc-btn sc-primary" href="${site.url}" rel="noopener nofollow">Open ${site.domain}</a>`
+ : html`<span class="muted">Not serving yet — ${site.url} is its address.</span>`}
+<a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped on ${site.name}</a></p>
+${provenance(site.provenance)}
+
+${site.pillars.length ? html`<section class="sc-sec" aria-labelledby="h-pillars"><h2 id="h-pillars">What you can do</h2>
+<ul class="pillars">${site.pillars.map((p) => html`<li><b>${p.title}</b>${p.text ? html` <span class="muted">${p.text}</span>` : ''}</li>`)}</ul></section>` : ''}
+
+<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
+${articles.length
+ ? html`<p class="sc-lede">${articles.length} question${articles.length === 1 ? '' : 's'} this site answers about itself. Each one has its own page, so it can be linked and found on its own.</p>
+${articles.map((a) => html`<article class="faq-item" id="${a.anchor}">
+<h3><a href="${a.path}">${a.question}</a></h3>
+${textBlock(a.answer)}
+<p class="small"><a href="${a.path}">Open this question on its own page</a></p></article>`)}`
+ : html`<p class="muted">This site's manifest has no questions yet.${site.state === 'coming' ? ' It is still coming.' : ''} <a href="/tickets/new">Ask us instead</a> or read the site's own pages at <a href="${site.url}" rel="noopener nofollow">${site.domain}</a>.</p>`}
+</section>`,
+ });
+ });
+
+ // ── One question, one page (SEO title = the question) ────
+ r.get('/a/:site/:slug', (req, res) => {
+ const article = catalog.article(req.params.site, req.params.slug);
+ if (!article) return page(req, res, {
+ title: 'No such question', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: 'Not found' }],
+ body: html`<h1>No such question</h1><p class="muted">No answer here for <code>${truncate(`${req.params.site}/${req.params.slug}`, 90)}</code>. <a href="/sites">See every site</a>.</p>`,
+ }, 404);
+ const site = catalog.get(article.site);
+ page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: article.question,
+ description: truncate(article.answer, 160),
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],
+ jsonLd: [
+ seo.jsonLd.article({ headline: article.question, url: `${base()}${article.path}`, description: truncate(article.answer, 160) }),
+ seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: article.siteName, url: `${base()}/sites/${article.site}` }, { name: article.question, url: `${base()}${article.path}` }]),
+ ],
+ body: html`<article class="answer">
+<h1>${article.question}</h1>
+<p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>
+${textBlock(article.answer)}
+<p class="site-links"><a class="sc-btn" href="/sites/${article.site}">Everything ${article.siteName} answers</a>
+${site ? html`<a class="sc-btn" href="${site.url}" rel="noopener nofollow">${site.domain}</a><a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped</a>` : ''}</p>
+${provenance(article.provenance)}
+</article>
+<section class="sc-sec" aria-labelledby="h-more"><h2 id="h-more">Other questions from ${article.siteName}</h2>
+<ul class="q-list">${(catalog.bySite.get(article.site) || []).filter((a) => a.slug !== article.slug).map((a) => html`<li><a href="${a.path}">${a.question}</a></li>`)}</ul></section>`,
+ });
+ });
+
+ // ── Search ───────────────────────────────────────────────
+ r.get('/search', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 200) : '';
+ const hits = q ? searchArticles(catalog, q, { limit: 20 }) : [];
+ const network = q ? await search.search(q) : { ok: false, reason: 'no query', results: [] };
+ page(req, res, {
+ // Indexable, and in the sitemap: every query canonicalises to /search (the layout drops the query), so a
+ // crawler keeps one page, not one per query.
+ index: true, cache: PUBLIC_CACHE,
+ title: q ? `Search: ${truncate(q, 60)}` : 'Search',
+ description: 'Search every OpenVibe site\'s own answers, and the whole network through OpenVibe.Search.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
+ body: html`<h1>Search</h1>
+${searchForm(q, { big: true })}
+${!q ? html`<p class="sc-lede">Type something above. The first half of the answer comes from each site's own manifest; the second from <a href="${search.url}" rel="noopener nofollow">OpenVibe.Search</a>, the network's own index.</p>` : ''}
+${q ? html`<section class="sc-sec" aria-labelledby="h-help"><h2 id="h-help">Help articles</h2>
+${hits.length
+ ? html`<p class="sc-lede">${hits.length} article${hits.length === 1 ? '' : 's'} from the sites' own manifests, best match first.</p>
+<ol class="results">${hits.map((h) => html`<li><a href="${h.article.path}">${markTerms(h.article.question, q)}</a>
+<p class="small muted">${markTerms(h.snippet, q)}</p>
+<p class="small"><a href="/sites/${h.article.site}">${h.article.siteName}</a></p></li>`)}</ol>`
+ : html`<p class="muted">No article matches “${q}”. Try fewer or different words, <a href="/sites">browse the sites</a>, or <a href="/tickets/new">open a ticket</a>.</p>`}
+</section>` : ''}
+${q ? html`<section class="sc-sec" aria-labelledby="h-net"><h2 id="h-net">From across OpenVibe</h2>
+${network.ok
+ ? (network.results.length
+ ? html`<p class="sc-lede">From <a href="${search.url}" rel="noopener nofollow">OpenVibe.Search</a>, the network's index of streams, clips, pastes, posts and wiki pages.</p>
+<ul class="results net">${network.results.map((x) => html`<li><a href="${x.url}" rel="noopener nofollow">${x.title}</a>${x.summary ? html`<p class="small muted">${x.summary}</p>` : ''}<p class="small">${x.type ? html`<span class="badge">${x.type}</span> ` : ''}${x.owner ? html`<span class="muted">${x.owner}</span>` : ''}</p></li>`)}</ul>`
+ : html`<p class="muted">OpenVibe.Search answered, with nothing for “${q}”.</p>`)
+ : html`<p class="muted">OpenVibe.Search could not be reached just now (<span class="badge warn">${network.reason}</span>), so this page shows the help articles only. Try again in a moment.</p>`}
+</section>` : ''}`,
+ });
+ });
+
+ // ── Tickets ──────────────────────────────────────────────
+ /** A page write must come from this site, exactly as in the API: a cross-site form must not act as the person. */
+ function sameSite(req, res, back) {
+ if (sameOrigin(req, config.baseUrl)) return true;
+ page(req, res, {
+ title: 'Not from this site',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Not from this site' }],
+ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.help itself. <a href="${back}">Go back</a> and try again.</p>`,
+ }, 403);
+ return false;
+ }
+
+ /**
+ * The same per-caller budget the API applies to opening a ticket and replying, given an HTML face. The limiter
+ * writes its refusal straight to the response, so it writes to a shim and the page renders its own 429 — a person
+ * using the form should not be handed problem+json. The counters, the windows and the metric are the API's: the
+ * form is the same action through another door, so it cannot be a way round the limit.
+ */
+ const budgeted = (name) => {
+ const limit = ctx.limits.budget(name);
+ return (req, res, next) => {
+ let settled = false;
+ const refusal = (body) => {
+ let problem = null;
+ try { problem = JSON.parse(String(body)); } catch { problem = null; }
+ if (problem && problem.retry_after_seconds) res.set('Retry-After', String(problem.retry_after_seconds));
+ return page(req, res, {
+ title: 'Too many requests',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Too many requests' }],
+ body: html`<h1>Too many requests just now</h1>
+${notice(problem && problem.detail ? problem.detail : 'You are over the limit for this', 'warn')}
+<p class="muted">Wait a moment and send it again — nothing was lost. Everything you already opened is under <a href="/tickets">your tickets</a>.</p>`,
+ }, 429);
+ };
+ limit(req, {
+ statusCode: 0,
+ setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},
+ end: (body) => { if (!settled) { settled = true; refusal(body); } },
+ }, (err) => {
+ if (settled) return undefined;
+ settled = true;
+ return err ? next(err) : next();
+ });
+ return undefined;
+ };
+ };
+
+ /** The site picker: every catalog site, live ones first (the catalog is already in that order). */
+ function siteSelect(selected = '') {
+ const group = (state, label) => {
+ const list = catalog.sites.filter((x) => x.state === state);
+ if (!list.length) return '';
+ return html`<optgroup label="${label}">${list.map((x) => html`<option value="${x.id}"${x.id === selected ? raw(' selected') : ''}>${x.name}${x.live ? '' : ' (coming)'}</option>`)}</optgroup>`;
+ };
+ return html`<select id="site" name="site"><option value="">Not about one site</option>${group('live', 'Live')}${group('coming', 'Coming')}</select>`;
+ }
+
+ const ticketForm = ({ values = {}, problem = null } = {}) => html`<form class="card ticket-form" method="post" action="/tickets">
+${problem ? notice(problem, 'warn') : ''}
+<div class="field"><label for="site">Which site is this about?</label>${siteSelect(values.site || '')}
+<p class="small muted">Optional. Pick the OpenVibe site you were using, if it was one.</p></div>
+<div class="field"><label for="subject">Subject</label><input id="subject" name="subject" type="text" maxlength="${tickets.LIMITS.subject}" required value="${values.subject || ''}"></div>
+<div class="field"><label for="message">What happened?</label><textarea id="message" name="message" rows="8" maxlength="${tickets.LIMITS.body}" required placeholder="What you did, what you expected, what happened instead.">${values.message || ''}</textarea>
+<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters.</p></div>
+<div class="field"><label for="page_url">Page you were on</label><input id="page_url" name="page_url" type="text" maxlength="${tickets.LIMITS.pageUrl}" value="${values.pageUrl || ''}" placeholder="https://openvibe.example/some/page">
+<p class="small muted">Optional, and only useful if you have it: it is stored as text and never opened by us.</p></div>
+<p><button class="sc-btn sc-primary" type="submit">Open the ticket</button> <a class="sc-btn" href="/tickets">Your tickets</a></p>
+</form>`;
+
+ r.get('/tickets/new', (req, res) => {
+ if (!signedIn(req)) return page(req, res, {
+ title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
+ body: html`<h1>Open a ticket</h1>${signInPrompt('A ticket belongs to your OpenVibe account, so you can see and answer it later.', '/tickets/new')}`,
+ }, 401);
+ page(req, res, {
+ title: 'Open a ticket',
+ description: 'Tell OpenVibe support what happened; the ticket stays one conversation.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
+ body: html`<h1>Open a ticket</h1>
+<p class="sc-lede">One conversation with the people who run OpenVibe. You will see it under <a href="/tickets">your tickets</a> and can reply there.</p>
+${ticketForm()}`,
+ });
+ });
+
+ const mustSignIn = (back) => (req, res, next) => (signedIn(req) ? next()
+ : page(req, res, { title: 'Sign in', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sign in' }], body: html`<h1>Sign in first</h1>${signInPrompt('A ticket belongs to an account, so you can see and answer it later.', back)}` }, 401));
+
+ r.post('/tickets', mustSignIn('/tickets/new'), budgeted('help.ticket.create'), async (req, res) => {
+ if (!sameSite(req, res, '/tickets/new')) return;
+ const b = req.body || {};
+ const check = tickets.validateNew({ site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, { sites: new Set(catalog.sites.map((x) => x.id)) });
+ if (!check.ok) return page(req, res, {
+ title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
+ body: html`<h1>Open a ticket</h1>${ticketForm({ values: { site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, problem: check.detail })}`,
+ }, 422);
+ const row = await store.create(s, {
+ requester: requesterOf(req), site: check.fields.site, subject: check.fields.subject,
+ // Text only: this service never fetches a URL a caller sent.
+ pageUrl: check.fields.pageUrl, body: check.fields.message,
+ });
+ return res.redirect(303, `/tickets/${row.id}`);
+ });
+
+ const canSee = (req, row) => Boolean(row) && (row.requester === requesterOf(req) || staffViewer(req));
+
+ r.get('/tickets', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, {
+ title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
+ body: html`<h1>Your tickets</h1>${signInPrompt('Your tickets belong to your OpenVibe account.', '/tickets')}`,
+ }, 401);
+ const out = await store.listForRequester(s, requesterOf(req), { limit: PAGE_LIMIT });
+ return page(req, res, {
+ title: 'Your tickets',
+ description: 'The support tickets you opened, and where each conversation stands.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
+ body: html`<h1>Your tickets</h1>
+<p class="sc-lede">Everything you opened, newest first. A ticket stays one conversation — reply on its page and it carries on there.</p>
+${staffViewer(req) ? notice(html`You are OpenVibe staff, so you can also <a href="/staff">read the whole support queue</a>.`, 'info') : ''}
+<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a></p>
+${out.rows.length
+ ? html`<ul class="ticket-list">${out.rows.map((t) => html`<li><a href="/tickets/${t.id}"><b>${t.subject}</b></a> ${ticketBadge(t.state)}
+<span class="muted small">${t.site || 'not about one site'} · ${t.message_count} message${Number(t.message_count) === 1 ? '' : 's'} · updated ${time(t.updated_at)}</span></li>`)}</ul>`
+ : html`<p class="muted">No tickets yet. <a href="/tickets/new">Open one</a> if a site did not answer your question.</p>`}
+${out.next ? html`<p><a href="/tickets?before=${out.next}">Older tickets</a></p>` : ''}`,
+ });
+ });
+
+ r.get('/tickets/:id', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, {
+ title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Tickets', href: '/tickets' }, { label: 'Ticket' }],
+ body: html`<h1>A ticket</h1>${signInPrompt('A ticket belongs to an OpenVibe account.', `/tickets/${encodeURIComponent(String(req.params.id).slice(0, 40))}`)}`,
+ }, 401);
+ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
+ if (!canSee(req, row)) return page(req, res, {
+ title: 'No such ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'Not found' }],
+ body: html`<h1>No such ticket</h1><p class="muted">There is no ticket here for you. <a href="/tickets">Your tickets</a>.</p>`,
+ }, 404);
+ const messages = await store.messages(s, row.id);
+ const isMine = row.requester === requesterOf(req);
+ const link = tickets.pageLink(row.page_url);
+ // Who wrote a message, read from where the viewer sits: "You" for your own, the staff by their side, and for
+ // staff a stranger is "the person who opened it" (their subject id is on the page, not their name).
+ const whoLabel = (m) => (m.role === 'staff' ? 'OpenVibe support'
+ : (m.author === requesterOf(req) ? 'You' : (m.author === row.requester ? 'The person who opened it' : m.author)));
+ page(req, res, {
+ title: row.subject,
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: truncate(row.subject, 50) }],
+ body: html`<h1>${row.subject}</h1>
+<p>${ticketBadge(row.state)} <span class="muted small">${row.site ? html`about <a href="/sites/${row.site}">${row.site}</a>` : 'not about one site'} · opened ${time(row.created_at)}${isMine ? '' : html` · by <code>${row.requester}</code>`}</span></p>
+${row.page_url ? html`<p class="small muted">Page they were on: ${link ? html`<a href="${link}" rel="noopener nofollow">${row.page_url}</a>` : row.page_url} <span title="stored as text, never fetched">(text only)</span></p>` : ''}
+<ol class="conversation">${messages.map((m) => html`<li class="msg ${m.role}">
+<p class="msg-meta small muted"><b>${whoLabel(m)}</b> · ${time(m.created_at)}</p>
+${textBlock(m.body)}</li>`)}</ol>
+${staffViewer(req) ? html`<form class="card state-form" method="post" action="/tickets/${row.id}/state">
+<fieldset><legend>State</legend>
+${tickets.STATES.map((st) => html`<label class="radio"><input type="radio" name="state" value="${st}"${st === row.state ? raw(' checked') : ''}> ${STATE_TEXT[st]}</label>`)}
+<button class="sc-btn" type="submit">Set the state</button></fieldset>
+<p class="small muted">Changing the state does not send a message. Reply below if the person needs to hear something.</p>
+</form>` : ''}
+<form class="card reply-form" method="post" action="/tickets/${row.id}">
+<label for="message">${staffViewer(req) ? 'Reply as OpenVibe support' : 'Reply'}</label>
+<textarea id="message" name="message" rows="6" maxlength="${tickets.LIMITS.body}" required placeholder="${staffViewer(req) ? 'Answer the question, or ask for what you still need.' : 'Anything else we should know?'}"></textarea>
+<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters. Everything here stays in this one ticket.</p>
+<p><button class="sc-btn sc-primary" type="submit">Send the reply</button></p>
+</form>`,
+ });
+ });
+
+ r.post('/tickets/:id', mustSignIn('/tickets'), budgeted('help.ticket.message'), async (req, res) => {
+ if (!sameSite(req, res, '/tickets')) return;
+ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
+ if (!canSee(req, row)) return page(req, res, { title: 'No such ticket', body: html`<h1>No such ticket</h1><p class="muted"><a href="/tickets">Your tickets</a>.</p>` }, 404);
+ const check = tickets.validateMessage(req.body || {});
+ if (!check.ok) return res.redirect(303, `/tickets/${row.id}`);
+ await store.addMessage(s, row.id, { author: requesterOf(req), role: staffViewer(req) ? 'staff' : 'person', body: check.fields.body });
+ return res.redirect(303, `/tickets/${row.id}`);
+ });
+
+ r.post('/tickets/:id/state', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return page(req, res, { title: 'Staff only', body: html`<h1>Staff only</h1><p class="muted">Setting a ticket's state is for OpenVibe staff.</p>` }, 403);
+ if (!sameSite(req, res, '/staff')) return;
+ const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
+ if (!row) return page(req, res, { title: 'No such ticket', body: html`<h1>No such ticket</h1><p class="muted"><a href="/staff">The support queue</a>.</p>` }, 404);
+ const state = (req.body || {}).state;
+ if (tickets.isState(state)) await store.setState(s, row.id, state);
+ return res.redirect(303, `/tickets/${row.id}`);
+ });
+
+ // ── The support queue (staff) ────────────────────────────
+ r.get('/staff', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, {
+ title: 'Support queue', crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],
+ body: html`<h1>The support queue</h1>${signInPrompt('The queue is for OpenVibe staff.', '/staff')}`,
+ }, 401);
+ if (!staffViewer(req)) return page(req, res, {
+ title: 'Staff only', crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],
+ body: html`<h1>Staff only</h1><p class="muted">The support queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/tickets">Your own tickets</a>.</p>`,
+ }, 403);
+ const state = tickets.isState(req.query.state) || req.query.state === 'all' ? String(req.query.state) : 'open';
+ const site = catalog.byId.has(String(req.query.site)) ? String(req.query.site) : null;
+ const out = await store.listForStaff(s, { site, state, limit: PAGE_LIMIT });
+ const counts = await store.staffCounts(s);
+ return page(req, res, {
+ title: 'Support queue',
+ description: 'Every OpenVibe support ticket, filtered by site and state.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Support queue' }],
+ body: html`<h1>The support queue</h1>
+<p class="sc-lede">Every ticket, newest first. ${counts.open} open, ${counts.waiting} waiting on the person, ${counts.solved} solved.</p>
+<form class="card filters" method="get" action="/staff">
+<div class="field"><label for="state">State</label><select id="state" name="state">
+${[...tickets.STATES, 'all'].map((st) => html`<option value="${st}"${st === state ? raw(' selected') : ''}>${st === 'all' ? 'all' : STATE_TEXT[st]}</option>`)}
+</select></div>
+<div class="field"><label for="site">Site</label><select id="site" name="site"><option value="">any site</option>${catalog.sites.map((x) => html`<option value="${x.id}"${x.id === site ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>
+<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>
+</form>
+${table(['Ticket', 'Who', 'Site', 'State', 'Messages', 'Updated'], out.rows.map((t) => [
+ html`<a href="/tickets/${t.id}">${truncate(t.subject, 70)}</a>`,
+ html`<code>${t.requester}</code>`,
+ t.site ? html`<a href="/sites/${t.site}">${t.site}</a>` : '—',
+ ticketBadge(t.state),
+ String(t.message_count),
+ time(t.updated_at),
+ ]), { empty: 'No tickets with that filter.' })}
+${out.next ? html`<p><a href="/staff?state=${encodeURIComponent(state)}${site ? `&site=${encodeURIComponent(site)}` : ''}&before=${out.next}">Older tickets</a></p>` : ''}`,
});
});
});
});
@@ -3,8 +3,11 @@
@@ -3,8 +3,11 @@
/**
/**
* Who is calling /api/v1 — req.principal:
* Who is calling /api/v1 — req.principal:
*
*
- * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
- * or this site's session cookie
+ * { kind: 'user', requester: 'user:usr_…', project: null, role, viaSession } a person: their Network token as a
+ * Bearer, or this site's session cookie.
+ * `role` is the Network role claim
+ * (user | streamer | global_mod | admin),
+ * which is what decides staff here
* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
* a Network app, agent or service token for
* a Network app, agent or service token for
* audience openvibe.help; each route names
* audience openvibe.help; each route names
@@ -20,9 +23,12 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -20,9 +23,12 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-// The product fills this in: one entry per capability its routes name, in openvibe-contracts
-// manifests/capabilities/<help>.*. requireCapability('<name>') refuses a name that is not listed here, so a
-// route can never be guarded by a capability the service does not declare.
+// One entry per capability the API's routes name, from openvibe-contracts manifests/capabilities/help.*.
+// Empty on purpose: openvibe-contracts declares no `help.*` capability yet, and OpenVibe.Help's routes are all a
+// person acting for themself (their own tickets) or staff judged by their role claim — neither needs one. The
+// moment a route exists for an app (a support widget a site embeds, say), its capability is added here and in
+// openvibe-contracts together. requireCapability('<name>') refuses a name that is not listed here, so a route can
+// never be guarded by a capability the service does not declare.
const CAPABILITIES = [];
const CAPABILITIES = [];
function decodePayload(token) {
function decodePayload(token) {
@@ -31,6 +37,14 @@ function decodePayload(token) {
@@ -31,6 +37,14 @@ function decodePayload(token) {
try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
}
}
+/** The person's Network role claim, as a plain string (an unlisted or missing one is the lowest role). */
+function roleClaim(claims) {
+ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
+}
+function usernameClaim(claims) {
+ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+}
+
function requesterOfSub(sub) {
function requesterOfSub(sub) {
if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
return sub; // app:app_…, agent:agt_…, mod:mod_…
return sub; // app:app_…, agent:agt_…, mod:mod_…
@@ -63,11 +77,11 @@ function createPrincipal({ config, keys }) {
@@ -63,11 +77,11 @@ function createPrincipal({ config, keys }) {
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
+ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), viaSession: false } };
}
}
const viewer = req.viewer;
const viewer = req.viewer;
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
- return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };
+ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, viaSession: true } };
}
}
return { principal: { kind: 'anonymous' } };
return { principal: { kind: 'anonymous' } };
}
}
@@ -22,8 +22,12 @@ const NETWORK_URL = 'https://openvibe.network';
@@ -22,8 +22,12 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Help';
const SITE_NAME = 'OpenVibe.Help';
const TAGLINE = 'Answers, support and a way to reach people.';
const TAGLINE = 'Answers, support and a way to reach people.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
-// The product's own navigation: one entry per public page it serves.
+// The product's own navigation: one entry per public page it serves. The support queue (/staff) is not here — it
+// is for staff only, and a link in everyone's navbar would be an invitation to a 403. It is linked from /tickets.
const NAV = [
const NAV = [
+ { label: 'Answers', href: '/sites' },
+ { label: 'Search', href: '/search' },
+ { label: 'Your tickets', href: '/tickets' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Search for /search and GET /api/v1/articles?q=, in two halves:
+ *
+ * searchArticles(catalog, q) the help centre's own articles — a small, explainable score over the question,
+ * the answer and the site's own text (name, one line, keywords). No index, no
+ * stemming, no ranking claim: the same query always answers the same order.
+ * createNetworkSearch(...) OpenVibe.Search's public API (GET {HELP_SEARCH_URL}/api/v1/search?q=&limit=),
+ * the network's own index of streams, clips, pastes, posts and wiki pages.
+ *
+ * Only the fixed host named by config.search.url is ever fetched, never a URL a person typed; the call carries a
+ * User-Agent, a timeout, and a 5-minute in-memory cache of the answers. A failure is reported as a failure, and
+ * the page shows the articles alone.
+ */
+const { html } = require('./render/html');
+
+/** Lower-case, accent-folded, punctuation-free — the text both sides of a comparison are reduced to. */
+function norm(text) {
+ return String(text == null ? '' : text).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
+}
+
+const STOP = new Set(['the', 'a', 'an', 'to', 'of', 'in', 'on', 'and', 'or', 'is', 'it', 'for', 'with', 'my', 'your', 'you', 'do', 'does', 'can', 'i', 'we', 'at', 'by', 'from', 'that', 'this', 'be', 'as', 'if']);
+const isTerm = (t) => t.length >= 2 && !STOP.has(t);
+const termList = (q) => [...new Set((norm(q).match(/[a-z0-9]+/g) || []).filter(isTerm))];
+const phraseOf = (q) => norm(q).replace(/\s+/g, ' ').trim();
+
+/** The site's own words, as one haystack: what /sites shows of it. */
+const siteText = (site) => [site.name, site.oneLine, site.tagline, site.keywords, site.domain].filter(Boolean).join(' ');
+
+/**
+ * How well one article answers `q`. Phrase hits beat term hits, the question beats the answer, the answer beats
+ * the site's blurb. Every rule is here, so a surprising order can be explained rather than tuned.
+ */
+function scoreArticle(article, q) {
+ const phrase = phraseOf(q);
+ const terms = termList(q);
+ if (!terms.length) return 0;
+ const question = norm(article.question);
+ const answer = norm(article.answer);
+ const site = norm(article.siteText || '');
+ let score = 0;
+ if (phrase.length >= 3) {
+ if (question.includes(phrase)) score += 10;
+ if (answer.includes(phrase)) score += 5;
+ if (site.includes(phrase)) score += 3;
+ }
+ let inQuestion = 0;
+ for (const t of terms) {
+ if (question.includes(t)) { score += 3; inQuestion++; }
+ if (answer.includes(t)) score += 2;
+ if (site.includes(t)) score += 1;
+ }
+ // Every term in the question itself: this is the question they meant to ask.
+ if (inQuestion === terms.length && terms.length > 1) score += 4;
+ return score;
+}
+
+/**
+ * The best window of `text` around the first term, for the result list. Plain text: the page escapes it, and
+ * highlights with `markTerms` (which escapes too).
+ */
+function snippetOf(text, terms, max = 220) {
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ if (s.length <= max) return s;
+ const hay = norm(s);
+ let at = -1;
+ for (const t of terms) { const i = hay.indexOf(t); if (i !== -1 && (at === -1 || i < at)) at = i; }
+ if (at === -1) return `${s.slice(0, max - 1).replace(/[\s,;:.]$/, '')}…`;
+ const start = Math.max(0, at - Math.floor(max / 3));
+ const end = Math.min(s.length, start + max);
+ return `${start ? '…' : ''}${s.slice(start, end).replace(/^[\s,;:.]|[\s,;:.]$/g, '')}${end < s.length ? '…' : ''}`;
+}
+
+/** Rank the catalog's articles for `q` (an empty query ranks nothing). */
+function searchArticles(catalog, q, { limit = 20 } = {}) {
+ const terms = termList(q);
+ if (!terms.length) return [];
+ const out = [];
+ for (const article of catalog.articles) {
+ const site = catalog.byId.get(article.site);
+ const withSite = site ? { ...article, siteText: siteText(site) } : article;
+ const score = scoreArticle(withSite, q);
+ if (score <= 0) continue;
+ out.push({ article, score, snippet: snippetOf(article.answer, terms) });
+ }
+ return out
+ .sort((a, b) => (b.score - a.score)
+ || String(a.article.siteName).localeCompare(String(b.article.siteName))
+ || String(a.article.slug).localeCompare(String(b.article.slug)))
+ .slice(0, limit);
+}
+
+/** Wrap the query's terms in <mark> — the text is matched raw and escaped piece by piece, never concatenated. */
+function markTerms(text, q) {
+ const terms = termList(q).sort((a, b) => b.length - a.length);
+ if (!terms.length) return html`${text}`;
+ const re = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
+ const parts = String(text == null ? '' : text).split(re);
+ return html`${parts.map((p, i) => (i % 2 ? html`<mark>${p}</mark>` : p))}`;
+}
+
+const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
+/** Only an absolute http(s) URL becomes a link — never javascript:, data:, or a relative guess. */
+function safeHttp(url) {
+ try {
+ const u = new URL(String(url));
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;
+ } catch { return null; }
+}
+
+/** One OpenVibe.Search result, as its API defines it, with anything unexpected dropped. */
+function pickResult(x) {
+ if (!x || typeof x !== 'object') return null;
+ const title = str(x.title, 200);
+ const url = safeHttp(x.canonical_url);
+ if (!title || !url) return null;
+ return { title, url, summary: str(x.summary, 400) || '', type: str(x.type, 40) || null, owner: str(x.owner, 60) || null };
+}
+
+/**
+ * OpenVibe.Search's public query API, cached. Returns { ok, results, cached } or { ok: false, reason } — the reason
+ * is shown to the reader, so it never pretends a failure was an empty index.
+ */
+function createNetworkSearch({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
+ const cache = new Map();
+ const url = String(config.search.url || '').replace(/\/+$/, '');
+ const ua = `OpenVibeHelp/0.1 (+${config.baseUrl})`;
+ const limit = config.search.limit;
+
+ async function fetchResults(q) {
+ const target = new URL(`${url}/api/v1/search`);
+ target.searchParams.set('q', q);
+ target.searchParams.set('limit', String(limit));
+ let res;
+ try {
+ res = await fetchImpl(target.toString(), {
+ headers: { accept: 'application/json', 'user-agent': ua },
+ signal: AbortSignal.timeout(config.search.timeoutMs),
+ });
+ } catch (err) {
+ const reason = err && err.name === 'TimeoutError' ? 'timed out' : 'did not answer';
+ throw Object.assign(new Error(`OpenVibe.Search ${reason}`), { reason });
+ }
+ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Search answered ${res.status}`), { reason: `answered ${res.status}` });
+ const data = await res.json().catch(() => null);
+ if (!data || !Array.isArray(data.results)) throw Object.assign(new Error('OpenVibe.Search sent no result list'), { reason: 'sent no result list' });
+ return data.results.map(pickResult).filter(Boolean).slice(0, limit);
+ }
+
+ /** Search the network; a hit is cached for the cache window, a failure is never cached as a hit. */
+ async function search(q) {
+ const query = String(q == null ? '' : q).trim().slice(0, 200);
+ if (!query || !url) return { ok: false, reason: 'not configured', results: [] };
+ const key = `${query.toLowerCase()}|${limit}`;
+ const hit = cache.get(key);
+ if (hit && now() - hit.at < config.search.cacheMs) return { ok: true, cached: true, results: hit.results };
+ try {
+ const results = await fetchResults(query);
+ cache.set(key, { at: now(), results });
+ if (cache.size > 500) cache.delete(cache.keys().next().value);
+ return { ok: true, cached: false, results };
+ } catch (err) {
+ log.warn(`[OpenVibe.Help] search: ${err && err.reason ? err.reason : 'failed'} (OpenVibe.Search at ${url})`);
+ return { ok: false, reason: (err && err.reason) || 'failed', results: [] };
+ }
+ }
+
+ return { search, url };
+}
+
+module.exports = { searchArticles, scoreArticle, markTerms, termList, createNetworkSearch };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * Who is staff at OpenVibe.Help: the Network role claim on the person's session (server/auth/sso.js
+ * viewerFromClaims puts `role` on req.viewer, and http/principal.js puts it on req.principal). The roles are
+ * OpenVibe.Network's (manifests/policy/staff-roles.json): `global_mod` and `admin` may read the support queue.
+ *
+ * When the Network issues the staff capability claims it already defines (`staff.console.access` covers the staff
+ * console for both roles), this is where `contracts.staff.can(claims, …)` replaces the role comparison — one place
+ * decides staff, so the pages and the API cannot drift apart.
+ */
+const STAFF_ROLES = ['admin', 'global_mod'];
+
+const roleOf = (who) => (typeof who === 'string' ? who : (who && typeof who.role === 'string' ? who.role : null));
+
+/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */
+function isStaff(who) {
+ if (who && typeof who === 'object' && who.kind && who.kind !== 'user') return false;
+ return STAFF_ROLES.includes(roleOf(who));
+}
+
+module.exports = { isStaff, STAFF_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+
+/**
+ * Tickets and their conversation in OpenVibe.Help's own database (migrations/0002_tickets.sql). Every function
+ * takes the store (server/db.js createStore) first, so the clock and the id generator are the store's.
+ *
+ * A ticket is one conversation: it is created with its first message, and every later message is a reply.
+ * `updated_at` moves on a reply, so a list can be read as "what is waiting on us".
+ */
+
+const { DEFAULT_STATE } = require('./tickets');
+
+const LIMIT_MAX = 100;
+const clampLimit = (n, def = 20) => Math.min(LIMIT_MAX, Math.max(1, Number.parseInt(n, 10) || def));
+
+const COLS = 'id, requester, site, subject, state, page_url, created_at, updated_at';
+
+/** Create a ticket and its first message in one transaction: a ticket is never empty. */
+async function create(s, { requester, site, subject, pageUrl, body, role = 'person' }) {
+ return s.tx(async () => {
+ const at = s.iso();
+ const id = s.newId('tkt');
+ await s.db.query(
+ `INSERT INTO help_tickets (id, requester, site, subject, state, page_url, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
+ [id, requester, site || '', subject, DEFAULT_STATE, pageUrl || null, at]);
+ await s.db.query(
+ 'INSERT INTO help_messages (id, ticket_id, author, role, body, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
+ [s.newId('msg'), id, requester, role, body, at]);
+ return get(s, id);
+ });
+}
+
+const get = (s, id) => s.db.maybe(`SELECT ${COLS} FROM help_tickets WHERE id = $1`, [String(id)]);
+
+/** One page of one person's tickets, newest first. */
+async function listForRequester(s, requester, { limit = 20, before = null } = {}) {
+ const n = clampLimit(limit);
+ const rows = before
+ ? await s.db.many(`SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count FROM help_tickets WHERE requester = $1 AND id < $2 ORDER BY id DESC LIMIT $3`, [requester, before, n + 1])
+ : await s.db.many(`SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count FROM help_tickets WHERE requester = $1 ORDER BY id DESC LIMIT $2`, [requester, n + 1]);
+ const page = rows.slice(0, n);
+ return { rows: page, next: rows.length > n ? page[page.length - 1].id : null };
+}
+
+/** Staff: every ticket, optionally one site and/or one state, newest first (state 'all' means no filter). */
+async function listForStaff(s, { site = null, state = null, limit = 20, before = null } = {}) {
+ const n = clampLimit(limit);
+ const where = [];
+ const args = [];
+ if (site) { args.push(site); where.push(`site = $${args.length}`); }
+ if (state && state !== 'all') { args.push(state); where.push(`state = $${args.length}`); }
+ if (before) { args.push(before); where.push(`id < $${args.length}`); }
+ const rows = await s.db.many(
+ `SELECT ${COLS}, (SELECT count(*) FROM help_messages m WHERE m.ticket_id = help_tickets.id) AS message_count
+ FROM help_tickets ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT $${args.length + 1}`,
+ [...args, n + 1]);
+ const page = rows.slice(0, n);
+ return { rows: page, next: rows.length > n ? page[page.length - 1].id : null };
+}
+
+/** What staff are looking at: how many tickets are open, waiting or solved per state (and in total). */
+async function staffCounts(s) {
+ const rows = await s.db.many('SELECT state, count(*) AS n FROM help_tickets GROUP BY state');
+ const out = { open: 0, waiting: 0, solved: 0, all: 0 };
+ for (const r of rows) { out[r.state] = Number(r.n); out.all += Number(r.n); }
+ return out;
+}
+
+const messages = (s, ticketId) => s.db.many('SELECT id, author, role, body, created_at FROM help_messages WHERE ticket_id = $1 ORDER BY id', [String(ticketId)]);
+
+/** Append a reply and move updated_at. `role` is person or staff, decided by the caller's session. */
+async function addMessage(s, ticketId, { author, role, body }) {
+ return s.tx(async () => {
+ const at = s.iso();
+ await s.db.query(
+ 'INSERT INTO help_messages (id, ticket_id, author, role, body, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
+ [s.newId('msg'), ticketId, author, role, body, at]);
+ await s.db.query('UPDATE help_tickets SET updated_at = $2 WHERE id = $1', [ticketId, at]);
+ return get(s, ticketId);
+ });
+}
+
+/** Staff set the state (open | waiting | solved). Returns the ticket, or null when it is gone. */
+async function setState(s, ticketId, state) {
+ const at = s.iso();
+ await s.db.query('UPDATE help_tickets SET state = $2, updated_at = $3 WHERE id = $1', [ticketId, state, at]);
+ return get(s, ticketId);
+}
+
+module.exports = { create, get, listForRequester, listForStaff, staffCounts, messages, addMessage, setState };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+
+/**
+ * What a ticket is, on both sides of the wire — the rules shared by the pages (http/pages.js) and the API
+ * (http/api.js), so a form and a POST cannot disagree:
+ *
+ * id tkt_<ULID> (msg_<ULID> for a message)
+ * state open | waiting | solved
+ * role person | staff — which side of the conversation wrote a message
+ * body plain text, at most 10 000 characters, shown escaped with its line breaks
+ *
+ * A page URL a person pastes is kept as text only: this service never fetches it (a caller's URL must never
+ * decide what this service requests). It is shown as a link only when it parses as http(s).
+ */
+
+const STATES = ['open', 'waiting', 'solved'];
+const DEFAULT_STATE = 'open';
+
+const LIMITS = { subject: 160, body: 10000, pageUrl: 500, site: 120 };
+
+const TICKET_ID = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const isTicketId = (id) => TICKET_ID.test(String(id || ''));
+
+/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */
+function cleanText(value, max) {
+ const s = String(value == null ? '' : value)
+ .replace(/\r\n?/g, '\n')
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
+ .trim();
+ return max != null ? s.slice(0, max) : s;
+}
+
+/**
+ * Check a new ticket. Answers { ok, fields } or { ok: false, code, detail } — one shape for the form (which
+ * re-renders with the message) and the API (which sends it as problem+json).
+ */
+function validateNew({ site, subject, message, pageUrl }, { sites = null } = {}) {
+ const fields = {
+ site: cleanText(site, LIMITS.site),
+ subject: cleanText(subject, LIMITS.subject + 1),
+ message: cleanText(message, LIMITS.body + 1),
+ pageUrl: cleanText(pageUrl, LIMITS.pageUrl + 1),
+ };
+ if (fields.subject.length > LIMITS.subject) return { ok: false, code: 'ticket.invalid', detail: `subject: at most ${LIMITS.subject} characters` };
+ if (!fields.subject) return { ok: false, code: 'ticket.invalid', detail: 'subject: what is this about? (1 to 160 characters)' };
+ if (fields.message.length > LIMITS.body) return { ok: false, code: 'ticket.invalid', detail: `message: at most ${LIMITS.body} characters` };
+ if (!fields.message) return { ok: false, code: 'ticket.invalid', detail: 'message: tell us what happened (1 to 10000 characters)' };
+ if (fields.pageUrl.length > LIMITS.pageUrl) return { ok: false, code: 'ticket.invalid', detail: `page_url: at most ${LIMITS.pageUrl} characters` };
+ if (sites && fields.site && !sites.has(fields.site)) return { ok: false, code: 'ticket.invalid', detail: 'site: pick one of the OpenVibe sites, or leave it empty' };
+ fields.pageUrl = fields.pageUrl || null;
+ return { ok: true, fields };
+}
+
+/** Check a reply. `role` is decided by the server (staff or person), never by the caller. */
+function validateMessage({ message }) {
+ const body = cleanText(message, LIMITS.body + 1);
+ if (body.length > LIMITS.body) return { ok: false, code: 'ticket.invalid', detail: `message: at most ${LIMITS.body} characters` };
+ if (!body) return { ok: false, code: 'ticket.invalid', detail: 'message: write something first (1 to 10000 characters)' };
+ return { ok: true, fields: { body } };
+}
+
+const isState = (s) => STATES.includes(String(s || ''));
+
+/** This service's own view of the page URL: a link only when it is an absolute http(s) URL (never javascript:). */
+function pageLink(pageUrl) {
+ try {
+ const u = new URL(String(pageUrl));
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;
+ } catch { return null; }
+}
+
+const subjectRef = (requester) => {
+ const i = String(requester).indexOf(':');
+ return { type: requester.slice(0, i), id: requester.slice(i + 1) };
+};
+
+/** A ticket row → the JSON the API answers with. Messages are only included when they were asked for. */
+function toWire(row, { messages = null } = {}) {
+ if (!row) return null;
+ const ticket = {
+ id: row.id,
+ requester: subjectRef(row.requester),
+ site: row.site || null,
+ subject: row.subject,
+ state: row.state,
+ page_url: row.page_url || null,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+ if (row.message_count != null) ticket.message_count = Number(row.message_count);
+ if (messages) ticket.messages = messages;
+ return ticket;
+}
+
+const toWireMessage = (row) => ({
+ id: row.id,
+ author: subjectRef(row.author),
+ role: row.role,
+ body: row.body,
+ created_at: row.created_at,
+});
+
+module.exports = { STATES, DEFAULT_STATE, LIMITS, isTicketId, cleanText, validateNew, validateMessage, isState, pageLink, toWire, toWireMessage };
@@ -20,8 +20,11 @@ const READ = 'help.api.read';
@@ -20,8 +20,11 @@ const READ = 'help.api.read';
const rosa = t.network.addUser('rosa');
const rosa = t.network.addUser('rosa');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {
- assert.deepStrictEqual(BUDGETS, {});
+ await check('the product\'s own budgets are pinned: opening a ticket and replying', () => {
+ assert.deepStrictEqual(BUDGETS, {
+ 'help.ticket.create': { minute: 6, hour: 60 },
+ 'help.ticket.message': { minute: 30, hour: 300 },
+ });
});
});
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+/**
+ * The help centre is generated, not written: /sites, /sites/:id and /a/:site/:slug are built at boot from
+ * OpenVibe.Contracts, and this test checks them against the manifests themselves — every catalog domain has a
+ * page, every product or service site that carries an `faq` gets one page per question, the text is the manifest's
+ * own, the state is the manifest's own exposure.state, and nothing read from a manifest or a URL is rendered raw.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { esc } = require('../server/render/html');
+const { boot, check, done } = require('./helpers/boot');
+
+const siteDomain = contracts.products.siteDomain;
+const PRODUCTS = contracts.products.manifests;
+const SERVICE_SITES = contracts.services.manifests.filter((m) => m.site);
+const WITH_FAQ = [
+ ...PRODUCTS.filter((p) => Array.isArray(p.faq) && p.faq.length).map((p) => ({ domain: p.domain, faq: p.faq, name: p.name })),
+ ...SERVICE_SITES.filter((m) => Array.isArray(m.site.faq) && m.site.faq.length).map((m) => ({ domain: siteDomain(m), faq: m.site.faq, name: m.site.name })),
+];
+
+(async () => {
+ const t = await boot();
+ const catalog = t.ctx.catalog;
+
+ await check('the catalog is exactly the manifests: every product domain and every service site, once each', () => {
+ const expected = new Set([...PRODUCTS.map((p) => p.domain), ...SERVICE_SITES.map(siteDomain)]);
+ const got = catalog.sites.map((s) => s.id);
+ assert.deepStrictEqual([...new Set(got)].length, got.length, 'a domain appears twice in the catalog');
+ assert.deepStrictEqual([...got].sort(), [...expected].sort(), 'the catalog and the manifests disagree');
+ assert.ok(catalog.sites.length > 30, `only ${catalog.sites.length} sites`);
+ assert.ok(catalog.sites.some((s) => s.id === 'openvibe.help'), 'the help centre itself is a site');
+ });
+
+ await check('a live site is one a service manifest exposes live; everything else is coming', () => {
+ const live = new Set();
+ for (const m of contracts.services.manifests) {
+ if (!m.exposure || m.exposure.state !== 'live') continue;
+ for (const d of m.domains || []) live.add(d);
+ if (m.site && m.site.tld) live.add(siteDomain(m));
+ }
+ for (const s of catalog.sites) assert.strictEqual(s.state, live.has(s.id) ? 'live' : 'coming', `${s.id}: ${s.state}`);
+ assert.ok(catalog.sites.some((s) => s.state === 'live') && catalog.sites.some((s) => s.state === 'coming'));
+ // Ordered live first, so a reader meets the working sites first.
+ const first = catalog.sites.findIndex((s) => s.state === 'coming');
+ if (first !== -1) assert.ok(catalog.sites.slice(first).every((s) => s.state === 'coming'), 'live and coming sites are interleaved');
+ });
+
+ await check('every site has a page at /sites/<domain>, in the manifest\'s own words', async () => {
+ for (const site of catalog.sites) {
+ const r = await t.get(`/sites/${site.id}`);
+ assert.strictEqual(r.status, 200, `${site.id} → ${r.status}`);
+ assert.ok(r.text.includes(site.name), `${site.id}: the name is not on the page`);
+ if (site.oneLine) assert.ok(r.text.includes(esc(site.oneLine.slice(0, 40))), `${site.id}: the one line is not on the page`);
+ assert.ok(r.text.includes('OpenVibe.Contracts'), `${site.id}: no provenance`);
+ }
+ });
+
+ await check('every question in every faq is a page of its own, with the question as the SEO title and the answer verbatim', async () => {
+ assert.ok(WITH_FAQ.length >= 3, `only ${WITH_FAQ.length} sites answer questions`);
+ let articles = 0;
+ for (const { domain, faq } of WITH_FAQ) {
+ const site = catalog.byId.get(domain);
+ assert.ok(site, `${domain} is not in the catalog`);
+ assert.strictEqual(site.faq.length, faq.length, `${domain}: the catalog lost a question`);
+ for (const entry of faq) {
+ const article = (catalog.bySite.get(domain) || []).find((a) => a.question === entry.question);
+ assert.ok(article, `${domain}: no article for "${entry.question}"`);
+ const r = await t.get(article.path);
+ assert.strictEqual(r.status, 200, `${article.path} → ${r.status}`);
+ // The page's own heading is the question, verbatim; the <title> is the question too — openvibe-shared/clip
+ // shortens it to 60 characters and only appends the site name when both fit.
+ assert.ok(r.text.includes(`<h1>${esc(entry.question)}</h1>`), `${article.path}: the heading is not the question`);
+ const title = (r.text.match(/<title>([^<]*)<\/title>/) || [])[1] || '';
+ assert.ok(title.startsWith(esc(entry.question.slice(0, 40))), `${article.path}: the title is not the question (${title})`);
+ if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);
+ assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);
+ assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);
+ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.ok(ld.some((n) => n['@type'] === 'Article' && n.headline && n.headline.startsWith(entry.question.slice(0, 40))), `${article.path}: no Article JSON-LD`);
+ articles++;
+ }
+ const page = await t.get(`/sites/${domain}`);
+ for (const a of catalog.bySite.get(domain)) {
+ assert.ok(page.text.includes(`id="${a.slug}"`), `${domain}: ${a.slug} has no anchor on the site page`);
+ }
+ }
+ assert.ok(articles >= 20, `only ${articles} question pages`);
+ });
+
+ await check('a service manifest\'s own site faq is used, not only a product manifest\'s', () => {
+ const openre = catalog.byId.get('openre.stream');
+ assert.ok(openre && openre.faq.length === 5, `openre.stream has ${openre ? openre.faq.length : 'no'} questions`);
+ assert.ok((catalog.bySite.get('openre.stream') || []).length === 5, 'no article pages for openre.stream');
+ });
+
+ await check('the link back to the site and to its /updates is on the site page', async () => {
+ const r = await t.get('/sites/openvibe.actor');
+ assert.ok(r.text.includes('href="https://openvibe.actor"'), 'no link to the site itself');
+ assert.ok(r.text.includes('href="https://openvibe.actor/updates"'), 'no link to its /updates');
+ });
+
+ await check('a site the catalog does not know is a 404 page, and the URL it was asked for is escaped', async () => {
+ const r = await t.get('/sites/nope');
+ assert.strictEqual(r.status, 404);
+ const bad = await t.get(`/sites/${encodeURIComponent('"<script>alert(1)</script>')}`);
+ assert.strictEqual(bad.status, 404);
+ assert.ok(!bad.text.includes('<script>alert(1)</script>'), 'the path was rendered raw');
+ assert.ok(bad.text.includes('<script>'), 'the path is not shown escaped');
+ });
+
+ await check('/api/v1/sites lists the catalog, and one site answers with its pillars, questions and source', async () => {
+ const all = (await t.get('/api/v1/sites')).json();
+ assert.strictEqual(all.count, catalog.sites.length);
+ assert.strictEqual(all.sites.length, catalog.sites.length);
+ assert.ok(all.source.name.startsWith('OpenVibe.Contracts'), all.source.name);
+ const one = (await t.get('/api/v1/sites/openvibe.actor')).json();
+ assert.strictEqual(one.id, 'openvibe.actor');
+ assert.strictEqual(one.state, 'live');
+ assert.strictEqual(one.faq.length, 8);
+ assert.ok(one.pillars.length === 4);
+ assert.ok(one.url === 'https://openvibe.actor');
+ assert.ok(Array.isArray(one.source.manifests) && one.source.manifests.length === 2, 'both manifests are named');
+ assert.strictEqual((await t.get('/api/v1/sites/nope')).status, 404);
+ assert.strictEqual((await t.get('/api/v1/sites/nope')).json().code, 'help.site.not_found');
+ });
+
+ await check('/api/v1/articles?q= ranks the articles and names where each came from', async () => {
+ const all = (await t.get('/api/v1/articles')).json();
+ assert.strictEqual(all.count, catalog.articles.length);
+ const hits = (await t.get('/api/v1/articles?q=agent')).json();
+ assert.ok(hits.count > 0, 'no hits for "agent"');
+ assert.ok(hits.articles.every((a) => a.url.startsWith('/a/')), 'an article without a URL');
+ assert.ok(hits.articles[0].score >= hits.articles[hits.articles.length - 1].score, 'not ranked');
+ assert.ok(hits.articles[0].question.toLowerCase().includes('agent') || hits.articles[0].site_name, 'the top hit is unrelated');
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * /search and GET /api/v1/articles?q=: the help centre's own articles ranked by a fixed, explainable score, and
+ * OpenVibe.Search's public API for the "From across OpenVibe" half. The network half is a local stand-in
+ * (test/helpers/search-provider.js) — the real search.openvibe.network is never called from a test — and its
+ * failure is shown as a failure, with the articles still there.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { startSearch } = require('./helpers/search-provider');
+const { searchArticles, scoreArticle, termList } = require('../server/search');
+const { createCatalog } = require('../server/catalog');
+
+/** A tiny catalog with known overlaps, for the scoring rules themselves. */
+const FIXTURE = (() => {
+ const mk = (site, siteName, slug, question, answer) => ({ site, siteName, slug, question, answer, path: `/a/${site}/${slug}` });
+ const articles = [
+ mk('s', 'Stream', 'go-live', 'Can I go live from a browser?', 'Yes. Press Go Live and allow your camera and microphone.'),
+ mk('s', 'Stream', 'restream', 'Can I restream to Twitch and YouTube?', 'Yes, ingest once and send the stream to every platform at once.'),
+ mk('o', 'Other', 'price', 'What does it cost?', 'It is free within the published limits.'),
+ ];
+ return {
+ articles,
+ byId: new Map([['s', { id: 's', name: 'Stream', oneLine: 'Streams, clips and chat', tagline: 'Streams', keywords: 'live stream', domain: 's.example' }], ['o', { id: 'o', name: 'Other', oneLine: 'Something else', tagline: '', keywords: '', domain: 'o.example' }]]),
+ };
+})();
+
+(async () => {
+ const search = await startSearch();
+ let clock = Date.UTC(2026, 9, 8, 12, 0, 0);
+ const t = await boot({ env: search.env, now: () => clock });
+
+ await check('the score: the question beats the answer, the answer beats the site blurb, and nothing unrelated', () => {
+ assert.deepStrictEqual(termList('a b of the restream'), ['restream'], 'one-letter words and stop words are not terms');
+ assert.deepStrictEqual(termList('Restream restream RESTREAM'), ['restream'], 'the same word counts once');
+ const onQuestion = scoreArticle({ question: 'Can I restream?', answer: '', siteText: '' }, 'restream');
+ const onAnswer = scoreArticle({ question: 'Can I do it?', answer: 'You can restream.', siteText: '' }, 'restream');
+ const onSite = scoreArticle({ question: 'What is this?', answer: 'It is a tool.', siteText: 'OpenVibe.Restream' }, 'restream');
+ assert.ok(onQuestion > onAnswer && onAnswer > onSite, `${onQuestion} ${onAnswer} ${onSite}`);
+ assert.strictEqual(scoreArticle({ question: 'What is this?', answer: 'It is a tool.', siteText: '' }, 'banana'), 0);
+ assert.strictEqual(scoreArticle({ question: 'What is this?', answer: '', siteText: '' }, 'a'), 0, 'a one-letter query ranks nothing');
+ const phrase = scoreArticle({ question: 'Can I go live from a browser?', answer: '', siteText: '' }, 'go live from a browser');
+ const terms = scoreArticle({ question: 'Can I go live from a browser?', answer: '', siteText: '' }, 'go browser live');
+ assert.ok(phrase > terms, 'a phrase hit is worth more than the same words scattered');
+ });
+
+ await check('the ranking over the fixture: the best question first, and only matches', () => {
+ const hits = searchArticles(FIXTURE, 'restream twitch', { limit: 10 });
+ assert.deepStrictEqual(hits.map((h) => h.article.slug), ['restream'], 'the wrong article matched');
+ // "live" is in the Stream site's own keywords, so both of its questions match and the unrelated site does not.
+ const live = searchArticles(FIXTURE, 'live', { limit: 10 });
+ assert.deepStrictEqual(live.map((h) => h.article.site), ['s', 's']);
+ assert.deepStrictEqual(searchArticles(FIXTURE, 'zzz', { limit: 10 }), []);
+ assert.deepStrictEqual(searchArticles(FIXTURE, '', { limit: 10 }), [], 'an empty query ranks nothing');
+ });
+
+ await check('the ranking over the real catalog: a real question comes back with a snippet', () => {
+ const catalog = createCatalog({ now: () => clock });
+ const hits = searchArticles(catalog, 'take control of my computer', { limit: 5 });
+ assert.ok(hits.length > 0, 'no hits for a real question');
+ assert.match(hits[0].article.question, /control/i);
+ assert.ok(hits[0].snippet.length > 0 && hits[0].snippet.length <= 240);
+ });
+
+ await check('/search?q= shows both sections: the help articles and, through the stand-in, OpenVibe.Search', async () => {
+ search.requests.length = 0;
+ const r = await t.get('/search?q=agent');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.ok(r.text.includes('Help articles'), 'no help-articles section');
+ assert.ok(r.text.includes('From across OpenVibe'), 'no network section');
+ assert.ok(r.text.includes('/a/openvibe.actor/'), 'no article link in the results');
+ assert.ok(r.text.includes('https://openvibe.live/@openvibe/actor-explained'), 'the network result is missing');
+ assert.ok(r.text.includes('OpenVibe.Actor explained'), 'the network result title is missing');
+ assert.ok(r.text.includes('<mark>agent</mark>') || /<mark>Agent<\/mark>/.test(r.text), 'the query is not marked in the results');
+ assert.strictEqual(search.requests.length, 1, 'the stand-in was called once');
+ assert.strictEqual(search.requests[0].query, 'agent');
+ assert.strictEqual(search.requests[0].limit, '8');
+ assert.strictEqual(search.requests[0].userAgent, 'OpenVibeHelp/0.1 (+https://openvibe.help)');
+ });
+
+ await check('the network answer is cached for five minutes: the same query is asked once', async () => {
+ search.requests.length = 0;
+ await t.get('/search?q=agent');
+ assert.strictEqual(search.requests.length, 0, 'a cached query called the network again');
+ clock += 4 * 60_000;
+ await t.get('/search?q=agent');
+ assert.strictEqual(search.requests.length, 0, 'the cache expired early');
+ clock += 2 * 60_000;
+ await t.get('/search?q=agent');
+ assert.strictEqual(search.requests.length, 1, 'the cache did not expire after five minutes');
+ });
+
+ await check('a query with no article match still shows the network section, and says so honestly', async () => {
+ search.requests.length = 0;
+ const r = await t.get('/search?q=fluxcapacitor');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('No article matches'), 'the articles section did not say it found nothing');
+ assert.ok(r.text.includes('OpenVibe.Actor explained'), 'the network section is missing');
+ });
+
+ await check('OpenVibe.Search failing degrades to the articles alone, and the page says what happened', async () => {
+ search.state.fail = 'error';
+ try {
+ const r = await t.get('/search?q=computer');
+ assert.strictEqual(r.status, 200, 'a failed network search must not fail the page');
+ assert.ok(r.text.includes('Help articles'), 'the articles are gone');
+ assert.ok(r.text.includes('/a/openvibe.actor/'), 'no article results');
+ assert.ok(!r.text.includes('OpenVibe.Actor explained'), 'a stale network result was shown');
+ assert.ok(/could not be reached|answered 500/.test(r.text), 'the page does not say the network search failed');
+ } finally { search.state.fail = null; }
+ const back = await t.get('/search?q=computer');
+ assert.ok(back.text.includes('OpenVibe.Actor explained'), 'the network did not recover');
+ });
+
+ await check('a timeout is a failure too, not a hang', async () => {
+ search.state.fail = 'timeout';
+ try {
+ const r = await t.get('/search?q=browser');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Help articles'));
+ assert.ok(/could not be reached/.test(r.text), 'no failure notice');
+ } finally { search.state.fail = null; }
+ });
+
+ await check('an answer without a results list is treated as a failure, not as an empty index', async () => {
+ search.state.fail = 'garbage';
+ try {
+ const r = await t.get('/search?q=stream');
+ assert.ok(/could not be reached/.test(r.text), 'a malformed answer was shown as "nothing found"');
+ } finally { search.state.fail = null; }
+ });
+
+ await check('an empty search asks nobody, and reflects the query escaped', async () => {
+ search.requests.length = 0;
+ const empty = await t.get('/search');
+ assert.strictEqual(empty.status, 200);
+ assert.strictEqual(search.requests.length, 0, 'an empty query still called the network');
+ assert.ok(!empty.text.includes('From across OpenVibe'), 'sections rendered without a query');
+ const nasty = await t.get(`/search?q=${encodeURIComponent('"<script>alert(1)</script>')}`);
+ assert.strictEqual(nasty.status, 200);
+ assert.ok(!nasty.text.includes('<script>alert(1)</script>'), 'the query was reflected raw');
+ });
+
+ await check('GET /api/v1/articles?q= answers the same ranking as JSON', async () => {
+ const r = await t.get('/api/v1/articles?q=computer');
+ assert.strictEqual(r.status, 200);
+ const body = r.json();
+ assert.ok(body.count > 0 && body.articles.every((a) => a.snippet && a.url.startsWith('/a/')));
+ assert.ok(body.articles[0].score >= (body.articles[1] ? body.articles[1].score : 0));
+ });
+
+ await t.close();
+ await search.close();
+ done();
+})().catch(async (err) => { console.error(err); try { await search.close(); } catch { /* gone */ } process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * A stand-in for OpenVibe.Search's public query API, in one local HTTP server:
+ *
+ * GET /api/v1/search?q=&limit= → { results: [{ title, summary, canonical_url, type, owner }] }
+ *
+ * Behaviour is set per test on `s.state`:
+ * results what to answer with (default: one stream and one wiki page)
+ * fail null (answer) | 'error' (HTTP 500) | 'timeout' (never answers) | 'garbage' (not JSON, no results)
+ * `s.requests` lists { query, limit, userAgent } for every call, so a test can prove what was asked, how often
+ * (the 5-minute cache), and with which User-Agent. Tests never call the real search.openvibe.network.
+ */
+const http = require('http');
+
+const DEFAULT_RESULTS = [
+ { title: 'OpenVibe.Actor explained', summary: 'A stream on how a task is routed and checked.', canonical_url: 'https://openvibe.live/@openvibe/actor-explained', type: 'stream', owner: 'openvibe' },
+ { title: 'Agent', summary: 'What an agent is on OpenVibe, and how grants bound it.', canonical_url: 'https://openvibe.wiki/agent', type: 'wiki', owner: 'openvibe' },
+];
+
+async function startSearch({ results = DEFAULT_RESULTS, fail = null } = {}) {
+ const state = { results: [...results], fail };
+ const requests = [];
+ const hanging = new Set();
+
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://x');
+ if (url.pathname !== '/api/v1/search') { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{}'); }
+ requests.push({ query: url.searchParams.get('q'), limit: url.searchParams.get('limit'), userAgent: req.headers['user-agent'] || null });
+ if (state.fail === 'error') { res.writeHead(500, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: 'index unavailable' })); }
+ if (state.fail === 'garbage') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ note: 'no results key at all' })); }
+ if (state.fail === 'timeout') { hanging.add(res); return undefined; }
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ query: url.searchParams.get('q'), results: state.results }));
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}`;
+ return {
+ url, state, requests,
+ env: { HELP_SEARCH_URL: url, HELP_SEARCH_TIMEOUT_MS: '500' },
+ close: () => new Promise((r) => { for (const res of hanging) { try { res.destroy(); } catch { /* gone */ } } server.close(r); }),
+ };
+}
+
+module.exports = { startSearch };
@@ -13,15 +13,20 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,15 +13,20 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
+//
+// The CSS budget moved with the product: the home page is now the help centre (a search box, the questions the
+// sites answer, the site grid, the ticket form), so app.css grew by the styles those need — 6.0 KB of app.css
+// against 11.6 KB of the shared showcase.css. Raised 2026-10-08 from 16.5/4.1 to 18.5/4.5 for a measured
+// 17.5/4.3. The HTML budget did NOT move: those pages fit in the skeleton home page's 24.9 KB budget with room.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
- htmlBrotliKB: 6.9, // 6.2
+ htmlRawKB: 27.5, // measured 25.3 (the home page: hero, search, popular questions, the site grid, contact)
+ htmlBrotliKB: 6.9, // 6.0
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
- cssRawKB: 16.5, // 15.0
- cssBrotliKB: 4.1, // 3.7
+ cssRawKB: 18.5, // 17.5
+ cssBrotliKB: 4.5, // 4.3
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};
@@ -45,7 +45,18 @@ function walk(dir, out = []) {
@@ -45,7 +45,18 @@ function walk(dir, out = []) {
const rel = (p, dir) => path.relative(dir, p).split(path.sep).join('/');
const rel = (p, dir) => path.relative(dir, p).split(path.sep).join('/');
const text = (p) => fs.readFileSync(p, 'utf8');
const text = (p) => fs.readFileSync(p, 'utf8');
+// This file travels into every service the generator makes — and there the placeholders it exercises are already
+// substituted, so scripts/new-service.js, which copies the tree it lives in, can only produce another copy of this
+// service rather than the sample. That is a skip, not a pass (openvibe-shared/test-runner: a skipped file is listed
+// with ○, is not counted as passed, and does not fail the run); it still runs in the skeleton itself, where the
+// placeholders exist.
+const TEMPLATE = fs.readFileSync(path.join(ROOT, 'server', 'config.js'), 'utf8').includes(token('ID'));
+
(async () => {
(async () => {
+ if (!TEMPLATE) {
+ process.stdout.write("skeleton.test.js: skipped (this tree is already a generated service: the skeleton's placeholders are gone, so the generator cannot rebuild the sample here)\n");
+ return;
+ }
const base = tmpDir('ov-new-service-');
const base = tmpDir('ov-new-service-');
const dir = path.join(base, 'openvibe-sample');
const dir = path.join(base, 'openvibe-sample');
const printed = runGenerator(dir);
const printed = runGenerator(dir);
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+'use strict';
+/**
+ * Support tickets end to end, through the API and through the pages' plain forms: a signed-in person opens one and
+ * it is a single conversation (migrations/0002_tickets.sql); only they (and staff) can read it; anyone else gets
+ * 404; anonymous is refused; a write made with the session cookie must come from openvibe.help itself; staff read
+ * the queue and set the state; bodies are escaped and capped at 10 000 characters; opening tickets is limited per
+ * caller. Tokens are never in a response, and a page URL is stored as text and never fetched.
+ */
+const assert = require('assert');
+const { signJwt } = require('./helpers/mocks');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const TKT = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT = 'Nothing happens when I press Go Live';
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const ada = t.network.addUser('ada', { role: 'admin' });
+ const gil = t.network.addUser('gil', { role: 'global_mod' });
+ const sam = t.network.addUser('sam', { role: 'streamer' });
+
+ const create = (body, o = {}) => t.get('/api/v1/tickets', { as: o.as || kim, json: body, headers: { ...SAME, ...(o.headers || {}) } });
+ /** Every write from this site: the session cookie is only accepted for a same-origin request. */
+ const write = (path, body, o = {}) => t.get(path, { as: o.as || kim, json: body || {}, headers: { ...SAME, ...(o.headers || {}) } });
+ const ticket = { subject: SUBJECT, message: 'I press Go Live on OpenVibe.Live and nothing happens.', site: 'openvibe.live', page_url: 'https://openvibe.live/broadcast' };
+ let id = null;
+
+ await check('a signed-in person opens a ticket; its description is the first message of one conversation', async () => {
+ const r = await create(ticket);
+ assert.strictEqual(r.status, 201, r.text);
+ const made = r.json();
+ assert.match(made.id, TKT);
+ id = made.id;
+ assert.strictEqual(r.headers.get('location'), `/api/v1/tickets/${id}`);
+ assert.strictEqual(made.state, 'open');
+ assert.strictEqual(made.site, 'openvibe.live');
+ assert.strictEqual(made.page_url, 'https://openvibe.live/broadcast');
+ assert.strictEqual(made.requester.type, 'user');
+ assert.ok(!('messages' in made), 'the create answer stays small');
+ const got = (await t.get(`/api/v1/tickets/${id}`, { as: kim })).json();
+ assert.strictEqual(got.subject, SUBJECT);
+ assert.strictEqual(got.messages.length, 1, 'a ticket is never empty');
+ assert.strictEqual(got.messages[0].role, 'person');
+ assert.strictEqual(got.messages[0].body, ticket.message);
+ assert.strictEqual(got.messages[0].author.type, 'user');
+ });
+
+ await check('a ticket is private: another person gets 404, staff may read it', async () => {
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: lee })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: lee })).json().code, 'ticket.not_found');
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: ada })).status, 200, 'an admin reads it');
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: gil })).status, 200, 'a global mod reads it');
+ const deep = (await t.get('/api/v1/tickets?limit=100', { as: lee })).json();
+ assert.ok(!deep.tickets.some((x) => x.id === id), "lee's list carries kim's ticket");
+ const mine = (await t.get('/api/v1/tickets', { as: kim })).json();
+ assert.ok(mine.tickets.some((x) => x.id === id), 'kim cannot see her own ticket');
+ });
+
+ await check('anonymous is refused on every ticket route; a non-staff role is not staff', async () => {
+ assert.strictEqual((await t.get('/api/v1/tickets')).status, 401);
+ assert.strictEqual((await t.get('/api/v1/tickets')).json().code, 'token.required');
+ assert.strictEqual((await t.get('/api/v1/tickets', { json: ticket })).status, 401);
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`)).status, 401);
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}/messages`, { json: { message: 'hello' } })).status, 401);
+ assert.strictEqual((await t.get('/api/v1/staff/tickets')).status, 401);
+ assert.strictEqual((await t.get('/api/v1/staff/tickets', { as: sam })).status, 403, 'a streamer is not staff');
+ assert.strictEqual((await t.get('/api/v1/staff/tickets', { as: sam })).json().code, 'staff.forbidden');
+ assert.strictEqual((await write(`/api/v1/tickets/${id}/state`, { state: 'solved' }, { as: sam })).status, 403);
+ });
+
+ await check('an app token cannot act as a person', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const app = signJwt({
+ iss: t.network.url, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.help'],
+ cap: ['help.ticket.create'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production', jti: 'tok_test_app',
+ iat: now, exp: now + 300,
+ }, t.network.privatePem);
+ const r = await t.get('/api/v1/tickets', { bearer: app, json: ticket });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'help.person_required');
+ assert.strictEqual((await t.get('/api/v1/tickets', { bearer: app })).status, 403);
+ });
+
+ await check('a session write must come from this site; a Bearer write need not', async () => {
+ const cross = await t.get('/api/v1/tickets', { as: kim, json: ticket, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'request.cross_site');
+ const bare = await t.get('/api/v1/tickets', { as: kim, json: ticket });
+ assert.strictEqual(bare.status, 403, 'no fetch metadata and no Origin: refused, as for every other write here');
+ const before = (await t.get('/api/v1/tickets?limit=100', { as: kim })).json().tickets.length;
+ const bearer = await t.get('/api/v1/tickets', { bearer: t.network.userToken(kim), json: { ...ticket, subject: 'Through a Bearer token' } });
+ assert.strictEqual(bearer.status, 201, bearer.text);
+ assert.strictEqual((await t.get('/api/v1/tickets?limit=100', { as: kim })).json().tickets.length, before + 1);
+ // A GET never needs the same-origin header: nothing changes.
+ assert.strictEqual((await t.get('/api/v1/tickets', { as: kim })).status, 200);
+ });
+
+ await check('replying: the person, staff, and nobody else', async () => {
+ const mine = await write(`/api/v1/tickets/${id}/messages`, { message: 'It happens in Chrome and Firefox both.' });
+ assert.strictEqual(mine.status, 201, mine.text);
+ assert.strictEqual(mine.json().messages.length, 2);
+ assert.strictEqual(mine.json().messages[1].role, 'person');
+ const theirs = await write(`/api/v1/tickets/${id}/messages`, { message: 'me too' }, { as: lee });
+ assert.strictEqual(theirs.status, 404, 'a stranger cannot reply');
+ const staff = await write(`/api/v1/tickets/${id}/messages`, { message: 'Which browser version?' }, { as: ada });
+ assert.strictEqual(staff.status, 201, staff.text);
+ assert.strictEqual(staff.json().messages[2].role, 'staff', 'a staff reply is marked as staff');
+ const after = (await t.get(`/api/v1/tickets/${id}`, { as: kim })).json();
+ assert.strictEqual(after.messages.length, 3);
+ assert.ok(after.updated_at >= after.created_at);
+ });
+
+ await check('a body is plain text: capped at 10 000 characters, and empty is refused', async () => {
+ const long = await create({ ...ticket, subject: 'x'.repeat(161) });
+ assert.strictEqual(long.status, 422);
+ assert.match(long.json().detail, /subject/);
+ const huge = await create({ subject: 'Big', message: 'x'.repeat(10_001) });
+ assert.strictEqual(huge.status, 422);
+ assert.match(huge.json().detail, /message/);
+ const empty = await create({ subject: 'Empty', message: ' ' });
+ assert.strictEqual(empty.status, 422);
+ const tooLongReply = await write(`/api/v1/tickets/${id}/messages`, { message: 'y'.repeat(10_001) });
+ assert.strictEqual(tooLongReply.status, 422);
+ const exactly = await create({ subject: 'Exactly ten thousand', message: 'z'.repeat(10_000) });
+ assert.strictEqual(exactly.status, 201, 'exactly the limit is allowed');
+ assert.strictEqual((await t.get('/api/v1/sites/not-a-site', { as: kim })).status, 404);
+ const unknownSite = await create({ ...ticket, site: 'evil.example' });
+ assert.strictEqual(unknownSite.status, 422, 'a site must come from the catalog');
+ assert.match(unknownSite.json().detail, /site/);
+ });
+
+ await check('staff set the state and read the queue, filtered', async () => {
+ const bad = await write(`/api/v1/tickets/${id}/state`, { state: 'closed' }, { as: ada });
+ assert.strictEqual(bad.status, 422);
+ const solved = await write(`/api/v1/tickets/${id}/state`, { state: 'solved' }, { as: ada });
+ assert.strictEqual(solved.status, 200);
+ assert.strictEqual(solved.json().state, 'solved');
+ const open = (await t.get('/api/v1/staff/tickets?state=open', { as: ada })).json();
+ assert.ok(!open.tickets.some((x) => x.id === id), 'a solved ticket is not in the open queue');
+ assert.deepStrictEqual(open.filter, { site: null, state: 'open' });
+ assert.ok(open.counts.all >= open.counts.open + open.counts.solved, JSON.stringify(open.counts));
+ const all = (await t.get('/api/v1/staff/tickets?state=all', { as: ada })).json();
+ assert.ok(all.tickets.some((x) => x.id === id && x.state === 'solved'));
+ const bySite = (await t.get('/api/v1/staff/tickets?state=all&site=openvibe.live', { as: gil })).json();
+ assert.ok(bySite.tickets.length > 0 && bySite.tickets.every((x) => x.site === 'openvibe.live'));
+ const nobody = (await t.get('/api/v1/staff/tickets?state=all&site=openvibe.food', { as: gil })).json();
+ assert.deepStrictEqual(nobody.tickets, []);
+ });
+
+ await check('the pages: /tickets lists yours only, /staff is for staff, /tickets/:id is the conversation', async () => {
+ const mine = await t.get('/tickets', { as: kim });
+ assert.strictEqual(mine.status, 200);
+ assert.ok(mine.text.includes(SUBJECT), 'the ticket is not in the list');
+ const theirs = await t.get('/tickets', { as: lee });
+ assert.ok(!theirs.text.includes(SUBJECT), "lee's page shows kim's ticket");
+ assert.strictEqual((await t.get(`/tickets/${id}`)).status, 401, 'anonymous');
+ assert.strictEqual((await t.get(`/tickets/${id}`, { as: lee })).status, 404);
+ const conversation = await t.get(`/tickets/${id}`, { as: kim });
+ assert.strictEqual(conversation.status, 200);
+ assert.ok(conversation.text.includes('I press Go Live'), 'the description is not on the page');
+ assert.ok(conversation.text.includes('Which browser version?'), 'the staff reply is not on the page');
+ assert.strictEqual((await t.get('/staff', { as: ada })).status, 200);
+ assert.strictEqual((await t.get('/staff', { as: kim })).status, 403);
+ assert.strictEqual((await t.get('/staff')).status, 401);
+ const queue = await t.get('/staff?state=all&site=openvibe.live', { as: gil });
+ assert.ok(queue.text.includes(`/tickets/${id}`), 'the queue does not link the ticket');
+ });
+
+ await check('the page form opens a ticket and lands on it; a cross-site form does not', async () => {
+ const form = await t.get('/tickets/new', { as: kim });
+ assert.strictEqual(form.status, 200);
+ assert.ok(form.text.includes('<select id="site" name="site"'), 'no site picker');
+ for (const site of t.ctx.catalog.sites.slice(0, 5)) assert.ok(form.text.includes(`value="${site.id}"`), `${site.id} is not in the picker`);
+ const made = await t.get('/tickets', { as: kim, form: { site: 'openvibe.wiki', subject: 'A wiki question', message: 'How do I cite a source?', page_url: '' }, headers: SAME });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ assert.match(made.headers.get('location'), /^\/tickets\/tkt_/);
+ const created = await t.get(made.headers.get('location'), { as: kim });
+ assert.ok(created.text.includes('How do I cite a source?'));
+ const cross = await t.get('/tickets', { as: kim, form: { subject: 'From elsewhere', message: 'nope' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 403);
+ const bad = await t.get('/tickets', { as: kim, form: { subject: '', message: '' }, headers: SAME });
+ assert.strictEqual(bad.status, 422);
+ assert.ok(bad.text.includes('<select id="site" name="site"'), 'the form is not shown again');
+ });
+
+ await check('a reply from the page goes back to the ticket, and only from this site', async () => {
+ const r = await t.get(`/tickets/${id}`, { as: kim, form: { message: 'Still happening today.' }, headers: SAME });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/tickets/${id}`);
+ assert.ok((await t.get(`/tickets/${id}`, { as: kim })).text.includes('Still happening today.'));
+ const cross = await t.get(`/tickets/${id}`, { as: kim, form: { message: 'x' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual((await t.get(`/tickets/${id}`, { as: lee, form: { message: 'x' }, headers: SAME })).status, 404);
+ const state = await t.get(`/tickets/${id}/state`, { as: ada, form: { state: 'waiting' }, headers: SAME });
+ assert.strictEqual(state.status, 303);
+ assert.strictEqual((await t.get(`/api/v1/tickets/${id}`, { as: kim })).json().state, 'waiting');
+ });
+
+ await check('a body is rendered escaped, with its line breaks kept', async () => {
+ const nasty = 'First line\n\n<script>alert(1)</script> & <b>bold</b>';
+ const r = await create({ subject: 'Escaping <script>', message: nasty });
+ assert.strictEqual(r.status, 201, r.text);
+ const page = await t.get(`/tickets/${r.json().id}`, { as: kim });
+ assert.ok(!page.text.includes('<script>alert(1)</script>'), 'the body was rendered raw');
+ assert.ok(page.text.includes('<script>alert(1)</script>'), 'the body is not shown escaped');
+ assert.ok(page.text.includes('& <b>bold</b>'), 'ampersands and tags');
+ assert.ok(!page.text.includes('<h1>Escaping <script>'), 'the subject was rendered raw');
+ assert.ok(page.text.includes('<script></h1>'), 'the subject is not shown escaped');
+ assert.strictEqual(r.json().subject, 'Escaping <script>', 'the stored text is what was typed');
+ assert.ok((await t.get(`/api/v1/tickets/${r.json().id}`, { as: kim })).json().messages[0].body.includes('First line\n\n<script>'), 'the stored body lost its line breaks');
+ });
+
+ await check('a page URL is stored and shown as text; only http(s) becomes a link', async () => {
+ const r = await create({ subject: 'With a strange page URL', message: 'Where I was.', page_url: 'javascript:alert(1)' });
+ assert.strictEqual(r.status, 201);
+ const stored = (await t.get(`/api/v1/tickets/${r.json().id}`, { as: kim })).json();
+ assert.strictEqual(stored.page_url, 'javascript:alert(1)', 'the text is kept as typed');
+ const page = await t.get(`/tickets/${r.json().id}`, { as: kim });
+ assert.ok(page.text.includes('javascript:alert(1)'), 'the text is not shown');
+ assert.ok(!page.text.includes('href="javascript:'), 'a javascript: URL became a link');
+ const good = await create({ subject: 'With a real page URL', message: 'Where I was.', page_url: 'https://openvibe.help/sites' });
+ const goodPage = await t.get(`/tickets/${good.json().id}`, { as: kim });
+ assert.ok(goodPage.text.includes('href="https://openvibe.help/sites"'), 'an https URL is not a link');
+ });
+
+ await check('no token ever comes back on a ticket page or in the database', async () => {
+ const token = t.network.userToken(kim);
+ const page = await t.get(`/tickets/${id}`, { as: kim });
+ assert.ok(!page.text.includes(token) && !page.text.includes('eyJ'), 'a token is on the page');
+ const dump = await t.dbDump();
+ assert.ok(!dump.includes(token), 'a token is in the database');
+ });
+
+ await check('the tables are help_tickets and help_messages, with tkt_ and msg_ ids', async () => {
+ const tables = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ for (const name of ['help_tickets', 'help_messages']) assert.ok(tables.includes(name), `${name} is missing (have ${tables.join(', ')})`);
+ const rows = await t.ctx.s.db.many('SELECT * FROM help_tickets WHERE subject = $1', [SUBJECT]);
+ assert.strictEqual(rows.length, 1);
+ assert.deepStrictEqual(Object.keys(rows[0]).sort(), ['created_at', 'id', 'page_url', 'requester', 'site', 'state', 'subject', 'updated_at']);
+ assert.match(rows[0].id, TKT);
+ assert.match(rows[0].requester, /^user:usr_/);
+ assert.ok(['open', 'waiting', 'solved'].includes(rows[0].state), `unexpected state ${rows[0].state}`);
+ const msgs = await t.ctx.s.db.many('SELECT id, role FROM help_messages WHERE ticket_id = $1 ORDER BY id', [rows[0].id]);
+ assert.ok(msgs.length >= 4, `only ${msgs.length} messages`);
+ assert.ok(msgs.every((m) => /^msg_[0-9A-HJKMNP-TV-Z]{26}$/.test(m.id)), 'a message id is not msg_<ULID>');
+ assert.deepStrictEqual([...new Set(msgs.map((m) => m.role))].sort(), ['person', 'staff']);
+ });
+
+ await check('opening tickets is limited per caller: 6 a minute, then 429 with Retry-After', async () => {
+ let clock = Date.UTC(2026, 9, 8, 9, 0, 15);
+ const tl = await boot({ callerLimits: true, limitsNow: () => clock });
+ const rosa = tl.network.addUser('rosa');
+ const sam2 = tl.network.addUser('sam2');
+ try {
+ for (let i = 0; i < 6; i++) {
+ const r = await tl.get('/api/v1/tickets', { as: rosa, json: { subject: `One ${i}`, message: 'body' }, headers: SAME });
+ assert.strictEqual(r.status, 201, `request ${i + 1}: ${r.text.slice(0, 120)}`);
+ }
+ const over = await tl.get('/api/v1/tickets', { as: rosa, json: { subject: 'Seven', message: 'body' }, headers: SAME });
+ assert.strictEqual(over.status, 429);
+ assert.strictEqual(over.json().code, 'rate_limited');
+ assert.ok(Number(over.headers.get('retry-after')) > 0, 'no Retry-After');
+ assert.ok(over.json().detail.includes('help.ticket.create'), over.json().detail);
+ assert.strictEqual((await tl.get('/api/v1/tickets', { as: sam2, json: { subject: 'Mine', message: 'body' }, headers: SAME })).status, 201, 'another person still opens one');
+ // The page form is the same action through another door: it shares the counter and answers HTML.
+ const form = await tl.get('/tickets', { as: rosa, form: { subject: 'From the form', message: 'body' }, headers: SAME });
+ assert.strictEqual(form.status, 429, 'the form is a way round the limit');
+ assert.match(form.headers.get('content-type'), /^text\/html/);
+ assert.ok(form.text.includes('Too many requests'), 'the form got JSON instead of a page');
+ clock += 60_000;
+ assert.strictEqual((await tl.get('/api/v1/tickets', { as: rosa, json: { subject: 'Next minute', message: 'body' }, headers: SAME })).status, 201);
+ } finally { await tl.close(); }
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });