openvibers/openvibe.ai Commits

AI: a MediaRef is a Media object id (fetched at /o/<id>); legacy refs are refused (C-24) (#54)

OP OpenVibers committed 11 Oct 2026 · f9450adf5c · 3 files, +19 −20

This closes compatibility register C-24. A MediaRef is now a Media object id, and AI can fetch one; the `legacy:<svc>:<kind>:<id>` form is refused.

**Before:** only legacy refs resolved; they mapped to Media's old `/v/`, `/c/`, `/t/`, `/p/…/raw` and `/f/` URLs. A `med_` id, the form the contract names, was an explicit `media.unresolvable` "until the Wave 4 object API exists". That API exists.

**Now:**
- **Resolution:** `mediaRefUrl` maps `med_<ULID>` to `<Media>/o/<id>`, Media's public object-bytes route. It serves any public, ready object, legacy-backed VODs, clips and thumbnails included. It redirects to the storage copy (B2/R2), which the fetcher already allows only as a hop from Media. A private or missing object answers 404.
- **No new access:** a MediaRef never reaches more than its URL would, and the resolved URL goes through the same `judge()` rules as any media URL.
- **Refused:** anything that is not `med_` plus 26 ULID characters, legacy refs included, is `media.unresolvable`. The workflow schemas' `MediaRef.media_id` pattern is narrowed to match.

**Safe to refuse legacy refs:** of AI's 30,957 runs (2026-09-23 to today), none sent a legacy ref, nor a MediaRef of any kind (`runs.input`). No caller in any OpenVibers repo builds one for AI. The contract's `media-ref.v1` keeps the legacy form, because lineage and Media still use it for their own ids; AI simply accepts the narrower set.

`test/ssrf.test.js` covers the `med_` mapping, the resolved URL passing `judge()`, and the refusal of legacy, malformed and empty ids. `ov test`: 6 covering files pass.

Changes

ddiff --git a/server/fetcher.js b/server/fetcher.js
iindex fc140c4..da7ab55 100644
---- a/server/fetcher.js
++++ b/server/fetcher.js
@@@ -14,8 +14,10 @@
* public hop (an allow-listed host that redirects must not reach the loopback Media service)
* - a byte cap, a timeout and cancellation on every transfer
* data: URLs (images a caller sends inline) are decoded locally with a size cap — never fetched.
-- * A MediaRef resolves to the public Media URL for the legacy kinds; med_ ids are an explicit
-- * `media.unresolvable` until the Wave 4 object API exists.
++ * A MediaRef (a Media object id, med_…) resolves to that object's public bytes URL, <Media>/o/<id>:
++ * Media serves a public, ready object there (redirecting to its storage copy) and answers 404 for a
++ * private one, so a MediaRef never reaches more than its URL would. The `legacy:<svc>:<kind>:<id>`
++ * form is refused since 2026-10-11 (compatibility register C-24: no run ever sent one).
*/
const dns = require('dns');
const net = require('net');
@@@ -26,6 +28,9 @@ const os = require('os');
const path = require('path');
const { AiError } = require('./util');
++/** A Media object id (contracts media/media-ref.v1: med_ + a ULID). */
++const MEDIA_OBJECT_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++
const blocked = new net.BlockList();
for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(a, p, 'ipv4');
@@@ -217,22 +222,11 @@ function createFetcher(config, { transport = null } = {}) {
return url;
}
-- /** A MediaRef -> the public URL of that object (legacy kinds only for now). */
++ /** A MediaRef -> the public bytes URL of that Media object. */
function mediaRefUrl(ref) {
-- const id = ref && ref.media_id;
-- const mm = /^legacy:([a-z][a-z0-9-]{1,39}):(vod|clip|file|paste|thumbnail|avatar):([A-Za-z0-9._/-]{1,200})$/.exec(String(id || ''));
-- if (!mm) throw new AiError(422, 'media.unresolvable', `media ${id} cannot be resolved by this service yet`);
-- const [, , kind, key] = mm;
-- const base = m.publicUrl;
-- if (key.includes('..')) throw new AiError(422, 'media.unresolvable', 'bad media key');
-- switch (kind) {
-- case 'vod': return `${base}/v/${encodeURIComponent(key)}`;
-- case 'clip': return `${base}/c/${encodeURIComponent(key)}`;
-- case 'thumbnail': return `${base}/t/${encodeURIComponent(key)}`;
-- case 'paste': return `${base}/p/${encodeURIComponent(key)}/raw`;
-- case 'file': return `${base}/f/${key.split('/').map(encodeURIComponent).join('/')}`;
-- default: throw new AiError(422, 'media.unresolvable', `media kind ${kind} is not fetched`);
-- }
++ const id = String((ref && ref.media_id) || '');
++ if (!MEDIA_OBJECT_ID.test(id)) throw new AiError(422, 'media.unresolvable', `media ${id || '(none)'} is not a Media object id (med_…)`);
++ return `${m.publicUrl}/o/${id}`;
}
/** Image input { url } | { data_url } | { media: MediaRef } -> { mediaType, base64 }. */
ddiff --git a/server/workflows/common-schemas.js b/server/workflows/common-schemas.js
iindex b3e7ff2..b2cc1f0 100644
---- a/server/workflows/common-schemas.js
++++ b/server/workflows/common-schemas.js
@@@ -4,7 +4,7 @@
const MEDIA_REF = {
type: 'object', additionalProperties: false, required: ['media_id'],
properties: {
-- media_id: { type: 'string', pattern: '^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$' },
++ media_id: { type: 'string', pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' },
role: { type: 'string' }, variant: { type: 'string' },
},
};
ddiff --git a/test/ssrf.test.js b/test/ssrf.test.js
iindex f1ccb01..496a3f6 100644
---- a/test/ssrf.test.js
++++ b/test/ssrf.test.js
@@@ -53,8 +53,13 @@ t.test('URL rules: https + allow-listed host only', async () => {
}
assert.ok(f.judge('https://openvibe.media/v/1'));
assert.ok(f.judge('https://ai.openvibe.network/x'), '*.openvibe.network');
-- assert.strictEqual(f.mediaRefUrl({ media_id: 'legacy:live:vod:42' }), 'https://openvibe.media/v/42');
-- assert.throws(() => f.mediaRefUrl({ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPA' }), e => e.code === 'media.unresolvable');
++ // A MediaRef is a Media object id and resolves to its public bytes URL (Media 404s a private one).
++ assert.strictEqual(f.mediaRefUrl({ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPA' }), 'https://openvibe.media/o/med_01JAB2C3D4E5F6G7H8J9K0MNPA');
++ assert.ok(f.judge(f.mediaRefUrl({ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPA' })), 'the resolved URL passes the same URL rules');
++ // The legacy:<svc>:<kind>:<id> form is refused (C-24), as is anything else that is not an object id.
++ for (const id of ['legacy:live:vod:42', 'med_01JAB2C3D4E5F6G7H8J9K0MNP', 'med_01JAB2C3D4E5F6G7H8J9K0MNPA/../x', '', null]) {
++ assert.throws(() => f.mediaRefUrl({ media_id: id }), e => e.code === 'media.unresolvable', String(id));
++ }
});
t.test('an allow-listed NAME that resolves to an internal address is refused at connect time', async () => {