# AGENTS.md — OpenVibe.Inventory ## Project Overview OpenVibe.Inventory is the network-wide inventory for one OpenVibe account (ADR-054): the item kinds a surface can render, the definitions an issuer publishes, the instances a person owns, the equipped set, the ledger, the community Workshop and the reviewed cosmetic store. One inventory for every OpenVibe site, the way Steam's works across games. - **Site:** `openvibe.my/inventory` (base-path aware; API host `inventory.openvibe.network`) · **Port:** 5030 · **Service id:** `inventory` · **Env prefix:** `INVENTORY` - **Runtime:** Node 22, Express 4, PostgreSQL 18 through `openvibe-sdk/db` (PGlite in development/tests), Valkey 9 for shared limits. - **Read first:** [README.md](README.md), [docs/store.md](docs/store.md), [STATUS.json](STATUS.json), ADR-054 in OpenVibe.Contracts. **Network context:** subjects (`usr_…`), usernames, tokens, JWKS and roles are OpenVibe.Network. Image bytes are OpenVibe.Media (a Workshop image is uploaded to Inventory's Media tenant and referenced by `med_` id). Events are published through the SDK outbox to OpenVibe.Events; account export/deletion arrives there at `/internal/events`. Money is OpenVibe.Billing: the store snapshots a reviewed price, Billing authorizes the spend, and Inventory matches Billing's payment before granting. Inventory never moves money itself. ## Commands ```bash npm ci # install (contracts/sdk/shared pinned by tag) npm run dev # http://localhost:5030 (base path from PUBLIC_URL/BASE_URL) npm start # node server/index.js npm test # every test/*.test.js on temp PGlite, mocked Network/Media/Billing npm run test:pg # the same through PostgreSQL + PgBouncer npm test -- holdings # only test files whose name contains the word npm test -- --strict # a skipped test fails the run node --check # syntax check (no linter configured) ``` `test/run.js` uses `openvibe-shared/test-runner`; tests use fixtures and never reach the internet. No build step: pages are server-rendered and assets live under [public/](public/). ## Architecture at a Glance - **Entry:** [server/index.js](server/index.js) — listener, telemetry retention, the outbox relay and the two account subscriptions; graceful stop in `createLifecycle()`. `INVENTORY_DRILL` keeps boot-time work off. - **App factory:** [server/app.js](server/app.js) — CSP, SSO, `/auth`, `/internal/events`, the `/shared` + static mounts, `/api/v1`, discovery/agent twins, pages, 404/error handler. - **Config and URLs:** [server/config.js](server/config.js) (`load(env)` is pure), [server/urls.js](server/urls.js) (the one base-path translation for links, redirects, metadata and discovery — API paths keep their shape). - **Model/store:** [server/inventory/store.js](server/inventory/store.js) (kinds, definitions, instances, grants, equipped, ledger, subject merges), [server/inventory/holdings.js](server/inventory/holdings.js) (stacked public/own holdings and cursors), [server/inventory/commerce.js](server/inventory/commerce.js) (store, review, orders, Billing), [server/inventory/seed.js](server/inventory/seed.js), [server/inventory/account-data.js](server/inventory/account-data.js). Seed data: [server/data/kinds.json](server/data/kinds.json), [server/data/live-definitions.json](server/data/live-definitions.json), [server/data/live-grantors.json](server/data/live-grantors.json). - **Workshop:** [server/workshop/pages.js](server/workshop/pages.js), [server/workshop/people.js](server/workshop/people.js), [server/workshop/media.js](server/workshop/media.js), [server/workshop/image.js](server/workshop/image.js); upload JS [public/js/workshop-upload.js](public/js/workshop-upload.js). - **HTTP:** [server/http/api.js](server/http/api.js) (`/api/v1`), [server/http/pages.js](server/http/pages.js), [server/http/discovery.js](server/http/discovery.js), [server/http/principal.js](server/http/principal.js) (person vs service, `X-OV-Subject`, same-origin cookie writes, capabilities), [server/http/wallet-cors.js](server/http/wallet-cors.js), [server/http/router.js](server/http/router.js) (async router). - **Auth:** [server/auth/sso.js](server/auth/sso.js), [server/auth/keys.js](server/auth/keys.js), [server/auth/scoped-session.js](server/auth/scoped-session.js). - **Frontend:** server-rendered through [server/render/layout.js](server/render/layout.js), [server/render/html.js](server/render/html.js), [server/render/markdown.js](server/render/markdown.js) and `openvibe-shared/shell|frame|showcase`; assets [public/css/app.css](public/css/app.css), [public/holdings.js](public/holdings.js). Every page works without JavaScript. - **Database:** [server/db.js](server/db.js) and [migrations/](migrations/): `inventory_kinds`, `inventory_definitions`, `inventory_definition_aliases`, `inventory_instances`, `inventory_grants`, `inventory_equipped`, `inventory_ledger`, `inventory_store_listings`, `inventory_store_purchases`, `inventory_subject_merges`, `event_outbox`, `account_data_events`, `telemetry_samples`. - **Deploy:** [deploy/systemd/openvibe-inventory.service](deploy/systemd/openvibe-inventory.service) (`/etc/openvibe/inventory.env`, `/opt/inventory.openvibe.network`) and [deploy/nginx/inventory.openvibe.network.conf](deploy/nginx/inventory.openvibe.network.conf). `sudo ovhost deploy inventory`; database `ov_inventory`; rollback restores the previous sha when `/api/ready` fails. ## Ownership and Boundaries Inventory is the authority for kinds, definitions, instances, grants, the equipped set and the ledger, plus its Workshop definitions, store listings/orders and the receipts of account-export/deletion events. It is **not** the authority for accounts (Network), file bytes (Media), event delivery (Events), money (Billing), or the host/database/ Valkey (Host). Live's old `user_cosmetics`/`user_equipped` are Live's until [scripts/import-live.js](scripts/import-live.js) has converted them; since ADR-054 Live reads and writes only through this API. Account deletion removes a person's items and equipped set but keeps ledger rows without the subject, so supply counts stay true. ## Auth and Capabilities - Capabilities (Contracts): `inventory.item.read`, `inventory.item.list`, `inventory.equip.manage`, `inventory.item.grant`, `inventory.item.consume`, `inventory.definition.manage`, `inventory.definition.review`, and `inventory.store.list` (pins Contracts until released). - A person acts for themself with their Network token or this site's session; a cookie write must be same-origin. A service acting for a person sends `X-OV-Subject: usr_…` and holds the route capability. Public reads need no token; a presented token must still hold `inventory.item.read`. - Public reads (`/kinds`, `/definitions`, `/people/:subject/*`, `/equipped`) answer `Access-Control-Allow-Origin: *` with no credentials so any site can render what people wear; every other route stays same-site. Do not add a credentialed wildcard. - Outbound: `events.event.publish` + `events.subscription.manage`, `identity.subject.resolve`, and Media `media.object.upload` on namespace `inventory`; store purchases use Billing with the person's own bearer. - Tokens are audience `openvibe.inventory`. Refusals are RFC 9457 problem+json with stable `inventory.*` codes. ## API `/api/v1`: `GET /ping`, `GET /kinds[/:id]`, `GET /definitions[/:id]`, `GET /people/:subject/items|equipped|holdings|holdings/count`, `GET /equipped?subjects=a,b,…`, `GET /me/items|holdings|holdings/count`, `PUT /me/equipped`, `POST /grants`, `POST /instances/:id/consume|revoke`, `POST /definitions`, `PATCH /definitions/:id`, `GET /store`, `GET /store/purchases/:id`, `POST /store/:id/buy|list|review`. Holdings: 60 stacks per page (max 200), filters `issuer|site`, `kind`, `rarity`, `equipped`, `tradable`, `text`; sort `newest|name|rarity|count`; pass `next_cursor` back with the same filters. The cursor pins the newest instance, so later grants never move a page. `tradable=true` is empty: there is no trade/transfer operation. Events: `inventory.item.granted|consumed|revoked|equipped|unequipped`, `inventory.definition.published`. ## Conventions - CommonJS, 4-space indent, single quotes, semicolons; no linter/formatter. Route handlers via [server/http/router.js](server/http/router.js); never leak a raw row or a secret. - Every browser link goes through [server/urls.js](server/urls.js) so the `openvibe.my/inventory` base path survives redirects, discovery and the Markdown twins. API paths never take the prefix. - New schema = a new `migrations/NNNN_name.sql`; never edit a migration that has run. Keep `inventory_` prefixes. - CSP: add origins to `helmet` in [server/app.js](server/app.js). Media images come from `openvibe.media`; Billing and the wallet's connect-src are already named. Never widen `frameAncestors`/`objectSrc`. - Phone first: pages must fit 390 px without horizontal scroll; `/me` works with plain forms and no JS. - Empty states use the shared kit, never bespoke markup; "Calm reduces, only Off removes" applies to any hide preference (never delete). - No Font Awesome; use `openvibe-shared ov-icons` (`KIND_ICON` in [server/http/pages.js](server/http/pages.js)). - Nothing here sells, buys, trades or converts an item without a reviewed store listing and Billing. ## Key Pitfalls - **Base path.** The site can be mounted under `/inventory`; a hard-coded `href="/items"` breaks production. Use `urls.path()` and `urls.html()`. - **Store review is mandatory.** A price edit re-enters review; no price may be invented. An order reserves one serial under the ordinary grant lock; an uncertain response is recovered by retrying the same item/currency, and pending reservations do not expire by themselves. - **Grants are idempotent per key**; a repeat must not issue a second instance. A capped item answers 409 `inventory.supply_exhausted`. - **Public CORS is deliberately credential-free.** `/me` and all writes must stay same-site; wallet-cors is only for the named first-party origins in [server/http/wallet-cors.js](server/http/wallet-cors.js). - **Workshop images** are checked by their own bytes (64–512 px PNG/WebP, ≤200 KB), stored in Media's tenant, and never written to the database. On deletion a creator's badges pass to the kind issuer, retired. - **Drill mode** (`INVENTORY_DRILL`) keeps the outbox relay, subscriptions and timers off; add new background work there. - **Import Live's cosmetics** only with the dry run, `--apply`, then `--verify`; never print Live's URL or secrets. - **`event_outbox`** needs its rejected partial index ([migrations/0007_outbox_rejected_index.sql](migrations/0007_outbox_rejected_index.sql)). ## Testing `npm test` runs each file in its own process on a temp PGlite database with mocked Network/Media/Billing: [test/inventory-api.test.js](test/inventory-api.test.js), [test/holdings.test.js](test/holdings.test.js), [test/store.test.js](test/store.test.js), [test/workshop.test.js](test/workshop.test.js), [test/workshop-media-client.test.js](test/workshop-media-client.test.js), [test/public-cors.test.js](test/public-cors.test.js), [test/base-path.test.js](test/base-path.test.js), [test/import-live.test.js](test/import-live.test.js), account-data, search-index, SSO, limits, secret-leak/redirect, layout and perf budgets. Run the narrowest file first, then the suite; `npm run test:pg` for PostgreSQL. Always `node --check` changed files. ## Documentation - [README.md](README.md) — purpose, ownership, capabilities, model, API, Workshop, pages, Live migration, configuration, acceptance, deploy, security notes. - [docs/store.md](docs/store.md) — the universal store, review, orders and Billing matching. - [STATUS.json](STATUS.json) — stage, deployed flag, features and not-yet list. - [.env.example](.env.example) — variable names only; never commit values. - [CHANGELOG.md](CHANGELOG.md), [SECURITY.md](SECURITY.md).