openvibers/openvibe.inventory README.md
README.md
| 1 | # OpenVibe.Inventory |
| 2 | |
| 3 | > Your items, on every OpenVibe site. |
| 4 | |
| 5 | **Live site:** https://openvibe.my/inventory · **API docs:** https://openvibe.services · **For AI:** https://openvibe.my/inventory/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha (ADR-054, plan track T21 step 2). The authority, its API, pages and Live's migration script are built |
| 9 | and tested; Live switches to it next. |
| 10 | **Domain:** `inventory.openvibe.network` · **Port:** 5030 · **Service id:** `inventory` · **Env prefix:** `INVENTORY` |
| 11 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 12 | |
| 13 | One inventory for an OpenVibe account, the way Steam's works across games. Sites, games and apps give items for what |
| 14 | people do; people keep them in one place and wear them on every site that shows their kind. |
| 15 | |
| 16 | ## Purpose |
| 17 | |
| 18 | OpenVibe.Inventory is the network-wide inventory for an OpenVibe account (ADR-054): one place where the items people |
| 19 | earn or are given across every site, game and app are held. It registers item kinds (a contract plus a renderer per |
| 20 | surface), the definitions an issuer publishes and the instances a person owns, and keeps what each person has equipped. |
| 21 | Every movement is one append-only ledger row and one `inventory.*` event, written in the same transaction as the change. |
| 22 | Nothing is sold, bought, traded or converted into OpenCoins or Vibes (ADR-054 §5). |
| 23 | |
| 24 | ## Owns |
| 25 | |
| 26 | Its own PostgreSQL database (`ov_inventory`) and everything in it: the tables `inventory_kinds`, `inventory_definitions`, |
| 27 | `inventory_definition_aliases`, `inventory_instances`, `inventory_grants`, `inventory_equipped` and `inventory_ledger` |
| 28 | (see [migrations/](migrations/)), plus the SDK's `event_outbox` (events not yet sent) and `account_data_events` (applied |
| 29 | export and deletion receipts). It is the authority for item kinds, definitions, instances, grant receipts, the equipped |
| 30 | set and the ledger. Nothing written there is ever a credential, a key or a token. |
| 31 | |
| 32 | Requests are aggregated by route template, method and status class, never by raw URL; `/shared`, static assets, health, readiness and |
| 33 | `/metrics` are excluded. Once a minute one `http.request` sample per key plus the active-requests, p95 latency and event-loop-lag gauges go |
| 34 | to `telemetry_samples` (openvibe-sdk 0.46, `platform.telemetry-sample@1`, migration `0005_telemetry.sql`); rows older than 14 days are |
| 35 | deleted daily. Wired in `server/app.js` (`createHttpTelemetry`, middleware) and `server/index.js` (flush each minute, on stop, retention). |
| 36 | |
| 37 | ## Does not own |
| 38 | |
| 39 | - **Identity** is OpenVibe.Network's: it issues the `usr_` subjects, usernames and Network tokens, and resolves an |
| 40 | `@name` for the Workshop. Inventory stores only the subject. |
| 41 | - **Files** are OpenVibe.Media's: a Workshop badge image is uploaded to this service's Media tenant and referenced by its |
| 42 | `med_` id; Inventory keeps only the id and the metadata, never the bytes. |
| 43 | - **Event delivery** is OpenVibe.Events': the outbox relay publishes Inventory's events there and the two account |
| 44 | subscriptions are created there. |
| 45 | - **OpenVibe.Live's own cosmetics tables** (`user_cosmetics`, `user_equipped`) stay Live's; the migration script only |
| 46 | reads them (ADR-054 §8). |
| 47 | |
| 48 | ## Depends on |
| 49 | |
| 50 | - **OpenVibe.Network** — sign-in (OAuth2 authorization code + PKCE, client `inventory`), the JWKS signing key and |
| 51 | `GET /internal/identity/resolve` for `@name`: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, |
| 52 | `OV_OAUTH_CLIENT_SECRET`. |
| 53 | - **OpenVibe.Events** — the outbox relay publishes `inventory.*` and the account-export/deletion subscriptions are |
| 54 | created there: `EVENTS_URL`, `INVENTORY_EVENTS_SECRET`, `EVENTS_RELAY_INTERVAL_MS`. |
| 55 | - **OpenVibe.Media** — Workshop image uploads to this service's tenant use its Network service token |
| 56 | (`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`): `OV_MEDIA_INTERNAL_URL`. Network grants |
| 57 | `[inventory, media.object.upload, openvibe.media]` on namespace `inventory`. |
| 58 | - **PostgreSQL** (`DATABASE_URL` to serve, `DATABASE_DIRECT_URL` to migrate) and, optionally, **Valkey** for shared |
| 59 | per-caller limit counters (`VALKEY_URL`). |
| 60 | - **Packages** — `openvibe-contracts` and `openvibe-sdk` (auth, db, events, account-data, limits, service, valkey), |
| 61 | `openvibe-shared` (shell, frame, legal, seo, serve, showcase, cache-policy, metrics, ready, release), plus Express, |
| 62 | helmet, ajv, busboy, pg/PGlite and iovalkey. |
| 63 | |
| 64 | ## Capabilities |
| 65 | |
| 66 | Its manifest (openvibe-contracts) registers: `inventory.item.read`, `inventory.item.list`, `inventory.equip.manage`, |
| 67 | `inventory.item.grant`, `inventory.item.consume`, `inventory.definition.manage` and `inventory.definition.review` (the |
| 68 | staff review route). |
| 69 | |
| 70 | It calls, on other services: `events.event.publish` and `events.subscription.manage` (OpenVibe.Events; the outbox relay |
| 71 | and the account subscriptions) and `identity.subject.resolve` (OpenVibe.Network; resolving a creator's `@name`). |
| 72 | |
| 73 | ## The model (ADR-054) |
| 74 | |
| 75 | - **Kind** (`live.name_effect`, `live.hat`, …): what its items carry (a JSON Schema), where one is equipped (slots) and |
| 76 | which surfaces render it (chat, overlays, the profile, `game:<id>`). A new kind is a contract plus a renderer, never a |
| 77 | new table. Kinds are registered at boot from [server/data/kinds.json](server/data/kinds.json). |
| 78 | - **Definition** (`itd_…`): one item of a kind, with a name, art (a Media image, a renderer token, an emoji), an honest |
| 79 | rarity (common, uncommon, rare, epic, legendary), attributes valid against the kind, its issuer and an optional |
| 80 | supply cap. Rarity, attributes and the cap are fixed once it is published. |
| 81 | - **Instance** (`inv_…`): one definition owned by one person, with its origin (granted, earned, migrated), its state |
| 82 | (owned, consumed, revoked) and, under a cap, a serial number. |
| 83 | - **Equipped:** one instance per kind and slot. |
| 84 | - **Ledger:** an append-only row for every movement, with who did it and why. |
| 85 | |
| 86 | Only a kind's issuer defines and grants its items: `service:live` for `live.*`. An issuer may name **grantors** on |
| 87 | one of its items (ADR-054 §3 amendment): other services or apps that may grant that item and nothing more (no edit, |
| 88 | no revoke), recorded in the ledger as the actor. Live names OpenVibe.Quest on six common items, so they are earned as |
| 89 | quest rewards, and OpenVibe.Games on six more, earned as achievements |
| 90 | ([server/data/live-grantors.json](server/data/live-grantors.json), applied at boot); their pages say where to earn |
| 91 | them. **Nothing is sold, bought, traded or |
| 92 | converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and |
| 93 | Billing (ADR-054 §5). A test fails if a route or a column for that appears. |
| 94 | |
| 95 | ## API (`/api/v1`, problem+json errors) |
| 96 | |
| 97 | The public reads (kinds, definitions, `/people/:subject/*` and `/equipped`) answer every origin with |
| 98 | `Access-Control-Allow-Origin: *` and no credentials, so any site can draw what people wear (openvibe-shared `items.js`); |
| 99 | every other route stays same-site. |
| 100 | |
| 101 | | Route | Capability | | |
| 102 | |---|---|---| |
| 103 | | `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds | |
| 104 | | `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items | |
| 105 | | `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear | |
| 106 | | `GET /people/:subject/holdings`, `/people/:subject/holdings/count` | `inventory.item.read` (public) | stacked, filtered public holdings and their count | |
| 107 | | `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) | |
| 108 | | `GET /me/items` | `inventory.item.list` | the caller's own items, every state | |
| 109 | | `GET /me/holdings`, `/me/holdings/count` | `inventory.item.list` | the caller's stacked owned holdings and their count | |
| 110 | | `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot | |
| 111 | | `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) | |
| 112 | | `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance | |
| 113 | | `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items | |
| 114 | |
| 115 | Holdings default to 60 stacks per page (maximum 200). Filters are `issuer` or `site` (a service name), `kind`, |
| 116 | `rarity`, `equipped=true|false`, `tradable=true|false`, and `text` (a substring of the name). Sort with |
| 117 | `sort=newest|name|rarity|count`. Pass `next_cursor` as `cursor` with the same filters and sort. The cursor pins the |
| 118 | newest instance seen on the first page, so later grants do not move stacks during paging. `tradable=true` currently |
| 119 | returns an empty set because Inventory has no trade or transfer operation. The count endpoint counts stacks after |
| 120 | the same filters. The original `/items` routes still return individual instances, including non-owned states on `/me`. |
| 121 | |
| 122 | For example, `GET /api/v1/me/holdings?kind=live.hat&limit=1` answers: |
| 123 | |
| 124 | ```json |
| 125 | {"subject":"usr_…","holdings":[{"definition":{"id":"itd_…","kind":"live.hat","name":"Crown","rarity":"epic"},"instance":{"id":"inv_…","definition_id":"itd_…","state":"owned"},"count":3,"equipped":true,"tradable":false,"kind_slots":["hat"]}],"next_cursor":"…"} |
| 126 | ``` |
| 127 | |
| 128 | Tokens are for audience `openvibe.inventory` (Network's rule: `openvibe.<service>`). A person acts for themself (their |
| 129 | token or this site's session; a cookie write must come from this site). A service |
| 130 | acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a |
| 131 | token that is presented must hold `inventory.item.read`. |
| 132 | |
| 133 | **Events** (openvibe-sdk outbox): `inventory.item.granted`, `.consumed`, `.revoked`, `.equipped`, `.unequipped` and |
| 134 | `inventory.definition.published`. **Account export and deletion** (ADR-033) arrive at the loopback |
| 135 | `POST /internal/events` (`INVENTORY_EVENTS_SECRET`; subscriptions created at boot). The export carries the person's |
| 136 | items, equipped set and history. The deletion removes their items and equipped set and keeps the ledger rows without |
| 137 | them, so supply counts stay true. |
| 138 | |
| 139 | ## The Workshop (ADR-054 §6) |
| 140 | |
| 141 | Community badges, free. A signed-in person makes one at `/workshop/new`: a square PNG or WebP image (64 to 512 px, at |
| 142 | most 200 KB, checked by its own bytes and stored in OpenVibe.Media under this service's tenant), a name and a cap of |
| 143 | at most 10 000. They also confirm that they made the image or may use it. It is `in_review`, credited to them and |
| 144 | shown to nobody else until staff publish it at `/workshop/review` (or `POST /api/v1/definitions/:id/review`), with a |
| 145 | rarity, or reject it with a reason the creator reads on `/workshop/mine`. A published badge is given by its creator, |
| 146 | by `@name`, as `granted`, within its cap and 100 gifts a day. People wear it before their name in chat and on their |
| 147 | profile (`network.badge.image@1`; the equipped read carries the image's `media_id`). Limits: 5 in review and 20 |
| 148 | submissions a day per person. On account deletion a creator's badges pass to the kind's issuer, retired, without |
| 149 | them. |
| 150 | |
| 151 | ## Pages |
| 152 | |
| 153 | `/` (what it is), `/items` (every item by kind), `/items/:id` (one item: where it shows, who gives it, how many exist), |
| 154 | `/me` (your items; Wear and Take off are plain forms), `/u/:subject` (a person's public inventory) and `/updates`. |
| 155 | Every page works without JavaScript. With `INDEXNOW_KEY`, each item page Search gains or changes is sent to search |
| 156 | engines (IndexNow, through `openvibe-publishing/search-feed`) and the key is served at `/<key>.txt`. |
| 157 | |
| 158 | ## Moving Live's cosmetics (ADR-054 §8) |
| 159 | |
| 160 | Live's 70 cosmetics are seeded as definitions issued by `service:live`, with Live's item ids kept as aliases. Then: |
| 161 | |
| 162 | ```bash |
| 163 | node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env # dry run |
| 164 | node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply # convert |
| 165 | node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify # compare |
| 166 | ``` |
| 167 | |
| 168 | The script only reads Live's database, and never prints its URL. `--apply` keeps unlock times and grants nothing twice. |
| 169 | `--verify` compares every person's items and slots, row for row. Live has read and written only through this API |
| 170 | since 2026-10-09 (`INVENTORY_AUTHORITY=inventory`), and its migration `0005_drop_live_cosmetics` dropped Live's three |
| 171 | cosmetics tables on 2026-10-15. |
| 172 | |
| 173 | ## Configuration |
| 174 | |
| 175 | See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `inventory` OAuth client on the |
| 176 | Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the |
| 177 | Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`). |
| 178 | |
| 179 | ## Development |
| 180 | |
| 181 | ```bash |
| 182 | npm install |
| 183 | fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network |
| 184 | fnm exec --using=22 npm run dev # http://localhost:5030 |
| 185 | ``` |
| 186 | |
| 187 | Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run |
| 188 | test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)). |
| 189 | |
| 190 | ## Acceptance |
| 191 | |
| 192 | `npm test` runs every [test/*.test.js](test/) in its own process ([test/run.js](test/run.js)) against temp PGlite |
| 193 | databases and an in-process mock Network, so no test needs the network or a running site. `npm run test:pg` runs the same |
| 194 | suite through PostgreSQL and PgBouncer. The main files: |
| 195 | |
| 196 | - [test/inventory-api.test.js](test/inventory-api.test.js) — boot seeds Live's five kinds, the Workshop badge and 70 |
| 197 | definitions; a grant is namespaced, idempotent and capped (serials); attributes are validated against the kind; |
| 198 | equipping is one item per slot; public reads show owned items and the owner's own read every state; consume/revoke; |
| 199 | a presented token must hold `inventory.item.read`; no buy/sell/trade route or money column exists. |
| 200 | - [test/workshop.test.js](test/workshop.test.js) — a badge is judged by its own bytes (square PNG/WebP, 64–512 px, at |
| 201 | most 200 KB), submitted in review, published or rejected by staff with a reason, given by `@name`, limited (5 in |
| 202 | review, 20 submissions and 100 gifts a day), and taken down with or without revoking every copy. |
| 203 | - [test/grantors.test.js](test/grantors.test.js) — Live names Quest on its six reward items; a grantor grants that item |
| 204 | only (no edit, no revoke), and Live sets and clears the list. |
| 205 | - [test/account-data.test.js](test/account-data.test.js) — account export and deletion over the signed loopback route: |
| 206 | the ledger rows stay without their subject, supply counts stay true, and a creator's badges pass to the kind, retired. |
| 207 | - [test/import-live.test.js](test/import-live.test.js) — Live's cosmetics move row for row: the dry run, `--apply` (origin |
| 208 | `migrated`, unlock time kept, idempotent) and `--verify`. |
| 209 | - [test/public-cors.test.js](test/public-cors.test.js) — the public reads answer every origin with `*` and no credentials; |
| 210 | every other route stays same-site. [test/caller-limits.test.js](test/caller-limits.test.js) — a caller past its limit |
| 211 | gets 429 problem+json with Retry-After, the declared budgets hold, and health/ready/metrics are never limited. |
| 212 | - [test/auth-ops.test.js](test/auth-ops.test.js), [test/auth-jwks.test.js](test/auth-jwks.test.js) and |
| 213 | [test/security-session.test.js](test/security-session.test.js) — PKCE sign-in and truthful readiness; the Network JWKS |
| 214 | fetch, rotation and outage; only a real Network person token is a session. |
| 215 | - [test/security-secrets.test.js](test/security-secrets.test.js), [test/no-internal-key.test.js](test/no-internal-key.test.js) |
| 216 | and [test/open-redirect.test.js](test/open-redirect.test.js) — the OAuth client secret never leaves in a response, the |
| 217 | database or a log; no `X-Internal-Key` path; `next=` never leaves the site. |
| 218 | - [test/discovery.test.js](test/discovery.test.js), [test/layout.test.js](test/layout.test.js) and |
| 219 | [test/asset-cache.test.js](test/asset-cache.test.js) — robots/sitemap/llms and JSON-LD, the server-rendered shell and |
| 220 | the boost marker, and the asset cache headers. |
| 221 | - [test/nginx-auth-limit.test.js](test/nginx-auth-limit.test.js), [test/service-kit.test.js](test/service-kit.test.js) and |
| 222 | [test/perf-budget.test.js](test/perf-budget.test.js) — the nginx `/auth/me` zone, the graceful stop order, and the home |
| 223 | page's size budgets. |
| 224 | |
| 225 | ## Deploy (for the lead) |
| 226 | |
| 227 | - **Deploy:** `sudo ovhost deploy inventory` on the host (git checkout at `/opt/inventory.openvibe.network`, unit |
| 228 | `openvibe-inventory.service` on 127.0.0.1:5030, env `/etc/openvibe/inventory.env`, database `ov_inventory` on the data role). |
| 229 | - **nginx:** [deploy/nginx/inventory.openvibe.network.conf](deploy/nginx/inventory.openvibe.network.conf), installed with `ov-vhost-install`. |
| 230 | - **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart. |
| 231 | - **Network:** OAuth client `inventory`; grants `events.event.publish` and `events.subscription.manage` (openvibe.events), |
| 232 | then the account grants; the issuers' grants (`live`: `inventory.item.grant`, `inventory.item.consume`, |
| 233 | `inventory.definition.manage`, `inventory.item.list`, `inventory.equip.manage`, `inventory.item.read`). |
| 234 | - The vhost uses the `openvibe.network` wildcard certificate. |
| 235 | |
| 236 | ## Security (threat notes) |
| 237 | |
| 238 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 239 | |
| 240 | - Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session. |
| 241 | - Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged. |
| 242 | - Request bodies are never logged. |
| 243 | - Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard |
| 244 | decides what may be fetched. |
| 245 | |
| 246 | --- |
| 247 | |
| 248 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 249 | |
| 250 | <!-- versions:start --> |
| 251 | - openvibe-contracts: v0.419.0 |
| 252 | - openvibe-sdk: v0.161.0 |
| 253 | - openvibe-shared: v4.241.0 |
| 254 | - openvibe-publishing: v1.7.0 |
| 255 | <!-- versions:end --> |
| 256 | |
| 257 | |
| 258 | ### Account hub deployment (review before merging) |
| 259 | |
| 260 | `PUBLIC_URL=https://openvibe.my/inventory` overrides `BASE_URL` for browser URLs and |
| 261 | SSO's default callback. Without it, the existing unprefixed deployment is unchanged. |
| 262 | Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventory/workshop`, |
| 263 | `/inventory/u/<subject>` and `/inventory/updates`. APIs and feeds on the original host stay available. |
| 264 | `INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only, |
| 265 | preserving query strings. Leave it unset until the new deployment is verified. |
| 266 | |
| 267 | `server/auth/scoped-session.js` adapts the SDK's Express boundary. Inventory's own |
| 268 | session, flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`. |
| 269 | Mounted requests prefer the hub's root `ov_token`, verified by the same SDK verifier |
| 270 | against Inventory's configured issuer and audience (`openvibe.network` by default). |
| 271 | No audience is added or bypassed. Hub refresh and OAuth flow cookies are never borrowed |
| 272 | or written, and a previous Inventory refresh token cannot replace the hub viewer. |
| 273 | Guests render without an automatic OAuth redirect. Explicit Inventory OAuth still uses |
| 274 | SDK PKCE, state verification and error recovery. The original host is unchanged. |
| 275 | |
| 276 | After review, deploy in this order: |
| 277 | |
| 278 | 1. Merge/deploy the companion Network PR with |
| 279 | `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Network#<N>:network' < /dev/null`. |
| 280 | Its boot seed merges the new callback into existing clients, keeping old callbacks. |
| 281 | Install Network's `deploy/nginx/parked-domains.conf` with `sudo ov-vhost-install` |
| 282 | using the host's installed filename. The `/inventory` proxy preserves the prefix. |
| 283 | 2. Back up `/etc/openvibe/inventory.env` with sudo before editing it (never print its contents). |
| 284 | Set `PUBLIC_URL=https://openvibe.my/inventory`; if `OV_OAUTH_REDIRECT_URI` is set, |
| 285 | set it to `https://openvibe.my/inventory/auth/callback`. Leave legacy redirects disabled. |
| 286 | Deploy with `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Inventory#<N>:inventory' < /dev/null`. |
| 287 | 3. Verify hub and Inventory sign-in together, silent entry from Network, refresh, sign-out, |
| 288 | Workshop forms, assets and API callers. Use `networkToken()` from `tools/site-signin.js` |
| 289 | with the probe password loaded inline on the host, then follow the `/inventory` silent |
| 290 | flow in a browser with a path-aware cookie jar. That helper's `signIn()` assumes root |
| 291 | `/auth` and cannot test this prefix or hub cookie coexistence. Never print credentials |
| 292 | or cookies. The live check is pending |
| 293 | until the reviewed callback and prefix are deployed. Then enable |
| 294 | `INVENTORY_REDIRECT_LEGACY=true` and redeploy Inventory to activate the 301 table. |
| 295 | |
| 296 | Local coverage: `npm test -- base-path auth-ops security-session workshop discovery search-index`. |
| 297 |