openvibers/openvibe.inventory Commits

Inventory: Workshop images reach Media with the service token; the app key goes (C-71) (#31)

OP OpenVibers committed 11 Oct 2026 · 9a56c24834 · 6 files, +103 −15

Compatibility register C-71 groundwork. Workshop images reach OpenVibe.Media with Inventory's Network service token instead of the `inventory` tenant's app key.

- **Client:** `server/workshop/media.js` builds one SDK service-token client (client credentials, audience `openvibe.media`), lazily on the first upload, and sends `Authorization: Bearer <token>` on init, content and complete. `X-OV-Subject` still names the badge's owner; Media accepts it with a service token.
- **Not set up:** uploads count as set up when `OV_OAUTH_CLIENT_SECRET` is set. Without it, the Workshop says image uploads are not set up yet, as before without the key.
- **Token failure:** if Network cannot issue a token, the upload answers like Media being unreachable, with no secret in the message.
- **Removed:** `INVENTORY_MEDIA_APP_KEY` from config, `.env.example` and the README.
- **Grant:** `[inventory, media.object.upload, openvibe.media]` on namespace `inventory`, added by Network#145, which deploys first.
- **Test:** `test/workshop-media-client.test.js` checks the token request (`client_credentials`, audience `openvibe.media`), that the three Media calls carry the token, and that a client with no secret makes no request.

The code change was drafted by a Codex job and reviewed here. The review restored the shipped 0.4.0 changelog entry Codex had rewritten (the change has its own Unreleased line) and named the namespace. `ov test`: 18 covering files pass.

After deploy, `INVENTORY_MEDIA_APP_KEY` is removed from `inventory.env`, and Media drops the tenant's key in C-71.

Changes

ddiff --git a/.env.example b/.env.example
iindex a6c68cc..44878ac 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 50302fe..0692fc4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 964376a..9cef049 100644
---- a/README.md
++++ b/README.md
@@@ -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 are
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
per-caller limit counters (`VALKEY_URL`).
- **Packages** — `openvibe-contracts` and `openvibe-sdk` (auth, db, events, account-data, limits, service, valkey),
ddiff --git a/server/config.js b/server/config.js
iindex b66268c..0f081b0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
},
};
ddiff --git a/server/workshop/media.js b/server/workshop/media.js
iindex 3443610..5d393f8 100644
---- a/server/workshop/media.js
++++ b/server/workshop/media.js
@@@ -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'}` } });
ddiff --git a/test/workshop-media-client.test.js b/test/workshop-media-client.test.js
nnew file mode 100644
iindex 0000000..523d9fe
---- /dev/null
++++ b/test/workshop-media-client.test.js
@@@ -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); });