openvibers/openvibe.help Commits

OpenVibe.Help: the help centre, search and support tickets

OP OpenVibers committed 8 Oct 2026 · fe7db86c0c · 26 files, +2282 −143

The product on top of the skeleton: a help centre generated at boot from
OpenVibe.Contracts, search over it and over OpenVibe.Search, and support tickets
that stay one conversation.

Help articles (server/catalog.js, no database, nothing hand-written): every product
manifest and every service manifest whose `site` block presents a site is read from
the pinned openvibe-contracts release, and one page is built per site (/sites,
/sites/:id) and one per FAQ entry (/a/:site/:slug, one question per page). Text is
quoted verbatim, the state is the manifest's own exposure.state, and each page names
the manifests it read with a link back. 45 sites (22 live) and 20 questions.

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 only host it calls, with a User-Agent, a timeout
and a 5-minute in-process cache. A failure is shown as a failure and the page degrades
to the articles alone.

Tickets (migrations/0002_tickets.sql, server/tickets/, /tickets, /staff): a signed-in
person opens one from the catalog's sites, sees only their own (anyone else 404s) and
replies; staff by the session's Network role claim (admin / global_mod) read the queue,
filter it and set open|waiting|solved. A page URL is stored as text and never fetched;
bodies are escaped, keep their line breaks and cap at 10 000 characters. Session writes
must be same-origin, an app token cannot act as a person, and opening and answering take
their own per-caller budgets — the page form shares the API's counters.

API: sites, articles, tickets and the staff queue under /api/v1, with the problem+json
shape. principal.js now carries the Network role claim (CAPABILITIES stays empty:
openvibe-contracts declares no help.* capability yet).

Pages all work without JavaScript; the sitemap and llms-full.txt are two renderings of
one page list so every site and question is crawlable in both. The CSS budget moved with
the product (16.5/4.1 → 18.5/4.5 KB, measured 17.5/4.3); the HTML budget did not (25.3 of
27.5 KB). Test/skeleton.test.js now skips itself in a generated service — a skip, not a
pass.

Tests: 16/17 files pass, 1 skipped (skeleton). New: help-articles, help-search (with a
stand-in for OpenVibe.Search) and tickets.

Changes

ddiff --git a/.env.example b/.env.example
iindex 5d10158..476e9c8 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bc6db45..61ea335 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 73d9445..6f6ee74 100644
---- a/README.md
++++ b/README.md
@@@ -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.
## Configuration
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
@@@ -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
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:** `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).
---
ddiff --git a/STATUS.json b/STATUS.json
iindex be435e8..4e77aa1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
ddiff --git a/migrations/0002_tickets.sql b/migrations/0002_tickets.sql
nnew file mode 100644
iindex 0000000..eec3463
---- /dev/null
++++ b/migrations/0002_tickets.sql
@@@ -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);
ddiff --git a/public/css/app.css b/public/css/app.css
iindex bd783d8..426bd01 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex f45cb63..f9e8d52 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/catalog.js b/server/catalog.js
nnew file mode 100644
iindex 0000000..9e0d81e
---- /dev/null
++++ b/server/catalog.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 12909b1..811c06a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5808eac..4c3c93a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
}
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 3d3f336..72a7348 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 684d510..1d3dab8 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0c22398..214d33d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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>` : ''}`,
});
});
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex aef8b12..df5e8e3 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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' } };
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b6c2a58..50114aa 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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' },
];
ddiff --git a/server/search.js b/server/search.js
nnew file mode 100644
iindex 0000000..727210a
---- /dev/null
++++ b/server/search.js
@@@ -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 };
ddiff --git a/server/tickets/staff.js b/server/tickets/staff.js
nnew file mode 100644
iindex 0000000..19dbf23
---- /dev/null
++++ b/server/tickets/staff.js
@@@ -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 };
ddiff --git a/server/tickets/store.js b/server/tickets/store.js
nnew file mode 100644
iindex 0000000..4c7d752
---- /dev/null
++++ b/server/tickets/store.js
@@@ -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 };
ddiff --git a/server/tickets/tickets.js b/server/tickets/tickets.js
nnew file mode 100644
iindex 0000000..b5f5594
---- /dev/null
++++ b/server/tickets/tickets.js
@@@ -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 };
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 49236ca..2045400 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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 () => {
ddiff --git a/test/help-articles.test.js b/test/help-articles.test.js
nnew file mode 100644
iindex 0000000..cb4f9ac
---- /dev/null
++++ b/test/help-articles.test.js
@@@ -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('&lt;script&gt;'), '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); });
ddiff --git a/test/help-search.test.js b/test/help-search.test.js
nnew file mode 100644
iindex 0000000..c3a8149
---- /dev/null
++++ b/test/help-search.test.js
@@@ -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); });
ddiff --git a/test/helpers/search-provider.js b/test/helpers/search-provider.js
nnew file mode 100644
iindex 0000000..48dbb36
---- /dev/null
++++ b/test/helpers/search-provider.js
@@@ -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 };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 657bd99..320c0ed 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.0
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
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
};
ddiff --git a/test/skeleton.test.js b/test/skeleton.test.js
iindex 2dc489e..2b206ba 100644
---- a/test/skeleton.test.js
++++ b/test/skeleton.test.js
@@@ -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);
ddiff --git a/test/tickets.test.js b/test/tickets.test.js
nnew file mode 100644
iindex 0000000..3442b08
---- /dev/null
++++ b/test/tickets.test.js
@@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;'), 'the body is not shown escaped');
++ assert.ok(page.text.includes('&amp; &lt;b&gt;bold&lt;/b&gt;'), 'ampersands and tags');
++ assert.ok(!page.text.includes('<h1>Escaping <script>'), 'the subject was rendered raw');
++ assert.ok(page.text.includes('&lt;script&gt;</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); });