openvibers/openvibe.inventory README.md

README.md

main 20.6 KB · 297 lines · markdown

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
9and 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
13One inventory for an OpenVibe account, the way Steam's works across games. Sites, games and apps give items for what
14people do; people keep them in one place and wear them on every site that shows their kind.
15
16## Purpose
17
18OpenVibe.Inventory is the network-wide inventory for an OpenVibe account (ADR-054): one place where the items people
19earn or are given across every site, game and app are held. It registers item kinds (a contract plus a renderer per
20surface), the definitions an issuer publishes and the instances a person owns, and keeps what each person has equipped.
21Every movement is one append-only ledger row and one `inventory.*` event, written in the same transaction as the change.
22Nothing is sold, bought, traded or converted into OpenCoins or Vibes (ADR-054 §5).
23
24## Owns
25
26Its 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
29export and deletion receipts). It is the authority for item kinds, definitions, instances, grant receipts, the equipped
30set and the ledger. Nothing written there is ever a credential, a key or a token.
31
32Requests 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
34to `telemetry_samples` (openvibe-sdk 0.46, `platform.telemetry-sample@1`, migration `0005_telemetry.sql`); rows older than 14 days are
35deleted 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
66Its 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
68staff review route).
69
70It calls, on other services: `events.event.publish` and `events.subscription.manage` (OpenVibe.Events; the outbox relay
71and 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
86Only a kind's issuer defines and grants its items: `service:live` for `live.*`. An issuer may name **grantors** on
87one of its items (ADR-054 §3 amendment): other services or apps that may grant that item and nothing more (no edit,
88no revoke), recorded in the ledger as the actor. Live names OpenVibe.Quest on six common items, so they are earned as
89quest 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
91them. **Nothing is sold, bought, traded or
92converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and
93Billing (ADR-054 §5). A test fails if a route or a column for that appears.
94
95## API (`/api/v1`, problem+json errors)
96
97The 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`);
99every 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
115Holdings 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
118newest instance seen on the first page, so later grants do not move stacks during paging. `tradable=true` currently
119returns an empty set because Inventory has no trade or transfer operation. The count endpoint counts stacks after
120the same filters. The original `/items` routes still return individual instances, including non-owned states on `/me`.
121
122For 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
128Tokens are for audience `openvibe.inventory` (Network's rule: `openvibe.<service>`). A person acts for themself (their
129token or this site's session; a cookie write must come from this site). A service
130acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a
131token 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
136items, equipped set and history. The deletion removes their items and equipped set and keeps the ledger rows without
137them, so supply counts stay true.
138
139## The Workshop (ADR-054 §6)
140
141Community badges, free. A signed-in person makes one at `/workshop/new`: a square PNG or WebP image (64 to 512 px, at
142most 200 KB, checked by its own bytes and stored in OpenVibe.Media under this service's tenant), a name and a cap of
143at most 10 000. They also confirm that they made the image or may use it. It is `in_review`, credited to them and
144shown to nobody else until staff publish it at `/workshop/review` (or `POST /api/v1/definitions/:id/review`), with a
145rarity, or reject it with a reason the creator reads on `/workshop/mine`. A published badge is given by its creator,
146by `@name`, as `granted`, within its cap and 100 gifts a day. People wear it before their name in chat and on their
147profile (`network.badge.image@1`; the equipped read carries the image's `media_id`). Limits: 5 in review and 20
148submissions a day per person. On account deletion a creator's badges pass to the kind's issuer, retired, without
149them.
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`.
155Every page works without JavaScript. With `INDEXNOW_KEY`, each item page Search gains or changes is sent to search
156engines (IndexNow, through `openvibe-publishing/search-feed`) and the key is served at `/<key>.txt`.
157
158## Moving Live's cosmetics (ADR-054 §8)
159
160Live's 70 cosmetics are seeded as definitions issued by `service:live`, with Live's item ids kept as aliases. Then:
161
162```bash
163node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env # dry run
164node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply # convert
165node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify # compare
166```
167
168The 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
170since 2026-10-09 (`INVENTORY_AUTHORITY=inventory`), and its migration `0005_drop_live_cosmetics` dropped Live's three
171cosmetics tables on 2026-10-15.
172
173## Configuration
174
175See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `inventory` OAuth client on the
176Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
177Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
178
179## Development
180
181```bash
182npm install
183fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network
184fnm exec --using=22 npm run dev # http://localhost:5030
185```
186
187Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
188test: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
193databases and an in-process mock Network, so no test needs the network or a running site. `npm run test:pg` runs the same
194suite 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
238Reporting 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
248Part 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
261SSO's default callback. Without it, the existing unprefixed deployment is unchanged.
262Inventory 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,
265preserving 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
268session, flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.
269Mounted requests prefer the hub's root `ov_token`, verified by the same SDK verifier
270against Inventory's configured issuer and audience (`openvibe.network` by default).
271No audience is added or bypassed. Hub refresh and OAuth flow cookies are never borrowed
272or written, and a previous Inventory refresh token cannot replace the hub viewer.
273Guests render without an automatic OAuth redirect. Explicit Inventory OAuth still uses
274SDK PKCE, state verification and error recovery. The original host is unchanged.
275
276After review, deploy in this order:
277
2781. 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.
2832. 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`.
2873. 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
296Local coverage: `npm test -- base-path auth-ops security-session workshop discovery search-index`.
297