# OpenVibe.Search > Permission-aware discovery for the network: the canonical index document, event-fed indexing, > an ACL-filtered query API, and deletion/visibility propagation. **Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` **Status:** alpha (roadmap Wave 14). Runs on the production host (127.0.0.1:4710, since 2026-09-23) and is fed through Events; on 2026-10-10 the index held, per the plan record, 1976 work jobs, 51 help articles and 70 inventory items (2097 documents) plus wiki tombstones; how many are public was not measured. **Domain:** `search.openvibe.network`, served by this repository's vhost ([deploy/nginx/](deploy/nginx/search.openvibe.network.conf); the service manifest records the search page and query API as public since 2026-09-23. Install steps: [Public host](#public-host)) **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap §4.2 A, §15.12, §29, §32.3. **License:** AGPL-3.0. ## Purpose Every publication product (Wiki, Blog, News, Reviews, Deals, Coupons, Trade) and every other service with discoverable content tells Search about its resources with one contract — [`search.index-document@1`](docs/contracts-proposal/) — and Search answers queries without ever returning a document the caller may not see. The owner stays the source of truth; Search keeps a revision-ordered copy and can be reconciled against it. ## Running it ```bash npm install cp .env.example .env npm run dev # http://127.0.0.1:4710 npm test # every test/*.test.js, a PGlite database per file, no network needed ``` Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/openvibe.search`, env `/etc/openvibe/search.env`, unit [deploy/systemd/openvibe-search.service](deploy/systemd/openvibe-search.service), database `ov_search` on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh search`; the full-text index rebuilds itself from the documents at boot), nginx [deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf) (the public vhost exposes the search page, the GET query API, saved searches and health; the owner API and the Events webhook are loopback-only, and owners and Events call `127.0.0.1:4710`). `GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the database (the documents and the full-text table) fails; a Network signing key that has not loaded (anonymous queries still answer) and a full-text index out of step with the documents table degrade it. It also reports document counts by exposure, the SDK outbox status (`enabled`, `pending`, `rejected`, `last_error`) and whether the Events webhook is on. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signals by route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outbox backlog. Request telemetry is aggregated by route template, method and status class, never by raw URL, and leaves out `/shared` files, static assets, health, readiness and `/metrics`. Once a minute, one `http.request` sample per key plus the active-requests, p95 latency and event-loop-lag gauges is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape, created by `migrations/0003_telemetry.sql`), and rows older than 14 days are deleted daily. `server/app.js` mounts the SDK's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` at production start, runs the 14-day retention and flushes on stop. ## Query API ```http GET /api/v1/search?q=solar+eclipse&owner=news&type=story&lang=en&facet.topic=space&facets=topic&limit=20&cursor=… GET /api/v1/suggest?q=sol GET /api/v1/documents/:owner/:type/:id ``` - `q` is plain text: word tokens only, every word required, a trailing `*` makes the last word a prefix. Query operators and column filters in `q` are neutralised. No `q` = newest first. - Results carry `owner, type, id, revision, visibility, title, summary, canonical_url, facets, language, authorship, provenance, published_at, updated_at, indexable` and a `snippet_html` (escaped text with ``). Never the ACL. `next_cursor` is bound to the query that made it. - `facets=k1,k2` returns value counts over exactly the visible matching set. There is no total count. - Every response is `Cache-Control: no-store`. Who sees what: | Caller | Sees | |---|---| | anonymous (or a user token without `subject_id`) | public, published, indexable documents | | Network user JWT (`ov_token` cookie or Bearer) | + restricted documents whose ACL names its subject or `role:` | | service token with `search.query.delegate` + `X-OV-Subject` | + the ACL matches of that `usr_`/`gst_` subject and the `X-OV-Groups` / `X-OV-Entitlements` the service vouches for | Drafts, unpublished, retracted and deleted documents are returned to nobody. A hidden document and a missing one give the same 404. A Bearer that does not verify is a 401 (never downgraded to anonymous); delegation headers from a browser are ignored. Visibility is enforced in the engine SQL (the full-text table split by audience plus an ACL predicate) and re-checked per hit — see [docs/adr-engine.md](docs/adr-engine.md). The search page at `/` runs the same query as HTML for browsers (no JavaScript; no-store; result pages `noindex`; anonymous unless this host has an `ov_token` cookie). Other clients get a text route index at `/`. The plain front page is the network's search front page (`server/web/page.js`, `server/web/front-data.js`): example searches, the index in numbers (public listed documents, sites sending, kinds, added or updated this week), shelves of the newest public documents of each kind (streams, blog posts, jobs, hosting plans, coupons, forum threads, help articles, places, rental cities, quests, tools; read with the same searcher and anonymous viewer as `GET /api/v1/search?owner=&type=`, so nothing private or draft can appear; community pastes, whose titles an AI writes from a picture, and placeholder titles are not shelved), the sources with what each holds, the API samples and the questions (JSON-LD WebSite with its SearchAction, WebApplication, ItemList of the sources and FAQPage). It is read once per five minutes; an empty shelf is left out, and without the numbers the sources fall back to the static list. ### Ranking Full-text results are ordered by a tier first, then full-text relevance (title ×10, summary ×4, body ×1) multiplied by a freshness boost: ``` tier = site × 10 + field field: title 3 > summary 2 > body 1 score = tier × 100 + relevance × (1 + W × 2^(−age_days / H)) W = SEARCH_FRESHNESS_WEIGHT (default 1) H = SEARCH_FRESHNESS_HALF_LIFE_DAYS (default 30) ``` A query naming a site ("openvibe live", "openvibe tools") puts that site's own records first: the site is the host `openvibe.` (subdomains too) or a help article under `/a//`, with Live's old `openre.stream` name included. A word that matches a document's owner or type is not a text match (the `owner=` filter is how an owner narrows a search), a title match always outranks a summary-only match, and a summary-only match outranks a body-only one. Within a tier, a thin document (a username or a very short title with no real summary: the recorded-stream VODs) ranks below one with a real title and summary, and one page keeps one result per title and owner. A tie in `score` is broken by Live's activity, then by recency (`documents.sort_at`, i.e. `updated_at`, else `published_at`): a channel that is live now comes before one that is not, a channel with more followers before one with fewer, and a VOD with a longer duration before a shorter one (Search is not sent views). A channel is never "thin": its handle title and service summary are normal, so the thin sink stays for the VODs it was written for. A Live channel whose only match is the service sentence " streams on OpenVibe.Live." is tied with every other such channel inside its field tier: the sentence matches all of them, and the length-normalized `ts_rank` would otherwise rank a busier channel lower because its body holds more past stream titles. A channel whose *title* matches still comes first, like any other title match. Only Live publishes an activity signal; for other owners the tie-break is just recency. `test/ranking.test.js` seeds tied channels for both rules. `age_days` counts from `published_at`, else `updated_at` (an edit does not make an old document new); a future date counts as today and a missing one gets no boost. With the defaults a document published today counts ×2.0, 30 days old ×1.5, 60 days ×1.25, 90 days ×1.125 and a year old ×1.0002: relevance still decides, and between comparably relevant documents the newer one ranks first. `W = 0` turns it off. Suggestions use the same score; browsing without `q` stays newest first. A cursor carries its first page's clock, so later pages rank on the same scores. The hybrid path fuses the keyword and vector top 100 by RRF and then applies the same tiers to the fused list (`test/freshness.test.js`, `test/ranking.test.js`). ### Saved searches ```http GET /api/v1/saved-searches POST /api/v1/saved-searches { "name": "Eclipses", "q": "solar eclipse", "owner": "news", "facets": { "topic": ["space"] } } GET /api/v1/saved-searches/:id DELETE /api/v1/saved-searches/:id GET /api/v1/saved-searches/:id/results?limit=&cursor= ``` A signed-in person (a Network user token with a `usr_` subject, as Bearer or the `ov_token` cookie) or a first-party service with `search.query.delegate` acting for `X-OV-Subject: usr_…` keeps up to `SEARCH_SAVED_MAX_PER_SUBJECT` (50) queries: words, filters or both. Saving the same query again returns the existing one (renamed if a new name is given). Only the query is stored: every run is a fresh query as that person at that moment, so what they lost access to (or what was deleted) is gone and what they gained appears. Someone else's saved search is the same 404 as a missing one; guests cannot save; a cookie-authenticated POST/DELETE must carry this origin's `Origin`. **Notifications of new matches** ([server/saved-notifier.js](server/saved-notifier.js)) are off unless `SEARCH_SAVED_NOTIFY=1`; they also need `OV_OAUTH_CLIENT_SECRET` (the Network OAuth client `search`, client credentials) and stay off without it, which `/api/ready` reports as `saved_notify: "off (…)"`, never as a failure. Every `SEARCH_SAVED_NOTIFY_INTERVAL_MS` (15 min) the notifier takes up to `SEARCH_SAVED_NOTIFY_BATCH` (100) saved searches, longest waiting first, and runs each as its owner for documents indexed (a new revision) after its watermark, `last_run_at` (else `created_at`). A search with new hits gets one notification through OpenVibe.Network via [server/network-push.js](server/network-push.js), an adapter over `openvibe-sdk/notifications` (type `SEARCH_SAVED_MATCH`, service `search`, category `service`: how many, the top title, a link to the search page with that query); the person is resolved to Network's user id first (`GET /internal/identity/resolve`, then `POST /internal/notifications/push`). The watermark moves to the tick's start only once the push went out (or there was nothing new, or Network no longer knows the person): a failed push is retried on the next tick and a delivered batch is never notified twice. Opening the results moves the watermark too, so what the person has seen is not announced. Network must grant the principal `search` `network.notifications.push` (for its own service) and `identity.subject.resolve`. The Network OAuth client (`search`) is provisioned on Network with `node server/setup/service-principal.js create search --env-file /etc/openvibe/search.env` (run in the OpenVibe.Network checkout with its `DATABASE_URL`, under sudo so the env file is written 0600; then restart Search so it reads `OV_OAUTH_CLIENT_ID` and `OV_OAUTH_CLIENT_SECRET`), and saved-search notifications are switched on here with `SEARCH_SAVED_NOTIFY=1`; until someone signs in that way, saved searches are used through Bearer tokens or a product acting for its visitor. ## Search Fabric One search over several scopes, routed the cheapest way that answers (`search.fabric.query`): | Scope | What it reads | |---|---| | `network` | Search's own index, with the same visibility rules as `GET /api/v1/search` | | `mine` | the same search kept to the caller's non-public documents (needs a signed-in person) | | `web` | OpenVibe's open index, then the paid/free web providers in a ladder | | `news`, `video`, `products`, `places`, `code` | facets of the same index, narrowed to the owner and type Search holds | | `images`, `people` | nothing yet: `skipped: "not indexed yet"` in `route.explain` | The web ladder is `open_index` first (free, the configured `SEARCH_OPEN_INDEX_OWNERS`), then `deepseek` (paid), then `wikipedia` (free). Every step says what happened in `route.explain`: `ok`, `count`, `ms`, `cached`, `cost_usd`, or `skipped` with the reason. Depth `quick` reads the index and the cache only; `standard` stops climbing once it has `min(limit, 3)` results; `deep` runs every step and merges them, de-duplicating by URL. Only a signed-in person, or a first-party service, may trigger a paid provider. Anonymous callers get the open index and cached answers; the cache key is the normalized query, scope, step, freshness and site, so the same question typed two ways is one entry. DeepSeek calls are capped per day (`SEARCH_WEB_DAILY`, default 500); past the cap the step is skipped with `daily cap reached`. Cache TTLs: `day` freshness 1 h, `week` 6 h, otherwise 24 h. ```http POST /api/v1/query { "q": "solar eclipse", "scopes": ["network", "web"], "depth": "standard" } GET /api/v1/query?q=solar+eclipse&scopes=network,web GET /api/v1/query/providers ``` The GET query string takes `q`, `scopes` (comma-separated), `freshness`, `depth`, `limit`, `cursor`, `owner`, `type`, `lang` and `site`. GET and `/providers` answer `Access-Control-Allow-Origin: *` (anonymous rules apply); POST does not. Results are grouped in the requested scope order, at most `limit` per scope and 100 in all; `citations` number the web results and `next_cursor` pages the network or mine scope when it was the only one asked. The answer never contains the query text. ```json { "results": [ { "scope": "network", "kind": "document", "title": "Eclipse timings", "url": "https://openvibe.news/s/1", "site": "openvibe.news", "owner": "news", "type": "story", "id": "story_1" }, { "scope": "web", "kind": "page", "title": "Solar eclipse", "url": "https://en.wikipedia.org/wiki/Solar_eclipse", "site": "en.wikipedia.org", "provider": "wikipedia", "retrieved_at": "2026-10-28T12:00:00.000Z" } ], "groups": { "network": 1, "web": 1 }, "answer": null, "citations": [{ "n": 1, "url": "https://en.wikipedia.org/wiki/Solar_eclipse", "title": "Solar eclipse", "provider": "wikipedia", "retrieved_at": "2026-10-28T12:00:00.000Z" }], "route": { "explain": [ { "scope": "network", "step": "index", "ok": true, "count": 1, "ms": 4 }, { "scope": "web", "step": "open_index", "ok": true, "count": 0, "ms": 3 }, { "scope": "web", "step": "deepseek", "ok": false, "count": 0, "skipped": "paid providers need a signed-in person" }, { "scope": "web", "step": "wikipedia", "ok": true, "count": 1, "ms": 190, "cost_usd": 0 } ], "cost_usd": 0 }, "next_cursor": null } ``` Asking `"answer": true` answers `answer: null` and adds an `answer` explain row: answers are not on yet. ## Code search `GET /api/v1/search/code?q=&lang=&repo=&licence=&limit=&cursor=` merges public code from GitHub (`GITHUB_CODE_TOKEN`; 10 API requests/minute), Sourcegraph's public stream and our own forge (public repositories only; one `git grep` per repository, because the forge has no global code route). The same file at the same commit is one result; exact identifier matches rank first, then stars and recency. Every result is text with a `permalink` to the source's own https host; a private repository is dropped by the source, never just hidden at display. At most 50 results per page; `next_cursor` is bound to the filters. The route is public and limited per address/person (30 a minute, 600 an hour). `POST /api/v1/search/code/deep` `{q, lang?, repo?, licence?}` is for a signed-in person. A cheap model rewrites the question into at most five code queries, the top files are fetched, and a second cheap model answers with `file:line` citations. The budget is two AI runs per question, at most `SEARCH_CODE_DEEP_FILES` files and `SEARCH_CODE_DEEP_TOKENS` answer tokens; the response carries the runs' real tokens and `cost_usd`. It is `503 search.ai_unavailable` when the AI client secret or `SEARCH_CODE_AI_URL` is unset, and `SEARCH_CODE_DEEP=off` turns it off. AI runs are the metered `ai.complete` path (the default route is chosen by price and health, so the cheap model answers first). The sources are a small registry: one module per source with a manifest, a per-minute quota, health and an on/off switch. The owner console at `/console/code-sources` (Network `role:admin`/`role:owner`) shows each source's switch, key **name** (never its value), quota and health; the JSON is `GET /api/v1/code/sources` for a service token with `search.code.sources`, and `PUT /api/v1/code/sources/:id` `{enabled}` flips it. Switches live in `code_source_settings` (migration 0009) and survive a restart. How to add a source, with the test it must pass: [docs/code-sources.md](docs/code-sources.md). ## MCP The same fabric is a [Model Context Protocol](https://modelcontextprotocol.io) server over Streamable HTTP at **`https://search.openvibe.network/mcp`** (JSON-RPC 2.0, protocol revision 2025-06-18). It is stateless: one `POST`, one answer, no session id. `GET` and `DELETE` answer 405. | Tool | What it does | |---|---| | `openvibe_search` | one search over `network`, `mine`, `web`, `news`, `video`, `products`, `places` and `code`; results grouped by scope with `route.explain` and cost | | `openvibe_search_providers` | the web ladder's providers, with price per call and whether each is available | Add it to an MCP client: ```json { "mcpServers": { "openvibe-search": { "type": "http", "url": "https://search.openvibe.network/mcp" } } } ``` Anonymous callers search the public network and the open web index. A Bearer token (a Network user token) adds the caller's own `mine` scope and lets the `web` scope reach DeepSeek; it is also the person the fabric's visibility rules apply to. Each `tools/call` shares the fabric's per-actor `search.fabric` limit (30 a minute, 600 an hour); `initialize` and `tools/list` are not limited. ## Indexing Owners call with an OpenVibe.Network client-credentials token (`audience=openvibe.search`) holding `search.document.write`, and may only touch documents whose `owner` is their own service slug. ```http PUT /api/v1/documents/wiki/page/pg_123 { …search.index-document@1… } DELETE /api/v1/documents/wiki/page/pg_123?revision=8 GET /api/v1/owners/wiki/documents?type=page&after=0&limit=500 # reconciliation GET /api/v1/owners/wiki/documents/page/pg_123 # stored doc + effective indexability GET /api/v1/owners/wiki/rejections # refused index events ``` Or, preferred, through Events: publish `wiki.index_document.upserted` (payload = the document) or `wiki.index_document.deleted` (payload = `{type, id, revision}`) from the owner's outbox with `visibility: "internal"`. Search subscribes to `*.index_document.*` (`npm run subscribe`) and consumes deliveries at `POST /internal/events`: HMAC-verified (`SEARCH_EVENTS_SECRET`), one inbox receipt per event in the same transaction as the change, so a redelivery never applies twice. A source may only write its own owner's documents (`SEARCH_EVENT_OWNERS` limits which sources feed the index at all). Revision order, on both paths: an older revision is `stale` and changes nothing; the same revision again is `unchanged`; different content at the same revision is a `conflict` (first write wins); at equal revision a deletion wins; a tombstone keeps the revision, so a late older upsert cannot bring the document back; a higher revision restores it. Search only makes indexability stricter (drafts, private/members/unlisted, unpublished, stub-provider provenance and public documents without a canonical URL are always `noindex`, with the reason recorded). Every change that lowers a document's exposure (deleted, drafted, unpublished, made private/members/unlisted, or public → noindex) leaves results in the same transaction and emits `search.document.removed` (with the canonical URL if it had been public, so caches and sitemaps purge); every accepted upsert emits `search.document.indexed`. Both go through a transactional outbox (`openvibe-sdk/events`, table `service_outbox`) relayed to Events when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Pending rows wait when the relay is off. ### Removals, caches and the CDN Search consumes its own removals ([server/purge.js](server/purge.js)); in the same transaction as the `search.document.removed` event it records: - **the owner's removal feed** — `GET /api/v1/owners/:owner/removals?after=&limit=` (owner token, own owner only): `seq, event_id, type, id, revision, reason, previous_exposure, exposure, canonical_url, at`. Products own their pages, caches and sitemaps, so they poll this feed (or subscribe to `search.document.removed`) and drop the document from anything derived they keep. - **a Cloudflare purge queue** — only when `CLOUDFLARE_PURGE_TOKEN` is set (inert otherwise: nothing is queued or sent). For a removal whose URL had been public, the canonical URL and the `CLOUDFLARE_PURGE_RELATED_PATHS` on its host (default `/sitemap.xml`) are queued once each and purged by URL (`POST /zones/:zone/purge_cache {"files": […]}`, at most 30 per call) in the zone `CLOUDFLARE_ZONE_IDS` maps the host (or its nearest parent domain) to. A host with no zone is recorded as `skipped`. 429, 5xx and network errors back off and retry (8 attempts); a refusal is retried one URL at a time and then marked `failed`. The token needs only *Zone → Cache Purge → Purge* on those zones; it is read from the environment, sent only in the Authorization header, and never stored or logged. `/api/ready` reports the purge state and counts, `/metrics` the `search_cdn_purges{state}` gauge (`test/purge.test.js`, against a mocked Cloudflare API). ## Owns - the index document contract (`search.index-document@1`, released in openvibe-contracts v0.12.0; proposal in [docs/contracts-proposal/](docs/contracts-proposal/)) - `documents`, `doc_acl`, `doc_facets`, `search_fts` (the full-text index), `idempotency_receipts`, `ingest_rejections`, `service_outbox` (the SDK outbox), `removals`, `cdn_purges`, `saved_searches` (PostgreSQL, schema in [migrations/](migrations/); engine decision in [docs/adr-engine.md](docs/adr-engine.md)) - `event_outbox` remains for the N-1 rollback window; the expand migration copies its pending rows to `service_outbox`. - the query API, its ranking and ACL filtering, the search page and saved searches; deletion and visibility-change propagation, including the CDN purge of removed public URLs ## Does not own - any product's content, publication state or indexability decision (owners and the publishing packages do) - sitemaps, feeds or page caches (each product; they read the removal feed or listen to `search.document.removed`). Search only purges the CDN copy of a removed public URL. - identity, groups or entitlements (Network, products, VIP/Billing — Search only matches the keys it is given) ## Depends on - OpenVibe.Contracts (service tokens, problem details, ids, the event envelope) - PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): documents and the full-text index, async through `openvibe-sdk/db` - OpenVibe.Network (signing key; service principal `search`) - OpenVibe.Events (index-document deliveries in, `search.document.*` out) ## Capabilities (released in openvibe-contracts v0.12.0; proposal in [docs/capabilities-proposal/](docs/capabilities-proposal/)) Implemented here (the service manifest's `capabilities`): | Capability | Routes | |---|---| | `search.document.write` | `PUT/DELETE /api/v1/documents/:owner/:type/:id`, `/api/v1/owners/:owner/...` including the removal feed (own owner only) | | `search.query.delegate` | the query and saved-search routes, acting for `X-OV-Subject` | | `search.query.run` | `GET /api/v1/search`, `/api/v1/suggest`, `/api/v1/documents/:owner/:type/:id` as anyone: anonymous or with a person's Network token (no grant needed; a service token must hold `search.query.delegate`) | | `search.code.sources` | `GET/PUT /api/v1/code/sources`, `GET/POST /console/code-sources` (owner console) | Called elsewhere, as the service principal `search` (client credentials from OpenVibe.Network): | Service | Grant | Why | |---|---|---| | OpenVibe.Events | `events.event.publish` (audience `openvibe.events`) | the outbox relays `search.document.indexed` / `search.document.removed` | | OpenVibe.Events | `events.subscription.manage` (`npm run subscribe`, once) | the HMAC-signed subscription to `*.index_document.*` that delivers index events | | OpenVibe.Network | `identity.subject.resolve` (audience `openvibe.network`) | saved-search notifications: a `usr_` subject to Network's user id (only with `SEARCH_SAVED_NOTIFY=1`) | | OpenVibe.Network | `network.notifications.push` (audience `openvibe.network`, own service `search`) | saved-search notifications: one notification per saved search with new hits | | OpenVibe.AI | `ai.run.create` (audience `openvibe.ai`, namespace `ai`) | deep code search: two `ai.complete` runs per signed-in question | Registered in `openvibe-contracts` with the service manifest (this repo pins v0.96.0); [server/auth.js](server/auth.js) decides them with the contracts grant rule, and CI runs `openvibe-contracts-check --service search`. ### Per-actor limits Every API route also limits who calls it, after its credential and before any work ([server/actor-limits.js](server/actor-limits.js), openvibe-sdk/limits, roadmap WS-R task 4): the service principal on owner routes; on query and saved-search routes the person (their own token, or the `X-OV-Subject` a delegating service vouches for), else the address. A service querying for its signed-out visitors is not counted (it speaks for all of them). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and `search_rate_limited_total{limit,window}`. | Route | Per caller | |---|---| | Queries, direct gets, owner reconciliation reads, saved-search reads and runs | `SEARCH_LIMITS_MINUTE` / `SEARCH_LIMITS_HOUR` (120 a minute, 3000 an hour) | | `POST/GET /api/v1/query` (`search.fabric`, a paid-provider ladder) | 30 / 600 | | `GET /api/v1/query/providers` | `SEARCH_LIMITS_MINUTE` / `SEARCH_LIMITS_HOUR` | | `GET /api/v1/suggest` | 300 / 6000 (a search box as the person types) | | `PUT/DELETE /api/v1/documents/…` | 120 / 1200 (a catalog sync is a burst; bulk content comes through Events) | | Saved-search create, delete | 30 / 300 | Never limited: the signed Events deliveries (`POST /internal/events`), `/api/health`, `/api/ready`, `/release.json`, `/metrics`; the HTML page keeps nginx's address limits. `test/actor-limits.test.js`. ## Acceptance (tests) - a private or draft document is never returned to an unauthorized query — hits, snippets, facet counts, suggestions, direct gets, cursors and full-text-syntax injection (`test/acl-leak.test.js`) - a visibility change or deletion removes the document from results immediately and emits `search.document.removed` (`test/acl-leak.test.js`) - revision ordering, tombstones, restore, reconciliation (`test/documents.test.js`) - signed deliveries, inbox dedupe, out-of-order delivery, crash before commit redelivers once, owner spoofing refused (`test/webhook-inbox.test.js`) - outbox relay to Events, retry and poison isolation (`test/outbox.test.js`) - proposals valid against the contracts schemas (`test/proposals.test.js`) - removals reach the owner feed; the CDN purge is inert without a token and, with one, purges the formerly public URL and its sitemap per zone, retries, isolates refusals and never stores the token (`test/purge.test.js`) - freshness decay, newer-first between equals, relevance over age, stable cursors (`test/freshness.test.js`) - saved searches: signed-in only, own only, run-time ACL, Origin check for cookies, limits (`test/saved-searches.test.js`) - the Search Fabric: network visibility, mine for a person, the web ladder, cache hits, the daily cap, provider offers and GET/POST parity (`test/fabric.test.js`) - saved-search notifications: one push per search with new hits, as its owner, the watermark only after a push, a failed push retried, never twice, off without the flag and the secret (`test/saved-notify.test.js`, `test/network-push.test.js`) - the search page: public only, escaped, no-store, noindex results, paging (`test/page.test.js`) - code search: three sources merged and de-duplicated, exact identifier first, filters and cursor, private repositories dropped, https permalinks, no key in an answer (`test/code-search.test.js`, `test/code-sources.test.js`); deep mode signed-in, two AI runs at most, cited answer, real cost, AI failure never partial (`test/code-deep.test.js`); the owner switch and console (`test/code-console.test.js`) - per-actor limits: 429 `rate_limited` with Retry-After per principal, person or address, another caller unaffected, deliveries and health never limited (`test/actor-limits.test.js`) As of 2026-10-10, work, help and inventory documents are indexed in production. The first wiki upserts were rejected (`owner_not_accepted`, since fixed), and only the wiki tombstones landed. Wiki pages enter the index once a person reviews them; Sources has every seed disabled. The removal feed and the CDN purge queue exist, but no product polls the feed yet and the purge needs a token with Cache Purge permission (the current Cloudflare token has DNS and rulesets scope only). Restore drill: `ovhost drill search` passed on the production host on 2026-09-23 (integrity check, readiness, identical query answers, row counts; see OpenVibe.Host `docs/restore-drills.md`). ## Security Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps: - **Auth.** Tokens are RS256 JWTs signed by OpenVibe.Network (verified through the SDK's JWKS client at `/api/.well-known/jwks`): service tokens need audience `openvibe.search` and the capability for the route, and an owner may only write documents under its own service slug. A Bearer that does not verify is a 401, never anonymous. Delegation headers (`X-OV-Subject`, `X-OV-Groups`, `X-OV-Entitlements`) count only with a `search.query.delegate` service token. A cookie-authenticated write must carry this origin's `Origin`. - **Private data.** Visibility is enforced in the engine SQL and re-checked per hit; drafts, unpublished, retracted and deleted documents reach nobody, a hidden document is the same 404 as a missing one, results never carry the ACL, and every response is `no-store`. Saved searches store the query only. - **Network exposure.** The owner API, `/internal/events` and document writes are loopback-only in the vhost; `/metrics` answers direct loopback callers only; client addresses come from `$remote_addr`. - **Egress.** Search calls only OpenVibe.Network (keys, tokens, and with `SEARCH_SAVED_NOTIFY=1` identity resolve and notification push), OpenVibe.Events (`EVENTS_URL`), when `CLOUDFLARE_PURGE_TOKEN` is set the Cloudflare purge API, the three code-source hosts (`api.github.com`/`raw.githubusercontent.com`, `SOURCEGRAPH_URL`, `FORGE_URL`), and OpenVibe.AI (`SEARCH_CODE_AI_URL`) for deep code search. It never fetches a URL a user chose. - **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `SEARCH_EVENTS_SECRET`, `CLOUDFLARE_PURGE_TOKEN`, `GITHUB_CODE_TOKEN`, `SOURCEGRAPH_TOKEN` and `FORGE_TOKEN` come from the environment (`/etc/openvibe/search.env`, 0600) and are never stored, returned or logged; the owner console shows the variable name. A GitHub token for the code source should be a fine-grained public-only token. Event deliveries are HMAC-verified before anything is read. ## Deploy Production deploys with `sudo ovhost deploy search` on the host (strategy `git-checkout`: fetch, fast-forward `/opt/openvibe.search`, install on a lockfile change, restart, wait for `/api/ready`). | | | |---|---| | Checkout | `/opt/openvibe.search` | | Unit | `openvibe-search.service` ([deploy/systemd/](deploy/systemd/openvibe-search.service)), runs as `ubuntu` | | Port | `127.0.0.1:4710` | | Env file | `/etc/openvibe/search.env` | | Data | PostgreSQL `ov_search` (`DATABASE_URL` through PgBouncer; migrations on `DATABASE_DIRECT_URL`) | | Public host | nginx [deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf) (below) | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the restart; afterwards `sudo ovhost rollback search --to `. Nothing blocks a rollback: the schema code only adds tables and columns, which an older release ignores. ### Public host `search.openvibe.network` resolves through Cloudflare; without this vhost nginx answers it with its default site (the admin placeholder). The vhost ([deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf)) uses the Network wildcard certificate, sets the client-address headers from `$remote_addr` only, answers `/metrics` with 404, keeps `/internal/`, `/api/v1/owners/` and document writes loopback-only, and answers every unknown path with a JSON 404. Install on the host, after the service runs a release that has the search page and saved searches (`ovhost deploy search`): ```bash sudo install -m 0644 /opt/openvibe.search/deploy/nginx/search.openvibe.network.conf /etc/nginx/sites-available/search.openvibe.network.conf sudo ln -sf /etc/nginx/sites-available/search.openvibe.network.conf /etc/nginx/sites-enabled/search.openvibe.network.conf sudo nginx -t && sudo systemctl reload nginx # check curl -sS https://search.openvibe.network/api/health curl -sS -H 'Accept: text/html' https://search.openvibe.network/ | grep -o '[^<]*' curl -sS -o /dev/null -w '%{http_code}\n' https://search.openvibe.network/metrics # 404 curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://search.openvibe.network/internal/events # 403 curl -sS -o /dev/null -w '%{http_code}\n' https://search.openvibe.network/api/v1/owners/wiki/removals # 403 ``` For the CDN purge, create a Cloudflare API token with only *Zone → Cache Purge → Purge* on the product zones, then add `CLOUDFLARE_PURGE_TOKEN` and `CLOUDFLARE_ZONE_IDS` to `/etc/openvibe/search.env` (0600) and restart `openvibe-search`; `/api/ready` then shows `purge.cdn: "cloudflare"`. ## Launch rule This repository does not make the product real, and the domain kept its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until: an owning runtime with health/readiness; Network identity and service principals; a public route useful without JavaScript (the search page at `/`); real persistence and end-to-end workflows; capabilities and events registered in OpenVibe.Contracts; a security review; and acceptance tests. The domain is not in `OpenVibe.Sites/sites.json`; this repository's own vhost serves it. --- Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). <!-- versions:start --> - openvibe-contracts: v0.419.0 - openvibe-sdk: v0.161.0 - openvibe-shared: v4.238.0 <!-- versions:end -->