openvibers/openvibe.inventory Commits

Inventory: the public reads answer every origin (no credentials), so any OpenVibe site can draw what people wear (#4)

OP OpenVibers committed 9 Oct 2026 · 4e35101a98 · 3 files, +62 −0

Changes

ddiff --git a/README.md b/README.md
iindex 11c881a..31f55bb 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,10 @@ Billing (ADR-054 §5). A test fails if a route or a column for that appears.
## API (`/api/v1`, problem+json errors)
++The public reads (kinds, definitions, `/people/:subject/*` and `/equipped`) answer every origin with
++`Access-Control-Allow-Origin: *` and no credentials, so any site can draw what people wear (openvibe-shared `items.js`);
++every other route stays same-site.
++
| Route | Capability | |
|---|---|---|
| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
ddiff --git a/server/app.js b/server/app.js
iindex 44dd6dc..a6fda7a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -139,6 +139,20 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
++ // Public reads from any site: the catalog and anyone's items and equipped set answer every origin, without
++ // credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else
++ // keeps the same-site resource policy and no CORS. A preflight is answered here.
++ const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;
++ app.use('/api/v1', (req, res, next) => {
++ if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ if (req.method !== 'OPTIONS') return next();
++ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', 'Accept');
++ res.setHeader('Access-Control-Max-Age', '86400');
++ return res.status(204).end();
++ });
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
nnew file mode 100644
iindex 0000000..e6360b6
---- /dev/null
++++ b/test/public-cors.test.js
@@@ -0,0 +1,44 @@
++'use strict';
++/**
++ * Public reads from any site (openvibe-shared/items.js): the catalog and anyone's items and equipped set answer every
++ * origin with Access-Control-Allow-Origin: * and a cross-origin resource policy, preflight included. The person's own
++ * routes, writes and the pages keep same-site and send no CORS header.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const A = 'usr_01JZ0000000000000000000AAA';
++ const origin = { origin: 'https://openvibe.community' };
++ try {
++ await check('the public reads answer every origin', async () => {
++ for (const p of ['/api/v1/kinds', '/api/v1/kinds/live.hat', '/api/v1/definitions?kind=live.hat', `/api/v1/equipped?subjects=${A}`, `/api/v1/people/${A}/items`, `/api/v1/people/${A}/equipped`]) {
++ const r = await t.get(p, { headers: origin });
++ assert.strictEqual(r.status, 200, p);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', p);
++ assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'cross-origin', p);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null, `${p}: never with credentials`);
++ }
++ const one = (await t.get('/api/v1/definitions?kind=live.hat')).json().definitions[0];
++ const r = await t.get(`/api/v1/definitions/${one.id}`, { headers: origin });
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ });
++ await check('a preflight is answered for them', async () => {
++ const r = await t.get('/api/v1/equipped', { method: 'OPTIONS', headers: { ...origin, 'access-control-request-method': 'GET' } });
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
++ });
++ await check('everything else stays same-site', async () => {
++ for (const [p, method] of [['/api/v1/me/items', 'GET'], ['/api/v1/grants', 'POST'], ['/api/v1/me/equipped', 'PUT'], ['/api/v1/ping', 'GET'], ['/items', 'GET']]) {
++ const r = await t.get(p, { method, headers: origin });
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, `${method} ${p}`);
++ assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'same-site', `${method} ${p}`);
++ }
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();