openvibers/openvibe.inventory Commits
Inventory: Workshop images reach Media with the service token; the app key goes (C-71) (#31)
Changes
@@@ -35,7 +35,7 @@ OV_OAUTH_CLIENT_SECRET=#EVENTS_RELAY_INTERVAL_MS=2000#INVENTORY_EVENTS_SECRET=--# The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media (MEDIA_APPS_SEED there--# lists app_id inventory with this key). Without it, the Workshop says image uploads are not set up yet.++# The Workshop's images use this service's Network service token for its own tenant in OpenVibe.Media.++# Grant [inventory, media.object.upload, openvibe.media] on namespace inventory in Network. Without++# OV_OAUTH_CLIENT_SECRET, the Workshop says image uploads are not set up yet.#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100--#INVENTORY_MEDIA_APP_KEY=@@@ -2,6 +2,11 @@What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.++## Unreleased++++- Workshop images reach OpenVibe.Media with Inventory's Network service token (audience `openvibe.media`, grant++ `media.object.upload` on namespace `inventory`). `INVENTORY_MEDIA_APP_KEY` is gone (compatibility register C-71).++## 0.4.1 — 2026-10-09- Workshop takedown (ADR-054 §6): `/workshop/review` lists the published community badges. Staff can retire one (nobody@@@ -44,8 +44,9 @@ set and the ledger. Nothing written there is ever a credential, a key or a token`OV_OAUTH_CLIENT_SECRET`.- **OpenVibe.Events** — the outbox relay publishes `inventory.*` and the account-export/deletion subscriptions arecreated there: `EVENTS_URL`, `INVENTORY_EVENTS_SECRET`, `EVENTS_RELAY_INTERVAL_MS`.--- **OpenVibe.Media** — Workshop image uploads to this service's tenant: `OV_MEDIA_INTERNAL_URL`,-- `INVENTORY_MEDIA_APP_KEY`.++- **OpenVibe.Media** — Workshop image uploads to this service's tenant use its Network service token++ (`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`): `OV_MEDIA_INTERNAL_URL`. Network grants++ `[inventory, media.object.upload, openvibe.media]` on namespace `inventory`.- **PostgreSQL** (`DATABASE_URL` to serve, `DATABASE_DIRECT_URL` to migrate) and, optionally, **Valkey** for sharedper-caller limit counters (`VALKEY_URL`).- **Packages** — `openvibe-contracts` and `openvibe-sdk` (auth, db, events, account-data, limits, service, valkey),@@@ -64,11 +64,10 @@ function load(env = process.env) {secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),},// The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media. Without the-- // key, submitting an image says uploads are not set up yet; everything else works.++ // OAuth client secret, submitting an image says uploads are not set up yet; everything else works.media: {url: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),app: env.INVENTORY_MEDIA_APP || 'inventory',-- appKey: env.INVENTORY_MEDIA_APP_KEY || '',timeoutMs: Math.max(1000, int(env.INVENTORY_MEDIA_TIMEOUT_MS, 15000)),},};@@@ -1,21 +1,40 @@'use strict';/**-- * Workshop images in OpenVibe.Media: Inventory's own tenant (`inventory`, its app key in INVENTORY_MEDIA_APP_KEY),-- * a public `asset` object per badge, served by Media at https://openvibe.media/o/<med_…>. The badge is shown to-- * nobody until staff publish it (the pages never link an image of an item in review except to its creator and to-- * staff); a rejected or retired item keeps its object so the review record stays whole.++ * Workshop images in OpenVibe.Media: Inventory's own tenant (`inventory`), using its Network service token++ * (OV_OAUTH_CLIENT_SECRET, audience openvibe.media). A public `asset` object per badge is served by Media at++ * https://openvibe.media/o/<med_…>. The badge is shown to nobody until staff publish it (the pages never link an++ * image of an item in review except to its creator and staff); a rejected or retired item keeps its object so the++ * review record stays whole.** upload({ buffer, type, name, ownerSubject }) → med_… id (init → content → complete; throws with a short reason)*/++const { createServiceTokenClient } = require('openvibe-sdk/auth');++function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = console }) {const base = () => `${String(config.media.url).replace(/\/+$/, '')}/api/v2/${encodeURIComponent(config.media.app)}/objects`;-- const enabled = () => Boolean(config.media.appKey);++ const enabled = () => Boolean(config.oauth.clientSecret);++ let tokens;async function call(path, { method = 'POST', json, body, headers = {} } = {}) {-- const h = { Accept: 'application/json', Authorization: `Bearer ${config.media.appKey}`, ...headers };let payload = body;-- if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }++ if (json !== undefined) payload = JSON.stringify(json);++ let token;++ try {++ tokens ||= createServiceTokenClient({++ tokenUrl: `${String(config.networkInternalUrl).replace(/\/+$/, '')}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.media',++ fetch: fetchImpl,++ });++ token = await tokens.getToken();++ } catch (err) {++ log.warn('[Workshop] OpenVibe.Media service token unavailable:', (err && err.name) || '');++ throw new Error('OpenVibe.Media did not answer (service token unavailable); try again in a minute.');++ }++ const h = { Accept: 'application/json', ...headers, Authorization: `Bearer ${token}` };++ if (json !== undefined) h['Content-Type'] = 'application/json';let res;try {res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(config.media.timeoutMs) });@@@ -32,7 +51,7 @@ function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = conso}async function upload({ buffer, type, name, ownerSubject }) {-- if (!enabled()) throw new Error('Image uploads are not set up yet (INVENTORY_MEDIA_APP_KEY).');++ if (!enabled()) throw new Error('Image uploads are not set up yet (OV_OAUTH_CLIENT_SECRET).');// The same call shape as OpenVibe.MediaHub's client: the owner in X-OV-Subject, a printable-ASCII filename label.const filename = String(name || 'badge').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').trim().slice(0, 100) || 'badge';const init = await call('', { headers: { 'X-OV-Subject': ownerSubject }, json: { kind: 'asset', visibility: 'public', size_bytes: buffer.length, mime_type: type, filename: `${filename}.${type === 'image/png' ? 'png' : 'webp'}` } });nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/** Workshop Media authentication without opening a socket. */++const assert = require('assert');++const { load } = require('../server/config');++const { createWorkshopMedia } = require('../server/workshop/media');++++const upload = { buffer: Buffer.from('image'), type: 'image/png', name: 'badge', ownerSubject: 'usr_test' };++const id = `med_${'0'.repeat(26)}`;++++(async () => {++ const config = load({++ OV_NETWORK_INTERNAL_URL: 'http://network.test',++ OV_MEDIA_INTERNAL_URL: 'http://media.test',++ OV_OAUTH_CLIENT_ID: 'inventory',++ OV_OAUTH_CLIENT_SECRET: 'inventory-secret',++ });++ const requests = [];++ const fetchImpl = async (url, opts) => {++ requests.push({ url, opts });++ if (url === 'http://network.test/oauth/token') {++ return { ok: true, json: async () => ({ access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 }) };++ }++ return { ok: true, json: async () => url.endsWith('/objects') ? { id } : {} };++ };++ const media = createWorkshopMedia({ config, fetchImpl });++ assert.strictEqual(media.enabled(), true);++ assert.strictEqual(requests.length, 0, 'the token client is lazy');++ assert.strictEqual(await media.upload(upload), id);++++ const tokenRequests = requests.filter((r) => r.url.endsWith('/oauth/token'));++ const mediaRequests = requests.filter((r) => r.url.startsWith('http://media.test/'));++ assert.strictEqual(tokenRequests.length, 1, 'the token is cached for the upload');++ assert.strictEqual(tokenRequests[0].opts.method, 'POST');++ const form = new URLSearchParams(tokenRequests[0].opts.body);++ assert.strictEqual(form.get('grant_type'), 'client_credentials');++ assert.strictEqual(form.get('audience'), 'openvibe.media');++ assert.strictEqual(form.get('client_id'), 'inventory');++ assert.strictEqual(mediaRequests.length, 3);++ for (const request of mediaRequests) assert.strictEqual(request.opts.headers.Authorization, 'Bearer svc-media');++ assert.strictEqual(mediaRequests[0].opts.headers['X-OV-Subject'], upload.ownerSubject);++++ const noSecretRequests = [];++ const noSecret = createWorkshopMedia({++ config: load({ OV_OAUTH_CLIENT_SECRET: '' }),++ fetchImpl: async (...args) => { noSecretRequests.push(args); throw new Error('unexpected request'); },++ });++ assert.strictEqual(noSecret.enabled(), false);++ await assert.rejects(noSecret.upload(upload), /not set up yet \(OV_OAUTH_CLIENT_SECRET\)/);++ assert.strictEqual(noSecretRequests.length, 0);++++ const failedRequests = [];++ const failed = createWorkshopMedia({++ config,++ fetchImpl: async (url) => { failedRequests.push(url); throw new Error('inventory-secret'); },++ log: { warn() {} },++ });++ const error = await failed.upload(upload).catch((err) => err);++ assert.match(error.message, /OpenVibe\.Media did not answer \(service token unavailable\)/);++ assert.ok(!error.message.includes('inventory-secret'));++ assert.deepStrictEqual(failedRequests, ['http://network.test/oauth/token']);++++ console.log('workshop Media client: passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -35,7 +35,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -35,7 +35,7 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
#EVENTS_RELAY_INTERVAL_MS=2000
#INVENTORY_EVENTS_SECRET=
#INVENTORY_EVENTS_SECRET=
-# The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media (MEDIA_APPS_SEED there
-# lists app_id inventory with this key). Without it, the Workshop says image uploads are not set up yet.
+# The Workshop's images use this service's Network service token for its own tenant in OpenVibe.Media.
+# Grant [inventory, media.object.upload, openvibe.media] on namespace inventory in Network. Without
+# OV_OAUTH_CLIENT_SECRET, the Workshop says image uploads are not set up yet.
#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
-#INVENTORY_MEDIA_APP_KEY=
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased
+
+- Workshop images reach OpenVibe.Media with Inventory's Network service token (audience `openvibe.media`, grant
+ `media.object.upload` on namespace `inventory`). `INVENTORY_MEDIA_APP_KEY` is gone (compatibility register C-71).
+
## 0.4.1 — 2026-10-09
## 0.4.1 — 2026-10-09
- Workshop takedown (ADR-054 §6): `/workshop/review` lists the published community badges. Staff can retire one (nobody
- Workshop takedown (ADR-054 §6): `/workshop/review` lists the published community badges. Staff can retire one (nobody
@@ -44,8 +44,9 @@ set and the ledger. Nothing written there is ever a credential, a key or a token
@@ -44,8 +44,9 @@ set and the ledger. Nothing written there is ever a credential, a key or a token
`OV_OAUTH_CLIENT_SECRET`.
`OV_OAUTH_CLIENT_SECRET`.
- **OpenVibe.Events** — the outbox relay publishes `inventory.*` and the account-export/deletion subscriptions are
- **OpenVibe.Events** — the outbox relay publishes `inventory.*` and the account-export/deletion subscriptions are
created there: `EVENTS_URL`, `INVENTORY_EVENTS_SECRET`, `EVENTS_RELAY_INTERVAL_MS`.
created there: `EVENTS_URL`, `INVENTORY_EVENTS_SECRET`, `EVENTS_RELAY_INTERVAL_MS`.
-- **OpenVibe.Media** — Workshop image uploads to this service's tenant: `OV_MEDIA_INTERNAL_URL`,
- `INVENTORY_MEDIA_APP_KEY`.
+- **OpenVibe.Media** — Workshop image uploads to this service's tenant use its Network service token
+ (`OV_OAUTH_CLIENT_SECRET`, audience `openvibe.media`): `OV_MEDIA_INTERNAL_URL`. Network grants
+ `[inventory, media.object.upload, openvibe.media]` on namespace `inventory`.
- **PostgreSQL** (`DATABASE_URL` to serve, `DATABASE_DIRECT_URL` to migrate) and, optionally, **Valkey** for shared
- **PostgreSQL** (`DATABASE_URL` to serve, `DATABASE_DIRECT_URL` to migrate) and, optionally, **Valkey** for shared
per-caller limit counters (`VALKEY_URL`).
per-caller limit counters (`VALKEY_URL`).
- **Packages** — `openvibe-contracts` and `openvibe-sdk` (auth, db, events, account-data, limits, service, valkey),
- **Packages** — `openvibe-contracts` and `openvibe-sdk` (auth, db, events, account-data, limits, service, valkey),
@@ -64,11 +64,10 @@ function load(env = process.env) {
@@ -64,11 +64,10 @@ function load(env = process.env) {
secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
},
// The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media. Without the
// The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media. Without the
- // key, submitting an image says uploads are not set up yet; everything else works.
+ // OAuth client secret, submitting an image says uploads are not set up yet; everything else works.
media: {
media: {
url: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
url: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
app: env.INVENTORY_MEDIA_APP || 'inventory',
app: env.INVENTORY_MEDIA_APP || 'inventory',
- appKey: env.INVENTORY_MEDIA_APP_KEY || '',
timeoutMs: Math.max(1000, int(env.INVENTORY_MEDIA_TIMEOUT_MS, 15000)),
timeoutMs: Math.max(1000, int(env.INVENTORY_MEDIA_TIMEOUT_MS, 15000)),
},
},
};
};
@@ -1,21 +1,40 @@
@@ -1,21 +1,40 @@
'use strict';
'use strict';
/**
/**
- * Workshop images in OpenVibe.Media: Inventory's own tenant (`inventory`, its app key in INVENTORY_MEDIA_APP_KEY),
- * a public `asset` object per badge, served by Media at https://openvibe.media/o/<med_…>. The badge is shown to
- * nobody until staff publish it (the pages never link an image of an item in review except to its creator and to
- * staff); a rejected or retired item keeps its object so the review record stays whole.
+ * Workshop images in OpenVibe.Media: Inventory's own tenant (`inventory`), using its Network service token
+ * (OV_OAUTH_CLIENT_SECRET, audience openvibe.media). A public `asset` object per badge is served by Media at
+ * https://openvibe.media/o/<med_…>. The badge is shown to nobody until staff publish it (the pages never link an
+ * image of an item in review except to its creator and staff); a rejected or retired item keeps its object so the
+ * review record stays whole.
*
*
* upload({ buffer, type, name, ownerSubject }) → med_… id (init → content → complete; throws with a short reason)
* upload({ buffer, type, name, ownerSubject }) → med_… id (init → content → complete; throws with a short reason)
*/
*/
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = console }) {
function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = console }) {
const base = () => `${String(config.media.url).replace(/\/+$/, '')}/api/v2/${encodeURIComponent(config.media.app)}/objects`;
const base = () => `${String(config.media.url).replace(/\/+$/, '')}/api/v2/${encodeURIComponent(config.media.app)}/objects`;
- const enabled = () => Boolean(config.media.appKey);
+ const enabled = () => Boolean(config.oauth.clientSecret);
+ let tokens;
async function call(path, { method = 'POST', json, body, headers = {} } = {}) {
async function call(path, { method = 'POST', json, body, headers = {} } = {}) {
- const h = { Accept: 'application/json', Authorization: `Bearer ${config.media.appKey}`, ...headers };
let payload = body;
let payload = body;
- if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
+ if (json !== undefined) payload = JSON.stringify(json);
+ let token;
+ try {
+ tokens ||= createServiceTokenClient({
+ tokenUrl: `${String(config.networkInternalUrl).replace(/\/+$/, '')}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.media',
+ fetch: fetchImpl,
+ });
+ token = await tokens.getToken();
+ } catch (err) {
+ log.warn('[Workshop] OpenVibe.Media service token unavailable:', (err && err.name) || '');
+ throw new Error('OpenVibe.Media did not answer (service token unavailable); try again in a minute.');
+ }
+ const h = { Accept: 'application/json', ...headers, Authorization: `Bearer ${token}` };
+ if (json !== undefined) h['Content-Type'] = 'application/json';
let res;
let res;
try {
try {
res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(config.media.timeoutMs) });
res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(config.media.timeoutMs) });
@@ -32,7 +51,7 @@ function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = conso
@@ -32,7 +51,7 @@ function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = conso
}
}
async function upload({ buffer, type, name, ownerSubject }) {
async function upload({ buffer, type, name, ownerSubject }) {
- if (!enabled()) throw new Error('Image uploads are not set up yet (INVENTORY_MEDIA_APP_KEY).');
+ if (!enabled()) throw new Error('Image uploads are not set up yet (OV_OAUTH_CLIENT_SECRET).');
// The same call shape as OpenVibe.MediaHub's client: the owner in X-OV-Subject, a printable-ASCII filename label.
// The same call shape as OpenVibe.MediaHub's client: the owner in X-OV-Subject, a printable-ASCII filename label.
const filename = String(name || 'badge').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').trim().slice(0, 100) || 'badge';
const filename = String(name || 'badge').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').trim().slice(0, 100) || 'badge';
const init = await call('', { headers: { 'X-OV-Subject': ownerSubject }, json: { kind: 'asset', visibility: 'public', size_bytes: buffer.length, mime_type: type, filename: `${filename}.${type === 'image/png' ? 'png' : 'webp'}` } });
const init = await call('', { headers: { 'X-OV-Subject': ownerSubject }, json: { kind: 'asset', visibility: 'public', size_bytes: buffer.length, mime_type: type, filename: `${filename}.${type === 'image/png' ? 'png' : 'webp'}` } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/** Workshop Media authentication without opening a socket. */
+const assert = require('assert');
+const { load } = require('../server/config');
+const { createWorkshopMedia } = require('../server/workshop/media');
+
+const upload = { buffer: Buffer.from('image'), type: 'image/png', name: 'badge', ownerSubject: 'usr_test' };
+const id = `med_${'0'.repeat(26)}`;
+
+(async () => {
+ const config = load({
+ OV_NETWORK_INTERNAL_URL: 'http://network.test',
+ OV_MEDIA_INTERNAL_URL: 'http://media.test',
+ OV_OAUTH_CLIENT_ID: 'inventory',
+ OV_OAUTH_CLIENT_SECRET: 'inventory-secret',
+ });
+ const requests = [];
+ const fetchImpl = async (url, opts) => {
+ requests.push({ url, opts });
+ if (url === 'http://network.test/oauth/token') {
+ return { ok: true, json: async () => ({ access_token: 'svc-media', token_type: 'Bearer', expires_in: 300 }) };
+ }
+ return { ok: true, json: async () => url.endsWith('/objects') ? { id } : {} };
+ };
+ const media = createWorkshopMedia({ config, fetchImpl });
+ assert.strictEqual(media.enabled(), true);
+ assert.strictEqual(requests.length, 0, 'the token client is lazy');
+ assert.strictEqual(await media.upload(upload), id);
+
+ const tokenRequests = requests.filter((r) => r.url.endsWith('/oauth/token'));
+ const mediaRequests = requests.filter((r) => r.url.startsWith('http://media.test/'));
+ assert.strictEqual(tokenRequests.length, 1, 'the token is cached for the upload');
+ assert.strictEqual(tokenRequests[0].opts.method, 'POST');
+ const form = new URLSearchParams(tokenRequests[0].opts.body);
+ assert.strictEqual(form.get('grant_type'), 'client_credentials');
+ assert.strictEqual(form.get('audience'), 'openvibe.media');
+ assert.strictEqual(form.get('client_id'), 'inventory');
+ assert.strictEqual(mediaRequests.length, 3);
+ for (const request of mediaRequests) assert.strictEqual(request.opts.headers.Authorization, 'Bearer svc-media');
+ assert.strictEqual(mediaRequests[0].opts.headers['X-OV-Subject'], upload.ownerSubject);
+
+ const noSecretRequests = [];
+ const noSecret = createWorkshopMedia({
+ config: load({ OV_OAUTH_CLIENT_SECRET: '' }),
+ fetchImpl: async (...args) => { noSecretRequests.push(args); throw new Error('unexpected request'); },
+ });
+ assert.strictEqual(noSecret.enabled(), false);
+ await assert.rejects(noSecret.upload(upload), /not set up yet \(OV_OAUTH_CLIENT_SECRET\)/);
+ assert.strictEqual(noSecretRequests.length, 0);
+
+ const failedRequests = [];
+ const failed = createWorkshopMedia({
+ config,
+ fetchImpl: async (url) => { failedRequests.push(url); throw new Error('inventory-secret'); },
+ log: { warn() {} },
+ });
+ const error = await failed.upload(upload).catch((err) => err);
+ assert.match(error.message, /OpenVibe\.Media did not answer \(service token unavailable\)/);
+ assert.ok(!error.message.includes('inventory-secret'));
+ assert.deepStrictEqual(failedRequests, ['http://network.test/oauth/token']);
+
+ console.log('workshop Media client: passed');
+})().catch((err) => { console.error(err); process.exit(1); });