openvibers/openvibe.inventory Commits
Inventory: the public reads answer every origin (no credentials), so any OpenVibe site can draw what people wear (#4)
Changes
@@@ -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 |@@@ -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 }));nnew file mode 100644@@@ -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();++})();
@@ -29,6 +29,10 @@ Billing (ADR-054 §5). A test fails if a route or a column for that appears.
@@ -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)
## 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 | |
| Route | Capability | |
|---|---|---|
|---|---|---|
| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
@@ -139,6 +139,20 @@ async function createApp(opts = {}) {
@@ -139,6 +139,20 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── 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', 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/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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();
+})();