openvibers/openvibe.sdk Commits
0.5.0: Media object API v2 client (createObjectsClient in openvibe-sdk/media)
Changes
@@@ -3,6 +3,40 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.5.0 (2026-09-23)++++Media's object API v2 (`/api/v2/:app/objects`) and its jobs (`/api/v2/:app/jobs`), wrapped as++`createObjectsClient()` in `openvibe-sdk/media`. Additive: nothing existing changes.++++- **`createObjectsClient({ app, baseUrl?, tokenClient | apiKey | client, … })`.** `tokenClient` is a++ `createServiceTokenClient()` (a service or developer-app principal with `media.object.upload` and++ `media.object.read` for namespace `app`; a developer app uses its project id as `app`). Options:++ `actingUserId` (X-OV-User-Id, app key only), `subject` (X-OV-Subject), `multipartThreshold` (64 MiB),++ `partSize` (16 MiB), `concurrency` (4), `hashMaxBytes` (256 MiB), `resumeRounds` (3).++- **Discovery.** Without `baseUrl`, Media's origin comes from the platform descriptor++ (`/.well-known/openvibe`). The descriptor gives an origin only for live services: when Media has++ none (not registered, or only a `planned_origin`) the client throws `sdk.service_unavailable` and++ never guesses. `objects.baseUrl()` says which origin is used.++- **`upload(data, { kind, visibility, mimeType, filename, metadata, contentHash, multipart, onProgress, signal, … })`**++ picks single or multipart by size. Single: init, then the bytes go to Media's presigned PUT URL (no++ credential on that request), then complete. Multipart (above `multipartThreshold`, with++ `multipart: true`, or when Media answers 413 `media.object.too_large` to a single part): parts go up++ `concurrency` at a time with `X-Content-SHA256`; after each round the session is read back and the++ missing parts are sent again; complete names every part's sha256. The whole object's sha256 is sent++ for Media to verify up to `hashMaxBytes`. A part that cannot be sent ends in `sdk.upload_incomplete`++ with `err.resume = { objectId, uploadId, missing }`.++- **`resume({ objectId, uploadId }, data)`** continues a multipart session, e.g. from another process:++ it reads the session with the client's credential, re-sends parts Media holds with a different sha256++ and the missing ones, and completes.++- **`get(id)`** (null on 404; `med_…` ids and legacy refs), **`signedUrl(id, { ttl })`** (`{ url,++ expires_at, public }`: a signed link for private objects), **`delete(id)`** (true, or false on 404),++ **`list(q)`** (one page) and **`iterate(q)`** (every object, newest first).++- **`jobs.create({ type, objectId, params, idempotencyKey })`**, `get`, `list`, `approve` (a proposal ->++ queued), `cancel` and `wait(id)` (polls until succeeded, failed or cancelled). Types today:++ `thumbnail.regenerate`, `invariant.scan`, `object.split`, `object.remux`.++- Types (`MediaObject`, `MediaJob`, `ObjectsClient`, `ObjectUploadOptions`), the ESM entry and the++ browser bundle include it.++## 0.4.0 (2026-09-23)Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:@@@ -2,7 +2,7 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.4.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Production services pin three releases (2026-09-23): v0.4.0 in Live, Deals, News, Reviews, Tips, Trade and VIP; v0.3.1 in Codes; v0.2.2 in Network, Media, Blog, Wiki, Coupons, Host, OpenRe.Stream and Games. OpenVibe.Examples uses v0.4.0. CI is green from `654d4b2` (it now runs `npm ci`); the runs for the v0.3.0, v0.3.1 and v0.4.0 commits failed because `better-sqlite3` was not installed.++**Status:** alpha, v0.5.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4). v0.5.0 is not tagged yet: the install line below stays on v0.4.0 until it is. Tested against local stub servers and the built-in mock platform only, never against the live platform. Production services pin three releases (2026-09-23): v0.4.0 in Live, Deals, News, Reviews, Tips, Trade and VIP; v0.3.1 in Codes; v0.2.2 in Network, Media, Blog, Wiki, Coupons, Host, OpenRe.Stream and Games. OpenVibe.Examples uses v0.4.0. CI is green from `654d4b2` (it now runs `npm ci`); the runs for the v0.3.0, v0.3.1 and v0.4.0 commits failed because `better-sqlite3` was not installed.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.@@@ -157,7 +157,7 @@ const res = await jobs.file(job.id, 0);| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services || `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` || `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |--| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |++| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted || `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` || `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()` || `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |@@@ -275,7 +275,7 @@ Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()## Not wrapped yet (intentionally)- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.--- **Media v2 objects** (`med_` ids, ADR-006, `/api/v2/:app/objects`) exist on Media but are not wrapped yet. This release wraps only the v1 files API, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).++- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.- **Chat, Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat has no app principal (`chat.message.send` is `first-party`), so there is no Chat client and no Chat mock.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.4.0",++ "version": "0.5.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","updated": "2026-09-23"}@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.4.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.5.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -19,9 +19,9 @@ module.exports = {modules: __load(10),realtime: __load(11),media: __load(12),-- community: __load(13),-- jobs: __load(14),-- projects: __load(15),++ community: __load(14),++ jobs: __load(15),++ projects: __load(16),};},// ── 1: src/core/index.js ───────────────────────────────────────────@@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.4.0';++const SDK_VERSION = '0.5.0';module.exports = {createClient,@@@ -1192,7 +1192,9 @@ module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };function (module, exports) {'use strict';/**-- * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.++ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files), public URL helpers, and++ * createObjectsClient() for the object API v2 (/api/v2/:app/objects: single and multipart uploads,++ * signed links, jobs), see ./objects.js.** Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a* secret), a Network service token for the app's namespace, or a developer app token on its@@@ -1208,6 +1210,7 @@ function (module, exports) {*/const { isOpenVibeError } = __load(3);const { paginate, offsetPager } = __load(7);++const { createObjectsClient } = __load(13);const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';const enc = encodeURIComponent;@@@ -1297,9 +1300,328 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOreturn { app, files, urls, upload: files.upload };}--module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };++module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };},--// ── 13: src/community.js ────────────────────────────────────────────++// ── 13: src/objects.js ──────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * createObjectsClient(): OpenVibe.Media's object API v2 (/api/v2/:app/objects) and its jobs++ * (/api/v2/:app/jobs). Exported from openvibe-sdk/media. Browser-safe code (fetch, Blob, Web Crypto),++ * but the credentials it needs belong on a server.++ *++ * const objects = createObjectsClient({ app: 'demo', tokenClient: createServiceTokenClient({ clientId, clientSecret }) });++ * const obj = await objects.upload(bytes, { kind: 'file', mimeType: 'image/png', filename: 'a.png' });++ * const { url } = await objects.signedUrl(obj.id, { ttl: 300 });++ *++ * Base URL: `baseUrl`, or discovered from the Network's platform descriptor (/.well-known/openvibe).++ * The descriptor gives an origin only for services that are live; when Media has none (not++ * registered, or only a planned origin) the client throws sdk.service_unavailable instead of guessing.++ *++ * Credentials (one of): `tokenClient` (createServiceTokenClient: a service or developer-app principal++ * with media.object.upload / media.object.read for namespace `app`; a developer app uses its project++ * id as `app`), `apiKey` (the app's Media API key), or a `client` from createClient() that carries its++ * own. `actingUserId` (app key only) acts for one of the app's users (X-OV-User-Id); `subject` names++ * the owner (X-OV-Subject: usr_…).++ *++ * upload() picks single or multipart by size: single PUT to Media's presigned URL up to++ * `multipartThreshold` (default 64 MiB), multipart above it (or when Media says the object is too++ * large for one part). Parts go up `concurrency` at a time with their sha256; failed parts are retried++ * and, after the round, the session is read back and whatever is missing is sent again (resumable++ * after a dropped connection; `resume()` continues a session from another process). The whole++ * object's sha256 is sent for Media to verify when the data is at most `hashMaxBytes` (256 MiB).++ */++const { createClient } = __load(2);++const { OpenVibeError, isOpenVibeError } = __load(3);++const { paginate } = __load(7);++++const MIB = 1024 * 1024;++const enc = encodeURIComponent;++const TERMINAL_JOB = new Set(['succeeded', 'failed', 'cancelled']);++++function toBody(data) {++ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;++ if (typeof data === 'string') return new TextEncoder().encode(data);++ if (data instanceof ArrayBuffer) return new Uint8Array(data);++ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);++ throw new TypeError('objects.upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');++}++const sizeOf = (body) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.size : body.byteLength);++const slice = (body, start, end) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.slice(start, end) : body.subarray(start, end));++++async function sha256Hex(part) {++ const subtle = globalThis.crypto && globalThis.crypto.subtle;++ if (!subtle) return null;++ const bytes = typeof Blob !== 'undefined' && part instanceof Blob ? new Uint8Array(await part.arrayBuffer()) : part;++ const digest = await subtle.digest('SHA-256', bytes);++ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');++}++++function createObjectsClient({++ app, baseUrl, tokenClient, apiKey, client, network, fetch: fetchImpl, discoveryUrl,++ actingUserId, subject, multipartThreshold = 64 * MIB, partSize = 16 * MIB, concurrency = 4,++ hashMaxBytes = 256 * MIB, resumeRounds = 3, timeoutMs, partTimeoutMs = 10 * 60 * 1000,++} = {}) {++ if (!app) throw new TypeError('createObjectsClient: app (the tenant: an app id, or a developer project id prj_…) is required');++ if (!client && !tokenClient && !apiKey) throw new TypeError('createObjectsClient: pass tokenClient, apiKey or client');++ const http = client || createClient({++ network, discoveryUrl, fetch: fetchImpl, ...(tokenClient ? { tokenProvider: tokenClient } : {}), ...(timeoutMs ? { timeoutMs } : {}),++ });++ const O = `/api/v2/${enc(app)}/objects`;++ const J = `/api/v2/${enc(app)}/jobs`;++ let base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : null;++++ /** Media's origin: the given baseUrl, or the descriptor's live origin for `media`. */++ async function resolveBase() {++ if (base) return base;++ const d = await http.discover();++ const s = (d.services || []).find((x) => x && x.id === 'media');++ if (!s || !s.origin) {++ const why = !s ? 'Media is not in the platform descriptor' : `Media has no live origin in the platform descriptor (status ${s.status || 'unknown'}${s.planned_origin ? `, planned at ${s.planned_origin}` : ''})`;++ throw new OpenVibeError({ code: 'sdk.service_unavailable', message: `${why}; pass baseUrl to use a Media you know is up` });++ }++ base = String(s.origin).replace(/\/+$/, '');++ return base;++ }++++ const headersFor = (per = {}) => {++ const h = { ...(per.headers || {}) };++ const acting = per.actingUserId !== undefined ? per.actingUserId : actingUserId;++ if (acting != null) h['X-OV-User-Id'] = String(acting);++ const subj = per.subject !== undefined ? per.subject : subject;++ if (subj) h['X-OV-Subject'] = String(subj);++ return h;++ };++ const call = async (opts, per = {}) => http.request({++ service: 'media', audience: 'openvibe.media', baseUrl: await resolveBase(), ...(apiKey ? { token: apiKey } : {}),++ ...opts, headers: headersFor({ ...per, headers: opts.headers }),++ });++ const json = async (opts, per) => (await call(opts, per)).data;++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ /** A presigned Media URL: no Authorization header (the token is in the URL). */++ const presigned = (url, opts) => http.request({ url, auth: false, ...opts });++++ // ── Upload ───────────────────────────────────────────────++++ async function init(body, per) {++ return json({ method: 'POST', path: O, json: body, idempotencyKey: false }, per);++ }++++ /**++ * How one multipart session is driven: through its presigned URLs (a fresh upload) or, for++ * resume(), with our own credential on the same routes (Media accepts either).++ */++ function sessionIo(session, { objectId, credential = false } = {}) {++ const path = `${O}/${enc(objectId)}/multipart/${enc(session.upload_id)}`;++ return {++ partSize: session.part_size,++ partsExpected: session.parts_expected,++ status: (signal) => (credential ? json({ path, signal }) : presigned(session.status_url, { method: 'GET', signal }).then((r) => r.data)),++ putPart: (n, part, headers, signal) => (credential++ ? call({ method: 'PUT', path: `${path}/parts/${n}`, body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })++ : presigned(session.part_url_template.replace('{part_number}', String(n)), { method: 'PUT', body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })),++ complete: (body, signal) => (credential++ ? json({ method: 'POST', path: `${path}/complete`, json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 })++ : presigned(session.complete_url, { method: 'POST', json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 }).then((r) => r.data)),++ };++ }++++ async function putParts(io, body, todo, { signal, onProgress, total, done, shaOf }) {++ const failed = [];++ let next = 0;++ const worker = async () => {++ while (next < todo.length) {++ const n = todo[next++];++ const start = (n - 1) * io.partSize;++ const part = slice(body, start, Math.min(start + io.partSize, total));++ const sha = await shaOf(n);++ try {++ await io.putPart(n, part, { 'Content-Type': 'application/octet-stream', ...(sha ? { 'X-Content-SHA256': sha } : {}) }, signal);++ done.bytes += sizeOf(part);++ if (onProgress) onProgress({ uploadedBytes: done.bytes, totalBytes: total, part: n });++ } catch (err) {++ if (err && err.code === 'sdk.aborted') throw err;++ failed.push({ part: n, error: err });++ }++ }++ };++ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, todo.length)) }, worker));++ return failed;++ }++++ /**++ * Send what a session is missing (round after round, reading it back each time), then complete it.++ * Parts Media already holds (a resumed session) are checked against our bytes; any that differ are++ * sent again. The complete call names every part's sha256 as computed here.++ */++ async function finishMultipart(objectId, uploadId, io, body, { signal, onProgress, contentHash, total }) {++ const mine = new Map();++ const shaOf = async (n) => {++ if (!mine.has(n)) {++ const start = (n - 1) * io.partSize;++ mine.set(n, await sha256Hex(slice(body, start, Math.min(start + io.partSize, total))));++ }++ return mine.get(n);++ };++ const done = { bytes: 0 };++ let status = await io.status(signal);++ if (status.total_size != null && status.total_size !== total) {++ throw new OpenVibeError({ code: 'sdk.upload_mismatch', message: `objects.upload: the session expects ${status.total_size} bytes, the data has ${total}` });++ }++ const stale = [];++ for (const p of status.parts || []) {++ const ours = await shaOf(p.part_number);++ if (ours && p.sha256 && ours !== p.sha256) stale.push(p.part_number);++ else done.bytes += p.size_bytes;++ }++ let todo = [...(status.missing || []), ...stale].sort((a, b) => a - b);++ let lastError = null;++ for (let round = 0; round <= resumeRounds && todo.length; round++) {++ const failed = await putParts(io, body, todo, { signal, onProgress, total, done, shaOf });++ if (failed.length) lastError = failed[0].error;++ status = await io.status(signal);++ todo = status.missing || [];++ }++ if (todo.length) {++ const err = new OpenVibeError({++ code: 'sdk.upload_incomplete', cause: lastError || undefined,++ message: `objects.upload: ${todo.length} part(s) could not be sent; call resume(err.resume, data) to continue`,++ });++ err.resume = { objectId, uploadId, missing: todo };++ throw err;++ }++ const parts = [];++ for (let n = 1; n <= io.partsExpected; n++) {++ const sha = await shaOf(n);++ if (sha) parts.push({ part_number: n, sha256: sha });++ }++ return io.complete({ ...(contentHash ? { content_hash: contentHash } : {}), ...(parts.length ? { parts } : {}) }, signal);++ }++++ /**++ * upload(data, { kind = 'file', visibility = 'private', mimeType | contentType, filename, metadata,++ * contentHash (hex | false), userId, subject, actingUserId, multipart ('auto' | true | false),++ * partSize, onProgress({ uploadedBytes, totalBytes, part }), signal, uploadTtl })++ * -> the object (Media's public shape, lifecycle_status 'ready')++ */++ async function upload(data, opts = {}) {++ const {++ kind = 'file', visibility = 'private', filename, metadata, userId, multipart = 'auto', signal, onProgress, uploadTtl,++ } = opts;++ const mime = opts.mimeType || opts.contentType || (typeof Blob !== 'undefined' && data instanceof Blob && data.type) || null;++ const body = toBody(data);++ const total = sizeOf(body);++ if (!total) throw new TypeError('objects.upload: nothing to upload (0 bytes)');++ const per = { actingUserId: opts.actingUserId, subject: opts.subject };++ let contentHash = opts.contentHash === false ? null : opts.contentHash || null;++ if (!contentHash && opts.contentHash !== false && total <= hashMaxBytes) contentHash = await sha256Hex(body);++ const wantParts = multipart === true || (multipart === 'auto' && total > multipartThreshold);++ const req = {++ kind, visibility, size_bytes: total, ...(mime ? { mime_type: mime } : {}), ...(filename ? { filename } : {}),++ ...(metadata ? { metadata } : {}), ...(contentHash ? { content_hash: contentHash } : {}), ...(userId != null ? { user_id: userId } : {}),++ ...(uploadTtl ? { upload_ttl: uploadTtl } : {}),++ };++ let created;++ try {++ created = await init(wantParts ? { ...req, multipart: true, part_size: opts.partSize || partSize } : req, per);++ } catch (err) {++ // Media's single-part limit is lower than our threshold: go multipart.++ if (!(multipart === 'auto' && !wantParts && isOpenVibeError(err) && err.status === 413 && err.code === 'media.object.too_large')) throw err;++ created = await init({ ...req, multipart: true, part_size: opts.partSize || partSize }, per);++ }++ const up = created.upload || {};++ if (up.multipart) {++ return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });++ }++ await presigned(up.url, {++ method: 'PUT', body, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2,++ headers: { 'Content-Type': mime || 'application/octet-stream' },++ });++ if (onProgress) onProgress({ uploadedBytes: total, totalBytes: total });++ return json({ method: 'POST', path: `${O}/${enc(created.id)}/complete`, json: contentHash ? { content_hash: contentHash } : {}, idempotent: true }, per);++ }++++ /**++ * Continue a multipart upload, e.g. from another process after a crash: reads the session with++ * this client's credential, sends the parts it is missing from `data` (the same bytes), completes++ * it. `ref` is err.resume from upload(), or { objectId, uploadId }.++ */++ async function resume({ objectId, uploadId }, data, { signal, onProgress, contentHash } = {}) {++ const body = toBody(data);++ const total = sizeOf(body);++ const session = await json({ path: `${O}/${enc(objectId)}/multipart/${enc(uploadId)}`, signal });++ if (session.status !== 'active') throw new OpenVibeError({ code: 'sdk.upload_incomplete', message: `objects.resume: the upload is ${session.status}` });++ const hash = contentHash === false ? null : contentHash || (total <= hashMaxBytes ? await sha256Hex(body) : null);++ return finishMultipart(objectId, uploadId, sessionIo(session, { objectId, credential: true }), body, { signal, onProgress, contentHash: hash, total });++ }++++ // ── Reads, links, delete ─────────────────────────────────++++ /** Object metadata (locations, lifecycle, public_url …), or null. Accepts med_… ids and legacy refs. */++ const get = (id) => orNull(json({ path: `${O}/${enc(id)}` }));++++ /**++ * signedUrl(id, { ttl }) -> { url, expires_at, public }. Private objects get a signed link valid++ * `ttl` seconds (30-3600); public and unlisted ones their public URL (expires_at null).++ */++ const signedUrl = (id, { ttl } = {}) => json({ path: `${O}/${enc(id)}/download`, query: { format: 'json', ttl } });++++ /** Soft delete. true when deleted (or already), false when there is no such object. */++ async function del(id) {++ try { await call({ method: 'DELETE', path: `${O}/${enc(id)}` }); return true; } catch (err) {++ if (isOpenVibeError(err) && err.status === 404) return false;++ throw err;++ }++ }++++ /** One page: { objects, next_cursor, limit }. */++ const list = ({ kind, visibility, status, owner, userId, limit, cursor, includeDeleted } = {}) => json({++ path: O, query: { kind, visibility, status, owner, user_id: userId, limit, cursor, include_deleted: includeDeleted ? 1 : undefined },++ });++++ /** Async iterator over every matching object, newest first. */++ const iterate = (q = {}) => paginate(async (cursor) => {++ const page = await list({ ...q, cursor: cursor || undefined });++ return { items: page.objects || [], next: page.next_cursor };++ }, { cursor: null });++++ // ── Jobs ─────────────────────────────────────────────────++++ const jobs = {++ /** create({ type, objectId, params, idempotencyKey, maxAttempts }) -> job (a repeat of the same key answers the same job). */++ async create({ type, objectId, params, idempotencyKey, maxAttempts } = {}) {++ if (!type) throw new TypeError('jobs.create: type is required');++ const res = await json({++ method: 'POST', path: J, idempotencyKey: idempotencyKey || undefined,++ json: { type, ...(objectId ? { object_id: objectId } : {}), ...(params ? { params } : {}), ...(maxAttempts ? { max_attempts: maxAttempts } : {}) },++ });++ return res.job;++ },++ get: async (id) => { const r = await orNull(json({ path: `${J}/${enc(id)}` })); return r ? r.job : null; },++ /** { jobs, next_cursor, limit } */++ list: ({ status, type, objectId, limit, cursor } = {}) => json({ path: J, query: { status, type, object_id: objectId, limit, cursor } }),++ /** A proposal (status proposed) -> queued. */++ approve: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/approve`, idempotent: true })).job,++ /** Cancel: proposed/queued at once; a running job reports cancel_requested until it stops. */++ cancel: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/cancel`, idempotent: true })).job,++ /** Poll until the job is succeeded, failed or cancelled (or timeoutMs passes: the job as it is then). */++ async wait(id, { intervalMs = 1000, timeoutMs: max = 10 * 60 * 1000, signal } = {}) {++ const end = Date.now() + max;++ for (;;) {++ const job = await jobs.get(id);++ if (!job || TERMINAL_JOB.has(job.status) || Date.now() >= end) return job;++ if (signal && signal.aborted) throw new OpenVibeError({ code: 'sdk.aborted', message: 'jobs.wait: aborted' });++ await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, end - Date.now()))));++ }++ },++ };++++ return { app, baseUrl: resolveBase, upload, resume, get, signedUrl, delete: del, list, iterate, jobs, client: http };++}++++module.exports = { createObjectsClient };++},++// ── 14: src/community.js ────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -1418,7 +1740,7 @@ function createCommunityClient(client, defaults = {}) {module.exports = { createCommunityClient, actingHeaders };},--// ── 14: src/jobs.js ─────────────────────────────────────────────────++// ── 15: src/jobs.js ─────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -1588,7 +1910,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openmodule.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };},--// ── 15: src/projects.js ─────────────────────────────────────────────++// ── 16: src/projects.js ─────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2,6 +2,6 @@import cjs from '../src/media.js';export const {-- createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN,++ createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.4.0",++ "version": "0.5.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.4.0",++ "version": "0.5.0","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.4.0",++ "version": "0.5.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.4.0';++const SDK_VERSION = '0.5.0';module.exports = {createClient,@@@ -1,6 +1,8 @@'use strict';/**-- * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.++ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files), public URL helpers, and++ * createObjectsClient() for the object API v2 (/api/v2/:app/objects: single and multipart uploads,++ * signed links, jobs), see ./objects.js.** Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a* secret), a Network service token for the app's namespace, or a developer app token on its@@@ -16,6 +18,7 @@*/const { isOpenVibeError } = require('./core/errors');const { paginate, offsetPager } = require('./core/paginate');++const { createObjectsClient } = require('./objects');const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';const enc = encodeURIComponent;@@@ -105,4 +108,4 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOreturn { app, files, urls, upload: files.upload };}--module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };++module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };nnew file mode 100644@@@ -0,0 +1,316 @@++'use strict';++/**++ * createObjectsClient(): OpenVibe.Media's object API v2 (/api/v2/:app/objects) and its jobs++ * (/api/v2/:app/jobs). Exported from openvibe-sdk/media. Browser-safe code (fetch, Blob, Web Crypto),++ * but the credentials it needs belong on a server.++ *++ * const objects = createObjectsClient({ app: 'demo', tokenClient: createServiceTokenClient({ clientId, clientSecret }) });++ * const obj = await objects.upload(bytes, { kind: 'file', mimeType: 'image/png', filename: 'a.png' });++ * const { url } = await objects.signedUrl(obj.id, { ttl: 300 });++ *++ * Base URL: `baseUrl`, or discovered from the Network's platform descriptor (/.well-known/openvibe).++ * The descriptor gives an origin only for services that are live; when Media has none (not++ * registered, or only a planned origin) the client throws sdk.service_unavailable instead of guessing.++ *++ * Credentials (one of): `tokenClient` (createServiceTokenClient: a service or developer-app principal++ * with media.object.upload / media.object.read for namespace `app`; a developer app uses its project++ * id as `app`), `apiKey` (the app's Media API key), or a `client` from createClient() that carries its++ * own. `actingUserId` (app key only) acts for one of the app's users (X-OV-User-Id); `subject` names++ * the owner (X-OV-Subject: usr_…).++ *++ * upload() picks single or multipart by size: single PUT to Media's presigned URL up to++ * `multipartThreshold` (default 64 MiB), multipart above it (or when Media says the object is too++ * large for one part). Parts go up `concurrency` at a time with their sha256; failed parts are retried++ * and, after the round, the session is read back and whatever is missing is sent again (resumable++ * after a dropped connection; `resume()` continues a session from another process). The whole++ * object's sha256 is sent for Media to verify when the data is at most `hashMaxBytes` (256 MiB).++ */++const { createClient } = require('./core/client');++const { OpenVibeError, isOpenVibeError } = require('./core/errors');++const { paginate } = require('./core/paginate');++++const MIB = 1024 * 1024;++const enc = encodeURIComponent;++const TERMINAL_JOB = new Set(['succeeded', 'failed', 'cancelled']);++++function toBody(data) {++ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;++ if (typeof data === 'string') return new TextEncoder().encode(data);++ if (data instanceof ArrayBuffer) return new Uint8Array(data);++ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);++ throw new TypeError('objects.upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');++}++const sizeOf = (body) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.size : body.byteLength);++const slice = (body, start, end) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.slice(start, end) : body.subarray(start, end));++++async function sha256Hex(part) {++ const subtle = globalThis.crypto && globalThis.crypto.subtle;++ if (!subtle) return null;++ const bytes = typeof Blob !== 'undefined' && part instanceof Blob ? new Uint8Array(await part.arrayBuffer()) : part;++ const digest = await subtle.digest('SHA-256', bytes);++ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');++}++++function createObjectsClient({++ app, baseUrl, tokenClient, apiKey, client, network, fetch: fetchImpl, discoveryUrl,++ actingUserId, subject, multipartThreshold = 64 * MIB, partSize = 16 * MIB, concurrency = 4,++ hashMaxBytes = 256 * MIB, resumeRounds = 3, timeoutMs, partTimeoutMs = 10 * 60 * 1000,++} = {}) {++ if (!app) throw new TypeError('createObjectsClient: app (the tenant: an app id, or a developer project id prj_…) is required');++ if (!client && !tokenClient && !apiKey) throw new TypeError('createObjectsClient: pass tokenClient, apiKey or client');++ const http = client || createClient({++ network, discoveryUrl, fetch: fetchImpl, ...(tokenClient ? { tokenProvider: tokenClient } : {}), ...(timeoutMs ? { timeoutMs } : {}),++ });++ const O = `/api/v2/${enc(app)}/objects`;++ const J = `/api/v2/${enc(app)}/jobs`;++ let base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : null;++++ /** Media's origin: the given baseUrl, or the descriptor's live origin for `media`. */++ async function resolveBase() {++ if (base) return base;++ const d = await http.discover();++ const s = (d.services || []).find((x) => x && x.id === 'media');++ if (!s || !s.origin) {++ const why = !s ? 'Media is not in the platform descriptor' : `Media has no live origin in the platform descriptor (status ${s.status || 'unknown'}${s.planned_origin ? `, planned at ${s.planned_origin}` : ''})`;++ throw new OpenVibeError({ code: 'sdk.service_unavailable', message: `${why}; pass baseUrl to use a Media you know is up` });++ }++ base = String(s.origin).replace(/\/+$/, '');++ return base;++ }++++ const headersFor = (per = {}) => {++ const h = { ...(per.headers || {}) };++ const acting = per.actingUserId !== undefined ? per.actingUserId : actingUserId;++ if (acting != null) h['X-OV-User-Id'] = String(acting);++ const subj = per.subject !== undefined ? per.subject : subject;++ if (subj) h['X-OV-Subject'] = String(subj);++ return h;++ };++ const call = async (opts, per = {}) => http.request({++ service: 'media', audience: 'openvibe.media', baseUrl: await resolveBase(), ...(apiKey ? { token: apiKey } : {}),++ ...opts, headers: headersFor({ ...per, headers: opts.headers }),++ });++ const json = async (opts, per) => (await call(opts, per)).data;++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ /** A presigned Media URL: no Authorization header (the token is in the URL). */++ const presigned = (url, opts) => http.request({ url, auth: false, ...opts });++++ // ── Upload ───────────────────────────────────────────────++++ async function init(body, per) {++ return json({ method: 'POST', path: O, json: body, idempotencyKey: false }, per);++ }++++ /**++ * How one multipart session is driven: through its presigned URLs (a fresh upload) or, for++ * resume(), with our own credential on the same routes (Media accepts either).++ */++ function sessionIo(session, { objectId, credential = false } = {}) {++ const path = `${O}/${enc(objectId)}/multipart/${enc(session.upload_id)}`;++ return {++ partSize: session.part_size,++ partsExpected: session.parts_expected,++ status: (signal) => (credential ? json({ path, signal }) : presigned(session.status_url, { method: 'GET', signal }).then((r) => r.data)),++ putPart: (n, part, headers, signal) => (credential++ ? call({ method: 'PUT', path: `${path}/parts/${n}`, body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })++ : presigned(session.part_url_template.replace('{part_number}', String(n)), { method: 'PUT', body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })),++ complete: (body, signal) => (credential++ ? json({ method: 'POST', path: `${path}/complete`, json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 })++ : presigned(session.complete_url, { method: 'POST', json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 }).then((r) => r.data)),++ };++ }++++ async function putParts(io, body, todo, { signal, onProgress, total, done, shaOf }) {++ const failed = [];++ let next = 0;++ const worker = async () => {++ while (next < todo.length) {++ const n = todo[next++];++ const start = (n - 1) * io.partSize;++ const part = slice(body, start, Math.min(start + io.partSize, total));++ const sha = await shaOf(n);++ try {++ await io.putPart(n, part, { 'Content-Type': 'application/octet-stream', ...(sha ? { 'X-Content-SHA256': sha } : {}) }, signal);++ done.bytes += sizeOf(part);++ if (onProgress) onProgress({ uploadedBytes: done.bytes, totalBytes: total, part: n });++ } catch (err) {++ if (err && err.code === 'sdk.aborted') throw err;++ failed.push({ part: n, error: err });++ }++ }++ };++ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, todo.length)) }, worker));++ return failed;++ }++++ /**++ * Send what a session is missing (round after round, reading it back each time), then complete it.++ * Parts Media already holds (a resumed session) are checked against our bytes; any that differ are++ * sent again. The complete call names every part's sha256 as computed here.++ */++ async function finishMultipart(objectId, uploadId, io, body, { signal, onProgress, contentHash, total }) {++ const mine = new Map();++ const shaOf = async (n) => {++ if (!mine.has(n)) {++ const start = (n - 1) * io.partSize;++ mine.set(n, await sha256Hex(slice(body, start, Math.min(start + io.partSize, total))));++ }++ return mine.get(n);++ };++ const done = { bytes: 0 };++ let status = await io.status(signal);++ if (status.total_size != null && status.total_size !== total) {++ throw new OpenVibeError({ code: 'sdk.upload_mismatch', message: `objects.upload: the session expects ${status.total_size} bytes, the data has ${total}` });++ }++ const stale = [];++ for (const p of status.parts || []) {++ const ours = await shaOf(p.part_number);++ if (ours && p.sha256 && ours !== p.sha256) stale.push(p.part_number);++ else done.bytes += p.size_bytes;++ }++ let todo = [...(status.missing || []), ...stale].sort((a, b) => a - b);++ let lastError = null;++ for (let round = 0; round <= resumeRounds && todo.length; round++) {++ const failed = await putParts(io, body, todo, { signal, onProgress, total, done, shaOf });++ if (failed.length) lastError = failed[0].error;++ status = await io.status(signal);++ todo = status.missing || [];++ }++ if (todo.length) {++ const err = new OpenVibeError({++ code: 'sdk.upload_incomplete', cause: lastError || undefined,++ message: `objects.upload: ${todo.length} part(s) could not be sent; call resume(err.resume, data) to continue`,++ });++ err.resume = { objectId, uploadId, missing: todo };++ throw err;++ }++ const parts = [];++ for (let n = 1; n <= io.partsExpected; n++) {++ const sha = await shaOf(n);++ if (sha) parts.push({ part_number: n, sha256: sha });++ }++ return io.complete({ ...(contentHash ? { content_hash: contentHash } : {}), ...(parts.length ? { parts } : {}) }, signal);++ }++++ /**++ * upload(data, { kind = 'file', visibility = 'private', mimeType | contentType, filename, metadata,++ * contentHash (hex | false), userId, subject, actingUserId, multipart ('auto' | true | false),++ * partSize, onProgress({ uploadedBytes, totalBytes, part }), signal, uploadTtl })++ * -> the object (Media's public shape, lifecycle_status 'ready')++ */++ async function upload(data, opts = {}) {++ const {++ kind = 'file', visibility = 'private', filename, metadata, userId, multipart = 'auto', signal, onProgress, uploadTtl,++ } = opts;++ const mime = opts.mimeType || opts.contentType || (typeof Blob !== 'undefined' && data instanceof Blob && data.type) || null;++ const body = toBody(data);++ const total = sizeOf(body);++ if (!total) throw new TypeError('objects.upload: nothing to upload (0 bytes)');++ const per = { actingUserId: opts.actingUserId, subject: opts.subject };++ let contentHash = opts.contentHash === false ? null : opts.contentHash || null;++ if (!contentHash && opts.contentHash !== false && total <= hashMaxBytes) contentHash = await sha256Hex(body);++ const wantParts = multipart === true || (multipart === 'auto' && total > multipartThreshold);++ const req = {++ kind, visibility, size_bytes: total, ...(mime ? { mime_type: mime } : {}), ...(filename ? { filename } : {}),++ ...(metadata ? { metadata } : {}), ...(contentHash ? { content_hash: contentHash } : {}), ...(userId != null ? { user_id: userId } : {}),++ ...(uploadTtl ? { upload_ttl: uploadTtl } : {}),++ };++ let created;++ try {++ created = await init(wantParts ? { ...req, multipart: true, part_size: opts.partSize || partSize } : req, per);++ } catch (err) {++ // Media's single-part limit is lower than our threshold: go multipart.++ if (!(multipart === 'auto' && !wantParts && isOpenVibeError(err) && err.status === 413 && err.code === 'media.object.too_large')) throw err;++ created = await init({ ...req, multipart: true, part_size: opts.partSize || partSize }, per);++ }++ const up = created.upload || {};++ if (up.multipart) {++ return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });++ }++ await presigned(up.url, {++ method: 'PUT', body, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2,++ headers: { 'Content-Type': mime || 'application/octet-stream' },++ });++ if (onProgress) onProgress({ uploadedBytes: total, totalBytes: total });++ return json({ method: 'POST', path: `${O}/${enc(created.id)}/complete`, json: contentHash ? { content_hash: contentHash } : {}, idempotent: true }, per);++ }++++ /**++ * Continue a multipart upload, e.g. from another process after a crash: reads the session with++ * this client's credential, sends the parts it is missing from `data` (the same bytes), completes++ * it. `ref` is err.resume from upload(), or { objectId, uploadId }.++ */++ async function resume({ objectId, uploadId }, data, { signal, onProgress, contentHash } = {}) {++ const body = toBody(data);++ const total = sizeOf(body);++ const session = await json({ path: `${O}/${enc(objectId)}/multipart/${enc(uploadId)}`, signal });++ if (session.status !== 'active') throw new OpenVibeError({ code: 'sdk.upload_incomplete', message: `objects.resume: the upload is ${session.status}` });++ const hash = contentHash === false ? null : contentHash || (total <= hashMaxBytes ? await sha256Hex(body) : null);++ return finishMultipart(objectId, uploadId, sessionIo(session, { objectId, credential: true }), body, { signal, onProgress, contentHash: hash, total });++ }++++ // ── Reads, links, delete ─────────────────────────────────++++ /** Object metadata (locations, lifecycle, public_url …), or null. Accepts med_… ids and legacy refs. */++ const get = (id) => orNull(json({ path: `${O}/${enc(id)}` }));++++ /**++ * signedUrl(id, { ttl }) -> { url, expires_at, public }. Private objects get a signed link valid++ * `ttl` seconds (30-3600); public and unlisted ones their public URL (expires_at null).++ */++ const signedUrl = (id, { ttl } = {}) => json({ path: `${O}/${enc(id)}/download`, query: { format: 'json', ttl } });++++ /** Soft delete. true when deleted (or already), false when there is no such object. */++ async function del(id) {++ try { await call({ method: 'DELETE', path: `${O}/${enc(id)}` }); return true; } catch (err) {++ if (isOpenVibeError(err) && err.status === 404) return false;++ throw err;++ }++ }++++ /** One page: { objects, next_cursor, limit }. */++ const list = ({ kind, visibility, status, owner, userId, limit, cursor, includeDeleted } = {}) => json({++ path: O, query: { kind, visibility, status, owner, user_id: userId, limit, cursor, include_deleted: includeDeleted ? 1 : undefined },++ });++++ /** Async iterator over every matching object, newest first. */++ const iterate = (q = {}) => paginate(async (cursor) => {++ const page = await list({ ...q, cursor: cursor || undefined });++ return { items: page.objects || [], next: page.next_cursor };++ }, { cursor: null });++++ // ── Jobs ─────────────────────────────────────────────────++++ const jobs = {++ /** create({ type, objectId, params, idempotencyKey, maxAttempts }) -> job (a repeat of the same key answers the same job). */++ async create({ type, objectId, params, idempotencyKey, maxAttempts } = {}) {++ if (!type) throw new TypeError('jobs.create: type is required');++ const res = await json({++ method: 'POST', path: J, idempotencyKey: idempotencyKey || undefined,++ json: { type, ...(objectId ? { object_id: objectId } : {}), ...(params ? { params } : {}), ...(maxAttempts ? { max_attempts: maxAttempts } : {}) },++ });++ return res.job;++ },++ get: async (id) => { const r = await orNull(json({ path: `${J}/${enc(id)}` })); return r ? r.job : null; },++ /** { jobs, next_cursor, limit } */++ list: ({ status, type, objectId, limit, cursor } = {}) => json({ path: J, query: { status, type, object_id: objectId, limit, cursor } }),++ /** A proposal (status proposed) -> queued. */++ approve: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/approve`, idempotent: true })).job,++ /** Cancel: proposed/queued at once; a running job reports cancel_requested until it stops. */++ cancel: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/cancel`, idempotent: true })).job,++ /** Poll until the job is succeeded, failed or cancelled (or timeoutMs passes: the job as it is then). */++ async wait(id, { intervalMs = 1000, timeoutMs: max = 10 * 60 * 1000, signal } = {}) {++ const end = Date.now() + max;++ for (;;) {++ const job = await jobs.get(id);++ if (!job || TERMINAL_JOB.has(job.status) || Date.now() >= end) return job;++ if (signal && signal.aborted) throw new OpenVibeError({ code: 'sdk.aborted', message: 'jobs.wait: aborted' });++ await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, end - Date.now()))));++ }++ },++ };++++ return { app, baseUrl: resolveBase, upload, resume, get, signedUrl, delete: del, list, iterate, jobs, client: http };++}++++module.exports = { createObjectsClient };nnew file mode 100644@@@ -0,0 +1,294 @@++'use strict';++/**++ * createObjectsClient (openvibe-sdk/media) against a mock of Media's object API v2: discovery of the++ * Media origin from the platform descriptor (live origins only), single uploads through the presigned++ * URL, multipart uploads with per-part sha256 (automatic by size, and as a fallback when Media refuses++ * a single part), recovery from failed parts, resume() of a session another process started, get /++ * signedUrl / delete / list / iterate, jobs, and the credentials and headers each call carries.++ */++const assert = require('node:assert/strict');++const crypto = require('node:crypto');++const { stubServer, send, problem, run } = require('./helpers');++const { createClient } = require('../src/core');++const { createObjectsClient } = require('../src/media');++++const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');++++/** A small Media v2: objects, presigned single PUT, multipart sessions, download links, jobs. */++async function mockMedia({ singleMax = 1024 * 1024, failPart = null, descriptor = null } = {}) {++ const state = { objects: new Map(), sessions: new Map(), jobs: new Map(), n: 0, failures: 0 };++ let base = '';++ const srv = await stubServer(async (req, res, body) => {++ const u = new URL(req.url, 'http://x');++ const p = u.pathname;++ if (p === '/.well-known/openvibe') return send(res, 200, descriptor ? descriptor(base) : { contracts: { version: '0.30.0' }, services: [{ id: 'media', status: 'beta', origin: base }] });++ const m = /^\/api\/v2\/([^/]+)\/(objects|jobs)(?:\/([^/]+))?(?:\/(.+))?$/.exec(p);++ if (!m) return send(res, 404, {});++ const [, app, coll, id, rest] = m;++ const token = u.searchParams.get('token');++ const authed = !!req.headers.authorization;++ if (coll === 'jobs') {++ if (!authed) return problem(res, 401, 'token.missing', 'no');++ if (req.method === 'POST' && !id) {++ const b = JSON.parse(body);++ const key = req.headers['idempotency-key'];++ const prior = key && [...state.jobs.values()].find((j) => j.key === key);++ if (prior) return send(res, 200, { job: prior.pub }, { 'Idempotent-Replayed': 'true' });++ const job = { key, polls: 0, pub: { id: `mjob_${++state.n}`, app_id: app, type: b.type, object_id: b.object_id || null, params: b.params || {}, status: 'queued' } };++ state.jobs.set(job.pub.id, job);++ return send(res, 202, { job: job.pub });++ }++ const job = state.jobs.get(id);++ if (!id) return send(res, 200, { jobs: [...state.jobs.values()].map((j) => j.pub).filter((j) => !u.searchParams.get('status') || j.status === u.searchParams.get('status')), next_cursor: null });++ if (!job) return problem(res, 404, 'media.job.not_found', 'No such job');++ if (req.method === 'GET') { if (++job.polls >= 3 && job.pub.status === 'queued') job.pub.status = 'succeeded'; return send(res, 200, { job: job.pub }); }++ if (rest === 'approve') { job.pub.status = 'queued'; return send(res, 200, { job: job.pub }); }++ if (rest === 'cancel') { job.pub.status = 'cancelled'; return send(res, 200, { job: job.pub }); }++ }++ // ── objects ──++ if (req.method === 'POST' && !id) {++ if (!authed) return problem(res, 401, 'token.missing', 'no');++ const b = JSON.parse(body);++ if (!b.multipart && b.size_bytes > singleMax) return problem(res, 413, 'media.object.too_large', `Single-part uploads are limited to ${singleMax} bytes; send multipart: true for larger objects`);++ const oid = `med_${String(++state.n).padStart(26, '0')}`;++ const obj = { id: oid, app_id: app, kind: b.kind, visibility: b.visibility, size_bytes: b.size_bytes, mime_type: b.mime_type || null, lifecycle_status: 'uploading', expected: b.content_hash || null, bytes: null, init: b, headers: req.headers };++ state.objects.set(oid, obj);++ const out = { id: oid, object: pub(obj), upload: { complete_url: `${base}/api/v2/${app}/objects/${oid}/complete`, multipart_url: `${base}/api/v2/${app}/objects/${oid}/multipart` } };++ if (b.multipart) out.upload = { ...out.upload, method: 'multipart', url: null, multipart: newSession(obj, b.part_size) };++ else Object.assign(out.upload, { method: 'PUT', url: `${base}/api/v2/${app}/objects/${oid}/content?token=put-${oid}`, token: `put-${oid}` });++ return send(res, 201, out);++ }++ if (req.method === 'GET' && !id) {++ if (!authed) return problem(res, 401, 'token.missing', 'no');++ const all = [...state.objects.values()].filter((o) => o.lifecycle_status !== 'deleted').sort((a, b) => (a.id < b.id ? 1 : -1));++ const cursor = u.searchParams.get('cursor');++ const limit = Number(u.searchParams.get('limit') || 50);++ const from = cursor ? all.filter((o) => o.id < cursor) : all;++ const page = from.slice(0, limit);++ return send(res, 200, { objects: page.map(pub), next_cursor: from.length > limit ? page[page.length - 1].id : null, limit });++ }++ const obj = state.objects.get(id);++ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');++ if (rest === 'content' && req.method === 'PUT') {++ if (authed || token !== `put-${id}`) return problem(res, 401, 'media.upload_token.invalid', 'presigned only here');++ if (body.length !== obj.size_bytes) return problem(res, 400, 'media.object.size_mismatch', 'size');++ obj.bytes = body; obj.putType = req.headers['content-type'];++ return send(res, 200, { id, size_bytes: body.length, content_hash: sha(body) });++ }++ if (rest === 'complete') {++ if (!authed) return problem(res, 401, 'token.missing', 'no');++ const b = body.length ? JSON.parse(body) : {};++ if ((b.content_hash || obj.expected) && sha(obj.bytes) !== (b.content_hash || obj.expected)) return problem(res, 422, 'media.object.hash_mismatch', 'hash');++ obj.lifecycle_status = 'ready';++ return send(res, 200, pub(obj));++ }++ if (rest && rest.startsWith('multipart/')) {++ const [, uid, what, n] = rest.split('/');++ const s = state.sessions.get(uid);++ if (!s || s.objectId !== id) return problem(res, 404, 'media.upload.not_found', 'no');++ if (!authed && token !== s.token) return problem(res, 401, 'media.upload_token.invalid', 'no');++ s.calls.push({ method: req.method, what: what || 'status', authed, n: n && Number(n) });++ if (!what && req.method === 'GET') return send(res, 200, sessionPub(s));++ if (what === 'parts') {++ const k = Number(n);++ const want = k < s.parts_expected ? s.part_size : s.total_size - s.part_size * (s.parts_expected - 1);++ if (failPart && failPart(k, s)) { state.failures++; res.socket.destroy(); return; }++ if (body.length !== want) return problem(res, 400, 'media.upload.part_size_mismatch', 'size');++ if (req.headers['x-content-sha256'] && req.headers['x-content-sha256'] !== sha(body)) return problem(res, 400, 'media.upload.part_hash_mismatch', 'sha');++ s.parts.set(k, body);++ return send(res, 200, { part_number: k, size_bytes: body.length, sha256: sha(body) });++ }++ if (what === 'complete') {++ const b = JSON.parse(body || '{}');++ if (sessionPub(s).missing.length) return problem(res, 409, 'media.upload.parts_missing', 'missing');++ for (const c of b.parts || []) if (sha(s.parts.get(c.part_number)) !== c.sha256) return problem(res, 400, 'media.upload.part_hash_mismatch', `part ${c.part_number}`);++ obj.bytes = Buffer.concat([...s.parts.keys()].sort((x, y) => x - y).map((k) => s.parts.get(k)));++ s.completeBody = b;++ if (b.content_hash && b.content_hash !== sha(obj.bytes)) return problem(res, 422, 'media.object.hash_mismatch', 'hash');++ obj.lifecycle_status = 'ready';++ s.status = 'completed';++ return send(res, 200, pub(obj));++ }++ }++ if (!authed) return problem(res, 401, 'token.missing', 'no');++ if (rest === 'download') return send(res, 200, { url: `${base}/o/${id}?exp=1&sig=s&ttl=${u.searchParams.get('ttl')}`, expires_at: '2030-01-01T00:00:00.000Z', public: false, format: u.searchParams.get('format') });++ if (req.method === 'GET' && !rest) return send(res, 200, pub(obj));++ if (req.method === 'DELETE' && !rest) { obj.lifecycle_status = 'deleted'; return send(res, 200, pub(obj)); }++ return send(res, 404, {});++ });++ base = srv.url;++ function pub(o) { return { id: o.id, app_id: o.app_id, kind: o.kind, visibility: o.visibility, size_bytes: o.size_bytes, mime_type: o.mime_type, lifecycle_status: o.lifecycle_status, content_hash: o.bytes ? sha(o.bytes) : null }; }++ function newSession(obj, partSize) {++ const uid = `mup_${++state.n}`;++ const s = { upload_id: uid, objectId: obj.id, part_size: partSize, total_size: obj.size_bytes, parts_expected: Math.ceil(obj.size_bytes / partSize), token: `mp1.${uid}`, parts: new Map(), calls: [], status: 'active' };++ state.sessions.set(uid, s);++ const u = `${base}/api/v2/${obj.app_id}/objects/${obj.id}/multipart/${uid}`;++ return { ...sessionPub(s, false), token: s.token, part_url_template: `${u}/parts/{part_number}?token=${s.token}`, status_url: `${u}?token=${s.token}`, complete_url: `${u}/complete?token=${s.token}`, abort_url: `${u}?token=${s.token}` };++ }++ function sessionPub(s, withParts = true) {++ const out = { upload_id: s.upload_id, object_id: s.objectId, status: s.status, part_size: s.part_size, total_size: s.total_size, parts_expected: s.parts_expected };++ if (!withParts) return out;++ const parts = [...s.parts.entries()].sort((a, b) => a[0] - b[0]).map(([k, b]) => ({ part_number: k, size_bytes: b.length, sha256: sha(b) }));++ const missing = [];++ for (let k = 1; k <= s.parts_expected; k++) if (!s.parts.has(k)) missing.push(k);++ return { ...out, parts, missing };++ }++ return { srv, state, newSession };++}++++const tokenClient = () => {++ const seen = [];++ return { seen, getToken: async (ctx) => { seen.push(ctx.audience); return 'svc-token'; }, invalidate() {} };++};++++run([++ ['discovery: the descriptor gives Media\'s live origin; none (or a planned one) is an error, never a guess', async () => {++ const m = await mockMedia();++ const tc = tokenClient();++ const objects = createObjectsClient({ app: 'demo', tokenClient: tc, network: m.srv.url });++ assert.equal(await objects.baseUrl(), m.srv.url);++ const obj = await objects.upload('hi', { mimeType: 'text/plain' });++ assert.equal(obj.lifecycle_status, 'ready');++ assert.deepEqual([...new Set(tc.seen)], ['openvibe.media'], 'tokens are asked for audience openvibe.media');++ await m.srv.close();++++ const planned = await mockMedia({ descriptor: () => ({ contracts: { version: '0.30.0' }, services: [{ id: 'media', status: 'planned', origin: null, planned_origin: 'https://openvibe.media' }] }) });++ const p = createObjectsClient({ app: 'demo', tokenClient: tokenClient(), network: planned.srv.url });++ await assert.rejects(p.get('med_x'), (e) => e.code === 'sdk.service_unavailable' && /planned at https:\/\/openvibe.media/.test(e.message));++ await planned.srv.close();++ const none = await mockMedia({ descriptor: () => ({ contracts: { version: '0.30.0' }, services: [{ id: 'network', status: 'stable', origin: 'https://openvibe.network' }] }) });++ await assert.rejects(createObjectsClient({ app: 'demo', tokenClient: tokenClient(), network: none.srv.url }).list(), (e) => e.code === 'sdk.service_unavailable');++ await none.srv.close();++ assert.throws(() => createObjectsClient({ tokenClient: tokenClient() }), TypeError);++ assert.throws(() => createObjectsClient({ app: 'demo' }), TypeError);++ }],++++ ['single upload: init with size, type and sha256; the bytes go to the presigned URL without a credential; complete', async () => {++ const m = await mockMedia();++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });++ const bytes = crypto.randomBytes(5000);++ const progress = [];++ const obj = await objects.upload(bytes, { kind: 'screenshot', visibility: 'public', mimeType: 'image/png', filename: 'a.png', metadata: { a: 1 }, onProgress: (p) => progress.push(p) });++ assert.deepEqual([obj.lifecycle_status, obj.size_bytes, obj.content_hash], ['ready', 5000, sha(bytes)]);++ const init = m.srv.requests.find((r) => r.method === 'POST' && r.url === '/api/v2/demo/objects');++ assert.deepEqual(JSON.parse(init.body), { kind: 'screenshot', visibility: 'public', size_bytes: 5000, mime_type: 'image/png', filename: 'a.png', metadata: { a: 1 }, content_hash: sha(bytes) });++ assert.equal(init.headers.authorization, 'Bearer svc-token');++ assert.equal(init.headers['x-ov-subject'], 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');++ const put = m.srv.requests.find((r) => r.method === 'PUT');++ assert.equal(put.headers.authorization, undefined, 'the presigned URL carries the credential');++ assert.equal(put.headers['content-type'], 'image/png');++ assert.deepEqual(put.body, bytes);++ assert.equal(progress.at(-1).uploadedBytes, 5000);++ await assert.rejects(objects.upload(new Uint8Array(0)), TypeError);++ await assert.rejects(objects.upload({ nope: true }), TypeError);++ await m.srv.close();++ }],++++ ['multipart by size: parts with sha256, in parallel; complete names every part; the object is the original', async () => {++ const m = await mockMedia();++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), multipartThreshold: 10000, partSize: 4096, concurrency: 3 });++ const bytes = crypto.randomBytes(4096 * 4 + 100);++ const obj = await objects.upload(new Blob([bytes], { type: 'video/webm' }), { kind: 'vod' });++ const init = JSON.parse(m.srv.requests.find((r) => r.url === '/api/v2/demo/objects').body);++ assert.deepEqual([init.multipart, init.part_size, init.mime_type, init.content_hash], [true, 4096, 'video/webm', sha(bytes)]);++ const s = [...m.state.sessions.values()][0];++ assert.equal(s.parts_expected, 5);++ const puts = m.srv.requests.filter((r) => r.method === 'PUT');++ assert.equal(puts.length, 5);++ assert.ok(puts.every((r) => !r.headers.authorization && r.headers['x-content-sha256'] === sha(r.body)), 'no credential, each part with its sha256');++ assert.deepEqual(s.completeBody.parts.map((p) => p.part_number), [1, 2, 3, 4, 5]);++ assert.equal(s.completeBody.content_hash, sha(bytes));++ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);++ assert.equal(obj.lifecycle_status, 'ready');++ await m.srv.close();++ }],++++ ['Media refusing a single part (413) sends it as multipart instead', async () => {++ const m = await mockMedia({ singleMax: 1000 });++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), partSize: 1024 });++ const bytes = crypto.randomBytes(3000);++ const obj = await objects.upload(bytes);++ assert.equal(obj.lifecycle_status, 'ready');++ const inits = m.srv.requests.filter((r) => r.url === '/api/v2/demo/objects').map((r) => JSON.parse(r.body).multipart);++ assert.deepEqual(inits, [undefined, true]);++ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);++ await m.srv.close();++ }],++++ ['dropped connections: a part that keeps failing is sent again in the next round after reading the session back', async () => {++ let drops = 0;++ const m = await mockMedia({ failPart: (k) => k === 2 && drops++ < 4 });++ const client = createClient({ retryDelayMs: 1, retries: 2 });++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, client, apiKey: 'app-key', multipartThreshold: 100, partSize: 1000 });++ const bytes = crypto.randomBytes(2500);++ const obj = await objects.upload(bytes);++ assert.equal(obj.lifecycle_status, 'ready');++ assert.equal(m.state.failures, 4, 'three attempts in round 1 (the client retries), one more in round 2');++ const s = [...m.state.sessions.values()][0];++ assert.ok(s.calls.filter((c) => c.what === 'status').length >= 2, 'the session was read back');++ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);++ assert.equal(m.srv.requests[0].headers.authorization, 'Bearer app-key', 'the app key when given');++ await m.srv.close();++ }],++++ ['a part that never goes up: sdk.upload_incomplete with err.resume; resume() finishes it with the credential', async () => {++ let broken = true;++ const m = await mockMedia({ failPart: (k) => k === 3 && broken });++ const client = createClient({ retryDelayMs: 1, retries: 0, token: 'svc-token' });++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, client, multipartThreshold: 100, partSize: 1000, resumeRounds: 1 });++ const bytes = crypto.randomBytes(2500);++ const err = await objects.upload(bytes).then(() => null, (e) => e);++ assert.equal(err && err.code, 'sdk.upload_incomplete');++ assert.deepEqual(err.resume.missing, [3]);++ broken = false;++ const s = m.state.sessions.get(err.resume.uploadId);++ s.parts.set(2, Buffer.alloc(1000)); // a part that differs from our bytes: sent again++ s.calls.length = 0;++ const done = await objects.resume(err.resume, bytes);++ assert.equal(done.lifecycle_status, 'ready');++ assert.deepEqual(s.calls.filter((c) => c.what === 'parts').map((c) => [c.n, c.authed]).sort(), [[2, true], [3, true]], 'only the stale and the missing part, with the credential');++ assert.deepEqual(m.state.objects.get(err.resume.objectId).bytes, bytes);++ await assert.rejects(objects.resume(err.resume, bytes), (e) => e.code === 'sdk.upload_incomplete', 'a completed session cannot be resumed');++ await m.srv.close();++ }],++++ ['get, signedUrl, delete, list and iterate', async () => {++ const m = await mockMedia();++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, apiKey: 'k', actingUserId: 7 });++ const a = await objects.upload('one');++ await objects.upload('two');++ await objects.upload('three');++ assert.equal((await objects.get(a.id)).id, a.id);++ assert.equal(await objects.get('med_missing'), null);++ const link = await objects.signedUrl(a.id, { ttl: 120 });++ assert.deepEqual([link.public, link.format, new URL(link.url).searchParams.get('ttl')], [false, 'json', '120']);++ const page = await objects.list({ limit: 2 });++ assert.equal(page.objects.length, 2);++ assert.ok(page.next_cursor);++ const all = [];++ for await (const o of objects.iterate({ limit: 2 })) all.push(o.id);++ assert.equal(all.length, 3);++ assert.equal(await objects.delete(a.id), true);++ assert.equal(await objects.delete('med_missing'), false);++ assert.ok(m.srv.requests.filter((r) => r.headers.authorization).every((r) => r.headers['x-ov-user-id'] === '7'), 'X-OV-User-Id on every credentialed call');++ await m.srv.close();++ }],++++ ['jobs: create with an Idempotency-Key, get, approve, cancel, wait', async () => {++ const m = await mockMedia();++ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient() });++ const job = await objects.jobs.create({ type: 'object.remux', objectId: 'med_1', idempotencyKey: 'remux-1' });++ assert.deepEqual([job.type, job.object_id, job.status], ['object.remux', 'med_1', 'queued']);++ assert.equal(m.srv.requests.at(-1).headers['idempotency-key'], 'remux-1');++ assert.equal((await objects.jobs.create({ type: 'object.remux', objectId: 'med_1', idempotencyKey: 'remux-1' })).id, job.id, 'a repeat answers the same job');++ const done = await objects.jobs.wait(job.id, { intervalMs: 5 });++ assert.equal(done.status, 'succeeded');++ assert.equal(await objects.jobs.get('mjob_nope'), null);++ const other = await objects.jobs.create({ type: 'invariant.scan' });++ assert.equal((await objects.jobs.approve(other.id)).status, 'queued');++ assert.equal((await objects.jobs.cancel(other.id)).status, 'cancelled');++ assert.equal((await objects.jobs.list({ status: 'cancelled' })).jobs.length, 1);++ await assert.rejects(objects.jobs.create({}), TypeError);++ await m.srv.close();++ }],++]);@@@ -59,3 +59,124 @@ export interface MediaClient {*/export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;export declare const DEFAULT_PUBLIC_ORIGIN: string;++++/** A Media object (object API v2, `/api/v2/:app/objects`): Media's public shape. */++export interface MediaObject {++ id: string;++ media_ref?: { media_id: string };++ legacy_ref?: string | null;++ app_id: string;++ namespace?: string;++ kind: 'vod' | 'clip' | 'file' | 'thumbnail' | 'screenshot' | 'avatar' | 'asset' | string;++ owner?: { subject: string | null; app: string | null; user_id: number | null };++ visibility: 'public' | 'unlisted' | 'private';++ lifecycle_status: 'uploading' | 'ready' | 'failed' | 'archived' | 'deleted' | string;++ mime_type: string | null;++ size_bytes: number;++ content_hash: string | null;++ metadata?: Record<string, unknown>;++ held?: boolean;++ /** Public/unlisted and ready: the public URL; null otherwise (private, sandbox). */++ public_url?: string | null;++ sandbox?: true;++ locations?: Array<{ provider: 'local' | 'b2' | 'r2'; storage_class: string | null; state: string; size_bytes: number | null; verified_at: string | null; canonical: boolean }>;++ created_at?: string;++ updated_at?: string;++ deleted_at?: string | null;++ [key: string]: unknown;++}++++/** A Media job (`/api/v2/:app/jobs`). */++export interface MediaJob {++ id: string;++ app_id: string;++ object_id: string | null;++ type: 'thumbnail.regenerate' | 'invariant.scan' | 'object.split' | 'object.remux' | string;++ status: 'proposed' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';++ params: Record<string, unknown>;++ result: unknown;++ error: string | null;++ error_code: string | null;++ attempts: number;++ max_attempts: number;++ cancel_requested: boolean;++ idempotency_key: string | null;++ created_at: string;++ finished_at: string | null;++ [key: string]: unknown;++}++++export interface ObjectUploadOptions {++ kind?: MediaObject['kind'];++ visibility?: MediaObject['visibility'];++ mimeType?: string;++ contentType?: string;++ filename?: string;++ metadata?: Record<string, unknown>;++ /** Expected sha256 (hex) for Media to verify; computed when omitted and the data is at most hashMaxBytes; false: none. */++ contentHash?: string | false;++ userId?: number;++ subject?: string | null;++ actingUserId?: string | number | null;++ /** 'auto' (default): multipart above multipartThreshold, or when Media refuses one part. */++ multipart?: 'auto' | boolean;++ partSize?: number;++ uploadTtl?: number;++ signal?: AbortSignal;++ onProgress?(p: { uploadedBytes: number; totalBytes: number; part?: number }): void;++}++++/** err.resume of an sdk.upload_incomplete error. */++export interface ObjectUploadRef { objectId: string; uploadId: string; missing?: number[] }++++export interface ObjectsClient {++ app: string;++ /** Media's origin: the baseUrl option, or the platform descriptor's live origin for `media`. */++ baseUrl(): Promise<string>;++ upload(data: UploadBody, opts?: ObjectUploadOptions): Promise<MediaObject>;++ resume(ref: ObjectUploadRef, data: UploadBody, opts?: { signal?: AbortSignal; onProgress?: ObjectUploadOptions['onProgress']; contentHash?: string | false }): Promise<MediaObject>;++ get(id: string): Promise<MediaObject | null>;++ signedUrl(id: string, opts?: { ttl?: number }): Promise<{ url: string; expires_at: string | null; public: boolean }>;++ delete(id: string): Promise<boolean>;++ list(opts?: { kind?: string; visibility?: string; status?: string; owner?: string; userId?: number; limit?: number; cursor?: string; includeDeleted?: boolean }): Promise<{ objects: MediaObject[]; next_cursor: string | null; limit: number }>;++ iterate(opts?: { kind?: string; visibility?: string; status?: string; owner?: string; userId?: number; limit?: number; includeDeleted?: boolean }): AsyncGenerator<MediaObject, void, unknown>;++ jobs: {++ create(opts: { type: MediaJob['type']; objectId?: string; params?: Record<string, unknown>; idempotencyKey?: string; maxAttempts?: number }): Promise<MediaJob>;++ get(id: string): Promise<MediaJob | null>;++ list(opts?: { status?: MediaJob['status']; type?: string; objectId?: string; limit?: number; cursor?: string }): Promise<{ jobs: MediaJob[]; next_cursor: string | null; limit: number }>;++ approve(id: string): Promise<MediaJob>;++ cancel(id: string): Promise<MediaJob>;++ wait(id: string, opts?: { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal }): Promise<MediaJob | null>;++ };++ client: OpenVibeClient;++}++++/**++ * Media's object API v2. Credentials: tokenClient (createServiceTokenClient; media.object.upload and++ * media.object.read for namespace `app`), apiKey, or a client carrying its own. Without baseUrl the Media++ * origin comes from the platform descriptor (live services only; otherwise sdk.service_unavailable).++ */++export declare function createObjectsClient(opts: {++ app: string;++ baseUrl?: string;++ tokenClient?: { getToken(ctx?: { audience?: string; scope?: string | string[] }): Promise<string>; invalidate?(ctx?: { audience?: string }): void };++ apiKey?: string;++ client?: OpenVibeClient;++ network?: string;++ discoveryUrl?: string;++ fetch?: typeof fetch;++ actingUserId?: string | number;++ subject?: string;++ /** Default 64 MiB. */++ multipartThreshold?: number;++ /** Default 16 MiB (Media accepts 5-256 MB by default). */++ partSize?: number;++ /** Parts in flight at once; default 4. */++ concurrency?: number;++ /** Whole-object sha256 is computed up to this size; default 256 MiB. */++ hashMaxBytes?: number;++ /** Extra rounds for parts that failed; default 3. */++ resumeRounds?: number;++ timeoutMs?: number;++ partTimeoutMs?: number;++}): ObjectsClient;
@@ -3,6 +3,40 @@
@@ -3,6 +3,40 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.5.0 (2026-09-23)
+
+Media's object API v2 (`/api/v2/:app/objects`) and its jobs (`/api/v2/:app/jobs`), wrapped as
+`createObjectsClient()` in `openvibe-sdk/media`. Additive: nothing existing changes.
+
+- **`createObjectsClient({ app, baseUrl?, tokenClient | apiKey | client, … })`.** `tokenClient` is a
+ `createServiceTokenClient()` (a service or developer-app principal with `media.object.upload` and
+ `media.object.read` for namespace `app`; a developer app uses its project id as `app`). Options:
+ `actingUserId` (X-OV-User-Id, app key only), `subject` (X-OV-Subject), `multipartThreshold` (64 MiB),
+ `partSize` (16 MiB), `concurrency` (4), `hashMaxBytes` (256 MiB), `resumeRounds` (3).
+- **Discovery.** Without `baseUrl`, Media's origin comes from the platform descriptor
+ (`/.well-known/openvibe`). The descriptor gives an origin only for live services: when Media has
+ none (not registered, or only a `planned_origin`) the client throws `sdk.service_unavailable` and
+ never guesses. `objects.baseUrl()` says which origin is used.
+- **`upload(data, { kind, visibility, mimeType, filename, metadata, contentHash, multipart, onProgress, signal, … })`**
+ picks single or multipart by size. Single: init, then the bytes go to Media's presigned PUT URL (no
+ credential on that request), then complete. Multipart (above `multipartThreshold`, with
+ `multipart: true`, or when Media answers 413 `media.object.too_large` to a single part): parts go up
+ `concurrency` at a time with `X-Content-SHA256`; after each round the session is read back and the
+ missing parts are sent again; complete names every part's sha256. The whole object's sha256 is sent
+ for Media to verify up to `hashMaxBytes`. A part that cannot be sent ends in `sdk.upload_incomplete`
+ with `err.resume = { objectId, uploadId, missing }`.
+- **`resume({ objectId, uploadId }, data)`** continues a multipart session, e.g. from another process:
+ it reads the session with the client's credential, re-sends parts Media holds with a different sha256
+ and the missing ones, and completes.
+- **`get(id)`** (null on 404; `med_…` ids and legacy refs), **`signedUrl(id, { ttl })`** (`{ url,
+ expires_at, public }`: a signed link for private objects), **`delete(id)`** (true, or false on 404),
+ **`list(q)`** (one page) and **`iterate(q)`** (every object, newest first).
+- **`jobs.create({ type, objectId, params, idempotencyKey })`**, `get`, `list`, `approve` (a proposal ->
+ queued), `cancel` and `wait(id)` (polls until succeeded, failed or cancelled). Types today:
+ `thumbnail.regenerate`, `invariant.scan`, `object.split`, `object.remux`.
+- Types (`MediaObject`, `MediaJob`, `ObjectsClient`, `ObjectUploadOptions`), the ESM entry and the
+ browser bundle include it.
+
## 0.4.0 (2026-09-23)
## 0.4.0 (2026-09-23)
Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:
Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.4.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Production services pin three releases (2026-09-23): v0.4.0 in Live, Deals, News, Reviews, Tips, Trade and VIP; v0.3.1 in Codes; v0.2.2 in Network, Media, Blog, Wiki, Coupons, Host, OpenRe.Stream and Games. OpenVibe.Examples uses v0.4.0. CI is green from `654d4b2` (it now runs `npm ci`); the runs for the v0.3.0, v0.3.1 and v0.4.0 commits failed because `better-sqlite3` was not installed.
+**Status:** alpha, v0.5.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4). v0.5.0 is not tagged yet: the install line below stays on v0.4.0 until it is. Tested against local stub servers and the built-in mock platform only, never against the live platform. Production services pin three releases (2026-09-23): v0.4.0 in Live, Deals, News, Reviews, Tips, Trade and VIP; v0.3.1 in Codes; v0.2.2 in Network, Media, Blog, Wiki, Coupons, Host, OpenRe.Stream and Games. OpenVibe.Examples uses v0.4.0. CI is green from `654d4b2` (it now runs `npm ci`); the runs for the v0.3.0, v0.3.1 and v0.4.0 commits failed because `better-sqlite3` was not installed.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@ -157,7 +157,7 @@ const res = await jobs.file(job.id, 0);
@@ -157,7 +157,7 @@ const res = await jobs.file(job.id, 0);
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
-| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
+| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
@@ -275,7 +275,7 @@ Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()
@@ -275,7 +275,7 @@ Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()
## Not wrapped yet (intentionally)
## Not wrapped yet (intentionally)
- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
-- **Media v2 objects** (`med_` ids, ADR-006, `/api/v2/:app/objects`) exist on Media but are not wrapped yet. This release wraps only the v1 files API, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).
+- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- **Chat, Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat has no app principal (`chat.message.send` is `first-party`), so there is no Chat client and no Chat mock.
- **Chat, Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat has no app principal (`chat.message.send` is `first-party`), so there is no Chat client and no Chat mock.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.4.0",
+ "version": "0.5.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"updated": "2026-09-23"
"updated": "2026-09-23"
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.4.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.5.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -19,9 +19,9 @@ module.exports = {
@@ -19,9 +19,9 @@ module.exports = {
modules: __load(10),
modules: __load(10),
realtime: __load(11),
realtime: __load(11),
media: __load(12),
media: __load(12),
- community: __load(13),
- jobs: __load(14),
- projects: __load(15),
+ community: __load(14),
+ jobs: __load(15),
+ projects: __load(16),
};
};
},
},
// ── 1: src/core/index.js ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.4.0';
+const SDK_VERSION = '0.5.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1192,7 +1192,9 @@ module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };
@@ -1192,7 +1192,9 @@ module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
- * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
+ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files), public URL helpers, and
+ * createObjectsClient() for the object API v2 (/api/v2/:app/objects: single and multipart uploads,
+ * signed links, jobs), see ./objects.js.
*
*
* Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
* Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
* secret), a Network service token for the app's namespace, or a developer app token on its
* secret), a Network service token for the app's namespace, or a developer app token on its
@@ -1208,6 +1210,7 @@ function (module, exports) {
@@ -1208,6 +1210,7 @@ function (module, exports) {
*/
*/
const { isOpenVibeError } = __load(3);
const { isOpenVibeError } = __load(3);
const { paginate, offsetPager } = __load(7);
const { paginate, offsetPager } = __load(7);
+const { createObjectsClient } = __load(13);
const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
const enc = encodeURIComponent;
const enc = encodeURIComponent;
@@ -1297,9 +1300,328 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
@@ -1297,9 +1300,328 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
return { app, files, urls, upload: files.upload };
return { app, files, urls, upload: files.upload };
}
}
-module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
+module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
},
},
-// ── 13: src/community.js ────────────────────────────────────────────
+// ── 13: src/objects.js ──────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * createObjectsClient(): OpenVibe.Media's object API v2 (/api/v2/:app/objects) and its jobs
+ * (/api/v2/:app/jobs). Exported from openvibe-sdk/media. Browser-safe code (fetch, Blob, Web Crypto),
+ * but the credentials it needs belong on a server.
+ *
+ * const objects = createObjectsClient({ app: 'demo', tokenClient: createServiceTokenClient({ clientId, clientSecret }) });
+ * const obj = await objects.upload(bytes, { kind: 'file', mimeType: 'image/png', filename: 'a.png' });
+ * const { url } = await objects.signedUrl(obj.id, { ttl: 300 });
+ *
+ * Base URL: `baseUrl`, or discovered from the Network's platform descriptor (/.well-known/openvibe).
+ * The descriptor gives an origin only for services that are live; when Media has none (not
+ * registered, or only a planned origin) the client throws sdk.service_unavailable instead of guessing.
+ *
+ * Credentials (one of): `tokenClient` (createServiceTokenClient: a service or developer-app principal
+ * with media.object.upload / media.object.read for namespace `app`; a developer app uses its project
+ * id as `app`), `apiKey` (the app's Media API key), or a `client` from createClient() that carries its
+ * own. `actingUserId` (app key only) acts for one of the app's users (X-OV-User-Id); `subject` names
+ * the owner (X-OV-Subject: usr_…).
+ *
+ * upload() picks single or multipart by size: single PUT to Media's presigned URL up to
+ * `multipartThreshold` (default 64 MiB), multipart above it (or when Media says the object is too
+ * large for one part). Parts go up `concurrency` at a time with their sha256; failed parts are retried
+ * and, after the round, the session is read back and whatever is missing is sent again (resumable
+ * after a dropped connection; `resume()` continues a session from another process). The whole
+ * object's sha256 is sent for Media to verify when the data is at most `hashMaxBytes` (256 MiB).
+ */
+const { createClient } = __load(2);
+const { OpenVibeError, isOpenVibeError } = __load(3);
+const { paginate } = __load(7);
+
+const MIB = 1024 * 1024;
+const enc = encodeURIComponent;
+const TERMINAL_JOB = new Set(['succeeded', 'failed', 'cancelled']);
+
+function toBody(data) {
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
+ if (typeof data === 'string') return new TextEncoder().encode(data);
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ throw new TypeError('objects.upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');
+}
+const sizeOf = (body) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.size : body.byteLength);
+const slice = (body, start, end) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.slice(start, end) : body.subarray(start, end));
+
+async function sha256Hex(part) {
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) return null;
+ const bytes = typeof Blob !== 'undefined' && part instanceof Blob ? new Uint8Array(await part.arrayBuffer()) : part;
+ const digest = await subtle.digest('SHA-256', bytes);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+function createObjectsClient({
+ app, baseUrl, tokenClient, apiKey, client, network, fetch: fetchImpl, discoveryUrl,
+ actingUserId, subject, multipartThreshold = 64 * MIB, partSize = 16 * MIB, concurrency = 4,
+ hashMaxBytes = 256 * MIB, resumeRounds = 3, timeoutMs, partTimeoutMs = 10 * 60 * 1000,
+} = {}) {
+ if (!app) throw new TypeError('createObjectsClient: app (the tenant: an app id, or a developer project id prj_…) is required');
+ if (!client && !tokenClient && !apiKey) throw new TypeError('createObjectsClient: pass tokenClient, apiKey or client');
+ const http = client || createClient({
+ network, discoveryUrl, fetch: fetchImpl, ...(tokenClient ? { tokenProvider: tokenClient } : {}), ...(timeoutMs ? { timeoutMs } : {}),
+ });
+ const O = `/api/v2/${enc(app)}/objects`;
+ const J = `/api/v2/${enc(app)}/jobs`;
+ let base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : null;
+
+ /** Media's origin: the given baseUrl, or the descriptor's live origin for `media`. */
+ async function resolveBase() {
+ if (base) return base;
+ const d = await http.discover();
+ const s = (d.services || []).find((x) => x && x.id === 'media');
+ if (!s || !s.origin) {
+ const why = !s ? 'Media is not in the platform descriptor' : `Media has no live origin in the platform descriptor (status ${s.status || 'unknown'}${s.planned_origin ? `, planned at ${s.planned_origin}` : ''})`;
+ throw new OpenVibeError({ code: 'sdk.service_unavailable', message: `${why}; pass baseUrl to use a Media you know is up` });
+ }
+ base = String(s.origin).replace(/\/+$/, '');
+ return base;
+ }
+
+ const headersFor = (per = {}) => {
+ const h = { ...(per.headers || {}) };
+ const acting = per.actingUserId !== undefined ? per.actingUserId : actingUserId;
+ if (acting != null) h['X-OV-User-Id'] = String(acting);
+ const subj = per.subject !== undefined ? per.subject : subject;
+ if (subj) h['X-OV-Subject'] = String(subj);
+ return h;
+ };
+ const call = async (opts, per = {}) => http.request({
+ service: 'media', audience: 'openvibe.media', baseUrl: await resolveBase(), ...(apiKey ? { token: apiKey } : {}),
+ ...opts, headers: headersFor({ ...per, headers: opts.headers }),
+ });
+ const json = async (opts, per) => (await call(opts, per)).data;
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ /** A presigned Media URL: no Authorization header (the token is in the URL). */
+ const presigned = (url, opts) => http.request({ url, auth: false, ...opts });
+
+ // ── Upload ───────────────────────────────────────────────
+
+ async function init(body, per) {
+ return json({ method: 'POST', path: O, json: body, idempotencyKey: false }, per);
+ }
+
+ /**
+ * How one multipart session is driven: through its presigned URLs (a fresh upload) or, for
+ * resume(), with our own credential on the same routes (Media accepts either).
+ */
+ function sessionIo(session, { objectId, credential = false } = {}) {
+ const path = `${O}/${enc(objectId)}/multipart/${enc(session.upload_id)}`;
+ return {
+ partSize: session.part_size,
+ partsExpected: session.parts_expected,
+ status: (signal) => (credential ? json({ path, signal }) : presigned(session.status_url, { method: 'GET', signal }).then((r) => r.data)),
+ putPart: (n, part, headers, signal) => (credential
+ ? call({ method: 'PUT', path: `${path}/parts/${n}`, body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })
+ : presigned(session.part_url_template.replace('{part_number}', String(n)), { method: 'PUT', body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })),
+ complete: (body, signal) => (credential
+ ? json({ method: 'POST', path: `${path}/complete`, json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 })
+ : presigned(session.complete_url, { method: 'POST', json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 }).then((r) => r.data)),
+ };
+ }
+
+ async function putParts(io, body, todo, { signal, onProgress, total, done, shaOf }) {
+ const failed = [];
+ let next = 0;
+ const worker = async () => {
+ while (next < todo.length) {
+ const n = todo[next++];
+ const start = (n - 1) * io.partSize;
+ const part = slice(body, start, Math.min(start + io.partSize, total));
+ const sha = await shaOf(n);
+ try {
+ await io.putPart(n, part, { 'Content-Type': 'application/octet-stream', ...(sha ? { 'X-Content-SHA256': sha } : {}) }, signal);
+ done.bytes += sizeOf(part);
+ if (onProgress) onProgress({ uploadedBytes: done.bytes, totalBytes: total, part: n });
+ } catch (err) {
+ if (err && err.code === 'sdk.aborted') throw err;
+ failed.push({ part: n, error: err });
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, todo.length)) }, worker));
+ return failed;
+ }
+
+ /**
+ * Send what a session is missing (round after round, reading it back each time), then complete it.
+ * Parts Media already holds (a resumed session) are checked against our bytes; any that differ are
+ * sent again. The complete call names every part's sha256 as computed here.
+ */
+ async function finishMultipart(objectId, uploadId, io, body, { signal, onProgress, contentHash, total }) {
+ const mine = new Map();
+ const shaOf = async (n) => {
+ if (!mine.has(n)) {
+ const start = (n - 1) * io.partSize;
+ mine.set(n, await sha256Hex(slice(body, start, Math.min(start + io.partSize, total))));
+ }
+ return mine.get(n);
+ };
+ const done = { bytes: 0 };
+ let status = await io.status(signal);
+ if (status.total_size != null && status.total_size !== total) {
+ throw new OpenVibeError({ code: 'sdk.upload_mismatch', message: `objects.upload: the session expects ${status.total_size} bytes, the data has ${total}` });
+ }
+ const stale = [];
+ for (const p of status.parts || []) {
+ const ours = await shaOf(p.part_number);
+ if (ours && p.sha256 && ours !== p.sha256) stale.push(p.part_number);
+ else done.bytes += p.size_bytes;
+ }
+ let todo = [...(status.missing || []), ...stale].sort((a, b) => a - b);
+ let lastError = null;
+ for (let round = 0; round <= resumeRounds && todo.length; round++) {
+ const failed = await putParts(io, body, todo, { signal, onProgress, total, done, shaOf });
+ if (failed.length) lastError = failed[0].error;
+ status = await io.status(signal);
+ todo = status.missing || [];
+ }
+ if (todo.length) {
+ const err = new OpenVibeError({
+ code: 'sdk.upload_incomplete', cause: lastError || undefined,
+ message: `objects.upload: ${todo.length} part(s) could not be sent; call resume(err.resume, data) to continue`,
+ });
+ err.resume = { objectId, uploadId, missing: todo };
+ throw err;
+ }
+ const parts = [];
+ for (let n = 1; n <= io.partsExpected; n++) {
+ const sha = await shaOf(n);
+ if (sha) parts.push({ part_number: n, sha256: sha });
+ }
+ return io.complete({ ...(contentHash ? { content_hash: contentHash } : {}), ...(parts.length ? { parts } : {}) }, signal);
+ }
+
+ /**
+ * upload(data, { kind = 'file', visibility = 'private', mimeType | contentType, filename, metadata,
+ * contentHash (hex | false), userId, subject, actingUserId, multipart ('auto' | true | false),
+ * partSize, onProgress({ uploadedBytes, totalBytes, part }), signal, uploadTtl })
+ * -> the object (Media's public shape, lifecycle_status 'ready')
+ */
+ async function upload(data, opts = {}) {
+ const {
+ kind = 'file', visibility = 'private', filename, metadata, userId, multipart = 'auto', signal, onProgress, uploadTtl,
+ } = opts;
+ const mime = opts.mimeType || opts.contentType || (typeof Blob !== 'undefined' && data instanceof Blob && data.type) || null;
+ const body = toBody(data);
+ const total = sizeOf(body);
+ if (!total) throw new TypeError('objects.upload: nothing to upload (0 bytes)');
+ const per = { actingUserId: opts.actingUserId, subject: opts.subject };
+ let contentHash = opts.contentHash === false ? null : opts.contentHash || null;
+ if (!contentHash && opts.contentHash !== false && total <= hashMaxBytes) contentHash = await sha256Hex(body);
+ const wantParts = multipart === true || (multipart === 'auto' && total > multipartThreshold);
+ const req = {
+ kind, visibility, size_bytes: total, ...(mime ? { mime_type: mime } : {}), ...(filename ? { filename } : {}),
+ ...(metadata ? { metadata } : {}), ...(contentHash ? { content_hash: contentHash } : {}), ...(userId != null ? { user_id: userId } : {}),
+ ...(uploadTtl ? { upload_ttl: uploadTtl } : {}),
+ };
+ let created;
+ try {
+ created = await init(wantParts ? { ...req, multipart: true, part_size: opts.partSize || partSize } : req, per);
+ } catch (err) {
+ // Media's single-part limit is lower than our threshold: go multipart.
+ if (!(multipart === 'auto' && !wantParts && isOpenVibeError(err) && err.status === 413 && err.code === 'media.object.too_large')) throw err;
+ created = await init({ ...req, multipart: true, part_size: opts.partSize || partSize }, per);
+ }
+ const up = created.upload || {};
+ if (up.multipart) {
+ return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });
+ }
+ await presigned(up.url, {
+ method: 'PUT', body, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2,
+ headers: { 'Content-Type': mime || 'application/octet-stream' },
+ });
+ if (onProgress) onProgress({ uploadedBytes: total, totalBytes: total });
+ return json({ method: 'POST', path: `${O}/${enc(created.id)}/complete`, json: contentHash ? { content_hash: contentHash } : {}, idempotent: true }, per);
+ }
+
+ /**
+ * Continue a multipart upload, e.g. from another process after a crash: reads the session with
+ * this client's credential, sends the parts it is missing from `data` (the same bytes), completes
+ * it. `ref` is err.resume from upload(), or { objectId, uploadId }.
+ */
+ async function resume({ objectId, uploadId }, data, { signal, onProgress, contentHash } = {}) {
+ const body = toBody(data);
+ const total = sizeOf(body);
+ const session = await json({ path: `${O}/${enc(objectId)}/multipart/${enc(uploadId)}`, signal });
+ if (session.status !== 'active') throw new OpenVibeError({ code: 'sdk.upload_incomplete', message: `objects.resume: the upload is ${session.status}` });
+ const hash = contentHash === false ? null : contentHash || (total <= hashMaxBytes ? await sha256Hex(body) : null);
+ return finishMultipart(objectId, uploadId, sessionIo(session, { objectId, credential: true }), body, { signal, onProgress, contentHash: hash, total });
+ }
+
+ // ── Reads, links, delete ─────────────────────────────────
+
+ /** Object metadata (locations, lifecycle, public_url …), or null. Accepts med_… ids and legacy refs. */
+ const get = (id) => orNull(json({ path: `${O}/${enc(id)}` }));
+
+ /**
+ * signedUrl(id, { ttl }) -> { url, expires_at, public }. Private objects get a signed link valid
+ * `ttl` seconds (30-3600); public and unlisted ones their public URL (expires_at null).
+ */
+ const signedUrl = (id, { ttl } = {}) => json({ path: `${O}/${enc(id)}/download`, query: { format: 'json', ttl } });
+
+ /** Soft delete. true when deleted (or already), false when there is no such object. */
+ async function del(id) {
+ try { await call({ method: 'DELETE', path: `${O}/${enc(id)}` }); return true; } catch (err) {
+ if (isOpenVibeError(err) && err.status === 404) return false;
+ throw err;
+ }
+ }
+
+ /** One page: { objects, next_cursor, limit }. */
+ const list = ({ kind, visibility, status, owner, userId, limit, cursor, includeDeleted } = {}) => json({
+ path: O, query: { kind, visibility, status, owner, user_id: userId, limit, cursor, include_deleted: includeDeleted ? 1 : undefined },
+ });
+
+ /** Async iterator over every matching object, newest first. */
+ const iterate = (q = {}) => paginate(async (cursor) => {
+ const page = await list({ ...q, cursor: cursor || undefined });
+ return { items: page.objects || [], next: page.next_cursor };
+ }, { cursor: null });
+
+ // ── Jobs ─────────────────────────────────────────────────
+
+ const jobs = {
+ /** create({ type, objectId, params, idempotencyKey, maxAttempts }) -> job (a repeat of the same key answers the same job). */
+ async create({ type, objectId, params, idempotencyKey, maxAttempts } = {}) {
+ if (!type) throw new TypeError('jobs.create: type is required');
+ const res = await json({
+ method: 'POST', path: J, idempotencyKey: idempotencyKey || undefined,
+ json: { type, ...(objectId ? { object_id: objectId } : {}), ...(params ? { params } : {}), ...(maxAttempts ? { max_attempts: maxAttempts } : {}) },
+ });
+ return res.job;
+ },
+ get: async (id) => { const r = await orNull(json({ path: `${J}/${enc(id)}` })); return r ? r.job : null; },
+ /** { jobs, next_cursor, limit } */
+ list: ({ status, type, objectId, limit, cursor } = {}) => json({ path: J, query: { status, type, object_id: objectId, limit, cursor } }),
+ /** A proposal (status proposed) -> queued. */
+ approve: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/approve`, idempotent: true })).job,
+ /** Cancel: proposed/queued at once; a running job reports cancel_requested until it stops. */
+ cancel: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/cancel`, idempotent: true })).job,
+ /** Poll until the job is succeeded, failed or cancelled (or timeoutMs passes: the job as it is then). */
+ async wait(id, { intervalMs = 1000, timeoutMs: max = 10 * 60 * 1000, signal } = {}) {
+ const end = Date.now() + max;
+ for (;;) {
+ const job = await jobs.get(id);
+ if (!job || TERMINAL_JOB.has(job.status) || Date.now() >= end) return job;
+ if (signal && signal.aborted) throw new OpenVibeError({ code: 'sdk.aborted', message: 'jobs.wait: aborted' });
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, end - Date.now()))));
+ }
+ },
+ };
+
+ return { app, baseUrl: resolveBase, upload, resume, get, signedUrl, delete: del, list, iterate, jobs, client: http };
+}
+
+module.exports = { createObjectsClient };
+},
+// ── 14: src/community.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1418,7 +1740,7 @@ function createCommunityClient(client, defaults = {}) {
@@ -1418,7 +1740,7 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
module.exports = { createCommunityClient, actingHeaders };
},
},
-// ── 14: src/jobs.js ─────────────────────────────────────────────────
+// ── 15: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1588,7 +1910,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -1588,7 +1910,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 15: src/projects.js ─────────────────────────────────────────────
+// ── 16: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/media.js';
import cjs from '../src/media.js';
export const {
export const {
- createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN,
+ createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.4.0",
+ "version": "0.5.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.4.0",
+ "version": "0.5.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.4.0';
+const SDK_VERSION = '0.5.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
'use strict';
'use strict';
/**
/**
- * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
+ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files), public URL helpers, and
+ * createObjectsClient() for the object API v2 (/api/v2/:app/objects: single and multipart uploads,
+ * signed links, jobs), see ./objects.js.
*
*
* Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
* Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
* secret), a Network service token for the app's namespace, or a developer app token on its
* secret), a Network service token for the app's namespace, or a developer app token on its
@@ -16,6 +18,7 @@
@@ -16,6 +18,7 @@
*/
*/
const { isOpenVibeError } = require('./core/errors');
const { isOpenVibeError } = require('./core/errors');
const { paginate, offsetPager } = require('./core/paginate');
const { paginate, offsetPager } = require('./core/paginate');
+const { createObjectsClient } = require('./objects');
const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
const enc = encodeURIComponent;
const enc = encodeURIComponent;
@@ -105,4 +108,4 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
@@ -105,4 +108,4 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
return { app, files, urls, upload: files.upload };
return { app, files, urls, upload: files.upload };
}
}
-module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
+module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
new file mode 100644
new file mode 100644
@@ -0,0 +1,316 @@
@@ -0,0 +1,316 @@
+'use strict';
+/**
+ * createObjectsClient(): OpenVibe.Media's object API v2 (/api/v2/:app/objects) and its jobs
+ * (/api/v2/:app/jobs). Exported from openvibe-sdk/media. Browser-safe code (fetch, Blob, Web Crypto),
+ * but the credentials it needs belong on a server.
+ *
+ * const objects = createObjectsClient({ app: 'demo', tokenClient: createServiceTokenClient({ clientId, clientSecret }) });
+ * const obj = await objects.upload(bytes, { kind: 'file', mimeType: 'image/png', filename: 'a.png' });
+ * const { url } = await objects.signedUrl(obj.id, { ttl: 300 });
+ *
+ * Base URL: `baseUrl`, or discovered from the Network's platform descriptor (/.well-known/openvibe).
+ * The descriptor gives an origin only for services that are live; when Media has none (not
+ * registered, or only a planned origin) the client throws sdk.service_unavailable instead of guessing.
+ *
+ * Credentials (one of): `tokenClient` (createServiceTokenClient: a service or developer-app principal
+ * with media.object.upload / media.object.read for namespace `app`; a developer app uses its project
+ * id as `app`), `apiKey` (the app's Media API key), or a `client` from createClient() that carries its
+ * own. `actingUserId` (app key only) acts for one of the app's users (X-OV-User-Id); `subject` names
+ * the owner (X-OV-Subject: usr_…).
+ *
+ * upload() picks single or multipart by size: single PUT to Media's presigned URL up to
+ * `multipartThreshold` (default 64 MiB), multipart above it (or when Media says the object is too
+ * large for one part). Parts go up `concurrency` at a time with their sha256; failed parts are retried
+ * and, after the round, the session is read back and whatever is missing is sent again (resumable
+ * after a dropped connection; `resume()` continues a session from another process). The whole
+ * object's sha256 is sent for Media to verify when the data is at most `hashMaxBytes` (256 MiB).
+ */
+const { createClient } = require('./core/client');
+const { OpenVibeError, isOpenVibeError } = require('./core/errors');
+const { paginate } = require('./core/paginate');
+
+const MIB = 1024 * 1024;
+const enc = encodeURIComponent;
+const TERMINAL_JOB = new Set(['succeeded', 'failed', 'cancelled']);
+
+function toBody(data) {
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
+ if (typeof data === 'string') return new TextEncoder().encode(data);
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ throw new TypeError('objects.upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');
+}
+const sizeOf = (body) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.size : body.byteLength);
+const slice = (body, start, end) => (typeof Blob !== 'undefined' && body instanceof Blob ? body.slice(start, end) : body.subarray(start, end));
+
+async function sha256Hex(part) {
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) return null;
+ const bytes = typeof Blob !== 'undefined' && part instanceof Blob ? new Uint8Array(await part.arrayBuffer()) : part;
+ const digest = await subtle.digest('SHA-256', bytes);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+function createObjectsClient({
+ app, baseUrl, tokenClient, apiKey, client, network, fetch: fetchImpl, discoveryUrl,
+ actingUserId, subject, multipartThreshold = 64 * MIB, partSize = 16 * MIB, concurrency = 4,
+ hashMaxBytes = 256 * MIB, resumeRounds = 3, timeoutMs, partTimeoutMs = 10 * 60 * 1000,
+} = {}) {
+ if (!app) throw new TypeError('createObjectsClient: app (the tenant: an app id, or a developer project id prj_…) is required');
+ if (!client && !tokenClient && !apiKey) throw new TypeError('createObjectsClient: pass tokenClient, apiKey or client');
+ const http = client || createClient({
+ network, discoveryUrl, fetch: fetchImpl, ...(tokenClient ? { tokenProvider: tokenClient } : {}), ...(timeoutMs ? { timeoutMs } : {}),
+ });
+ const O = `/api/v2/${enc(app)}/objects`;
+ const J = `/api/v2/${enc(app)}/jobs`;
+ let base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : null;
+
+ /** Media's origin: the given baseUrl, or the descriptor's live origin for `media`. */
+ async function resolveBase() {
+ if (base) return base;
+ const d = await http.discover();
+ const s = (d.services || []).find((x) => x && x.id === 'media');
+ if (!s || !s.origin) {
+ const why = !s ? 'Media is not in the platform descriptor' : `Media has no live origin in the platform descriptor (status ${s.status || 'unknown'}${s.planned_origin ? `, planned at ${s.planned_origin}` : ''})`;
+ throw new OpenVibeError({ code: 'sdk.service_unavailable', message: `${why}; pass baseUrl to use a Media you know is up` });
+ }
+ base = String(s.origin).replace(/\/+$/, '');
+ return base;
+ }
+
+ const headersFor = (per = {}) => {
+ const h = { ...(per.headers || {}) };
+ const acting = per.actingUserId !== undefined ? per.actingUserId : actingUserId;
+ if (acting != null) h['X-OV-User-Id'] = String(acting);
+ const subj = per.subject !== undefined ? per.subject : subject;
+ if (subj) h['X-OV-Subject'] = String(subj);
+ return h;
+ };
+ const call = async (opts, per = {}) => http.request({
+ service: 'media', audience: 'openvibe.media', baseUrl: await resolveBase(), ...(apiKey ? { token: apiKey } : {}),
+ ...opts, headers: headersFor({ ...per, headers: opts.headers }),
+ });
+ const json = async (opts, per) => (await call(opts, per)).data;
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ /** A presigned Media URL: no Authorization header (the token is in the URL). */
+ const presigned = (url, opts) => http.request({ url, auth: false, ...opts });
+
+ // ── Upload ───────────────────────────────────────────────
+
+ async function init(body, per) {
+ return json({ method: 'POST', path: O, json: body, idempotencyKey: false }, per);
+ }
+
+ /**
+ * How one multipart session is driven: through its presigned URLs (a fresh upload) or, for
+ * resume(), with our own credential on the same routes (Media accepts either).
+ */
+ function sessionIo(session, { objectId, credential = false } = {}) {
+ const path = `${O}/${enc(objectId)}/multipart/${enc(session.upload_id)}`;
+ return {
+ partSize: session.part_size,
+ partsExpected: session.parts_expected,
+ status: (signal) => (credential ? json({ path, signal }) : presigned(session.status_url, { method: 'GET', signal }).then((r) => r.data)),
+ putPart: (n, part, headers, signal) => (credential
+ ? call({ method: 'PUT', path: `${path}/parts/${n}`, body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })
+ : presigned(session.part_url_template.replace('{part_number}', String(n)), { method: 'PUT', body: part, headers, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 3 })),
+ complete: (body, signal) => (credential
+ ? json({ method: 'POST', path: `${path}/complete`, json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 })
+ : presigned(session.complete_url, { method: 'POST', json: body, signal, idempotent: true, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2 }).then((r) => r.data)),
+ };
+ }
+
+ async function putParts(io, body, todo, { signal, onProgress, total, done, shaOf }) {
+ const failed = [];
+ let next = 0;
+ const worker = async () => {
+ while (next < todo.length) {
+ const n = todo[next++];
+ const start = (n - 1) * io.partSize;
+ const part = slice(body, start, Math.min(start + io.partSize, total));
+ const sha = await shaOf(n);
+ try {
+ await io.putPart(n, part, { 'Content-Type': 'application/octet-stream', ...(sha ? { 'X-Content-SHA256': sha } : {}) }, signal);
+ done.bytes += sizeOf(part);
+ if (onProgress) onProgress({ uploadedBytes: done.bytes, totalBytes: total, part: n });
+ } catch (err) {
+ if (err && err.code === 'sdk.aborted') throw err;
+ failed.push({ part: n, error: err });
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, todo.length)) }, worker));
+ return failed;
+ }
+
+ /**
+ * Send what a session is missing (round after round, reading it back each time), then complete it.
+ * Parts Media already holds (a resumed session) are checked against our bytes; any that differ are
+ * sent again. The complete call names every part's sha256 as computed here.
+ */
+ async function finishMultipart(objectId, uploadId, io, body, { signal, onProgress, contentHash, total }) {
+ const mine = new Map();
+ const shaOf = async (n) => {
+ if (!mine.has(n)) {
+ const start = (n - 1) * io.partSize;
+ mine.set(n, await sha256Hex(slice(body, start, Math.min(start + io.partSize, total))));
+ }
+ return mine.get(n);
+ };
+ const done = { bytes: 0 };
+ let status = await io.status(signal);
+ if (status.total_size != null && status.total_size !== total) {
+ throw new OpenVibeError({ code: 'sdk.upload_mismatch', message: `objects.upload: the session expects ${status.total_size} bytes, the data has ${total}` });
+ }
+ const stale = [];
+ for (const p of status.parts || []) {
+ const ours = await shaOf(p.part_number);
+ if (ours && p.sha256 && ours !== p.sha256) stale.push(p.part_number);
+ else done.bytes += p.size_bytes;
+ }
+ let todo = [...(status.missing || []), ...stale].sort((a, b) => a - b);
+ let lastError = null;
+ for (let round = 0; round <= resumeRounds && todo.length; round++) {
+ const failed = await putParts(io, body, todo, { signal, onProgress, total, done, shaOf });
+ if (failed.length) lastError = failed[0].error;
+ status = await io.status(signal);
+ todo = status.missing || [];
+ }
+ if (todo.length) {
+ const err = new OpenVibeError({
+ code: 'sdk.upload_incomplete', cause: lastError || undefined,
+ message: `objects.upload: ${todo.length} part(s) could not be sent; call resume(err.resume, data) to continue`,
+ });
+ err.resume = { objectId, uploadId, missing: todo };
+ throw err;
+ }
+ const parts = [];
+ for (let n = 1; n <= io.partsExpected; n++) {
+ const sha = await shaOf(n);
+ if (sha) parts.push({ part_number: n, sha256: sha });
+ }
+ return io.complete({ ...(contentHash ? { content_hash: contentHash } : {}), ...(parts.length ? { parts } : {}) }, signal);
+ }
+
+ /**
+ * upload(data, { kind = 'file', visibility = 'private', mimeType | contentType, filename, metadata,
+ * contentHash (hex | false), userId, subject, actingUserId, multipart ('auto' | true | false),
+ * partSize, onProgress({ uploadedBytes, totalBytes, part }), signal, uploadTtl })
+ * -> the object (Media's public shape, lifecycle_status 'ready')
+ */
+ async function upload(data, opts = {}) {
+ const {
+ kind = 'file', visibility = 'private', filename, metadata, userId, multipart = 'auto', signal, onProgress, uploadTtl,
+ } = opts;
+ const mime = opts.mimeType || opts.contentType || (typeof Blob !== 'undefined' && data instanceof Blob && data.type) || null;
+ const body = toBody(data);
+ const total = sizeOf(body);
+ if (!total) throw new TypeError('objects.upload: nothing to upload (0 bytes)');
+ const per = { actingUserId: opts.actingUserId, subject: opts.subject };
+ let contentHash = opts.contentHash === false ? null : opts.contentHash || null;
+ if (!contentHash && opts.contentHash !== false && total <= hashMaxBytes) contentHash = await sha256Hex(body);
+ const wantParts = multipart === true || (multipart === 'auto' && total > multipartThreshold);
+ const req = {
+ kind, visibility, size_bytes: total, ...(mime ? { mime_type: mime } : {}), ...(filename ? { filename } : {}),
+ ...(metadata ? { metadata } : {}), ...(contentHash ? { content_hash: contentHash } : {}), ...(userId != null ? { user_id: userId } : {}),
+ ...(uploadTtl ? { upload_ttl: uploadTtl } : {}),
+ };
+ let created;
+ try {
+ created = await init(wantParts ? { ...req, multipart: true, part_size: opts.partSize || partSize } : req, per);
+ } catch (err) {
+ // Media's single-part limit is lower than our threshold: go multipart.
+ if (!(multipart === 'auto' && !wantParts && isOpenVibeError(err) && err.status === 413 && err.code === 'media.object.too_large')) throw err;
+ created = await init({ ...req, multipart: true, part_size: opts.partSize || partSize }, per);
+ }
+ const up = created.upload || {};
+ if (up.multipart) {
+ return finishMultipart(created.id, up.multipart.upload_id, sessionIo(up.multipart, { objectId: created.id }), body, { signal, onProgress, contentHash, total });
+ }
+ await presigned(up.url, {
+ method: 'PUT', body, signal, timeoutMs: partTimeoutMs, deadlineMs: partTimeoutMs * 2,
+ headers: { 'Content-Type': mime || 'application/octet-stream' },
+ });
+ if (onProgress) onProgress({ uploadedBytes: total, totalBytes: total });
+ return json({ method: 'POST', path: `${O}/${enc(created.id)}/complete`, json: contentHash ? { content_hash: contentHash } : {}, idempotent: true }, per);
+ }
+
+ /**
+ * Continue a multipart upload, e.g. from another process after a crash: reads the session with
+ * this client's credential, sends the parts it is missing from `data` (the same bytes), completes
+ * it. `ref` is err.resume from upload(), or { objectId, uploadId }.
+ */
+ async function resume({ objectId, uploadId }, data, { signal, onProgress, contentHash } = {}) {
+ const body = toBody(data);
+ const total = sizeOf(body);
+ const session = await json({ path: `${O}/${enc(objectId)}/multipart/${enc(uploadId)}`, signal });
+ if (session.status !== 'active') throw new OpenVibeError({ code: 'sdk.upload_incomplete', message: `objects.resume: the upload is ${session.status}` });
+ const hash = contentHash === false ? null : contentHash || (total <= hashMaxBytes ? await sha256Hex(body) : null);
+ return finishMultipart(objectId, uploadId, sessionIo(session, { objectId, credential: true }), body, { signal, onProgress, contentHash: hash, total });
+ }
+
+ // ── Reads, links, delete ─────────────────────────────────
+
+ /** Object metadata (locations, lifecycle, public_url …), or null. Accepts med_… ids and legacy refs. */
+ const get = (id) => orNull(json({ path: `${O}/${enc(id)}` }));
+
+ /**
+ * signedUrl(id, { ttl }) -> { url, expires_at, public }. Private objects get a signed link valid
+ * `ttl` seconds (30-3600); public and unlisted ones their public URL (expires_at null).
+ */
+ const signedUrl = (id, { ttl } = {}) => json({ path: `${O}/${enc(id)}/download`, query: { format: 'json', ttl } });
+
+ /** Soft delete. true when deleted (or already), false when there is no such object. */
+ async function del(id) {
+ try { await call({ method: 'DELETE', path: `${O}/${enc(id)}` }); return true; } catch (err) {
+ if (isOpenVibeError(err) && err.status === 404) return false;
+ throw err;
+ }
+ }
+
+ /** One page: { objects, next_cursor, limit }. */
+ const list = ({ kind, visibility, status, owner, userId, limit, cursor, includeDeleted } = {}) => json({
+ path: O, query: { kind, visibility, status, owner, user_id: userId, limit, cursor, include_deleted: includeDeleted ? 1 : undefined },
+ });
+
+ /** Async iterator over every matching object, newest first. */
+ const iterate = (q = {}) => paginate(async (cursor) => {
+ const page = await list({ ...q, cursor: cursor || undefined });
+ return { items: page.objects || [], next: page.next_cursor };
+ }, { cursor: null });
+
+ // ── Jobs ─────────────────────────────────────────────────
+
+ const jobs = {
+ /** create({ type, objectId, params, idempotencyKey, maxAttempts }) -> job (a repeat of the same key answers the same job). */
+ async create({ type, objectId, params, idempotencyKey, maxAttempts } = {}) {
+ if (!type) throw new TypeError('jobs.create: type is required');
+ const res = await json({
+ method: 'POST', path: J, idempotencyKey: idempotencyKey || undefined,
+ json: { type, ...(objectId ? { object_id: objectId } : {}), ...(params ? { params } : {}), ...(maxAttempts ? { max_attempts: maxAttempts } : {}) },
+ });
+ return res.job;
+ },
+ get: async (id) => { const r = await orNull(json({ path: `${J}/${enc(id)}` })); return r ? r.job : null; },
+ /** { jobs, next_cursor, limit } */
+ list: ({ status, type, objectId, limit, cursor } = {}) => json({ path: J, query: { status, type, object_id: objectId, limit, cursor } }),
+ /** A proposal (status proposed) -> queued. */
+ approve: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/approve`, idempotent: true })).job,
+ /** Cancel: proposed/queued at once; a running job reports cancel_requested until it stops. */
+ cancel: async (id) => (await json({ method: 'POST', path: `${J}/${enc(id)}/cancel`, idempotent: true })).job,
+ /** Poll until the job is succeeded, failed or cancelled (or timeoutMs passes: the job as it is then). */
+ async wait(id, { intervalMs = 1000, timeoutMs: max = 10 * 60 * 1000, signal } = {}) {
+ const end = Date.now() + max;
+ for (;;) {
+ const job = await jobs.get(id);
+ if (!job || TERMINAL_JOB.has(job.status) || Date.now() >= end) return job;
+ if (signal && signal.aborted) throw new OpenVibeError({ code: 'sdk.aborted', message: 'jobs.wait: aborted' });
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, end - Date.now()))));
+ }
+ },
+ };
+
+ return { app, baseUrl: resolveBase, upload, resume, get, signedUrl, delete: del, list, iterate, jobs, client: http };
+}
+
+module.exports = { createObjectsClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,294 @@
@@ -0,0 +1,294 @@
+'use strict';
+/**
+ * createObjectsClient (openvibe-sdk/media) against a mock of Media's object API v2: discovery of the
+ * Media origin from the platform descriptor (live origins only), single uploads through the presigned
+ * URL, multipart uploads with per-part sha256 (automatic by size, and as a fallback when Media refuses
+ * a single part), recovery from failed parts, resume() of a session another process started, get /
+ * signedUrl / delete / list / iterate, jobs, and the credentials and headers each call carries.
+ */
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const { stubServer, send, problem, run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createObjectsClient } = require('../src/media');
+
+const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
+
+/** A small Media v2: objects, presigned single PUT, multipart sessions, download links, jobs. */
+async function mockMedia({ singleMax = 1024 * 1024, failPart = null, descriptor = null } = {}) {
+ const state = { objects: new Map(), sessions: new Map(), jobs: new Map(), n: 0, failures: 0 };
+ let base = '';
+ const srv = await stubServer(async (req, res, body) => {
+ const u = new URL(req.url, 'http://x');
+ const p = u.pathname;
+ if (p === '/.well-known/openvibe') return send(res, 200, descriptor ? descriptor(base) : { contracts: { version: '0.30.0' }, services: [{ id: 'media', status: 'beta', origin: base }] });
+ const m = /^\/api\/v2\/([^/]+)\/(objects|jobs)(?:\/([^/]+))?(?:\/(.+))?$/.exec(p);
+ if (!m) return send(res, 404, {});
+ const [, app, coll, id, rest] = m;
+ const token = u.searchParams.get('token');
+ const authed = !!req.headers.authorization;
+ if (coll === 'jobs') {
+ if (!authed) return problem(res, 401, 'token.missing', 'no');
+ if (req.method === 'POST' && !id) {
+ const b = JSON.parse(body);
+ const key = req.headers['idempotency-key'];
+ const prior = key && [...state.jobs.values()].find((j) => j.key === key);
+ if (prior) return send(res, 200, { job: prior.pub }, { 'Idempotent-Replayed': 'true' });
+ const job = { key, polls: 0, pub: { id: `mjob_${++state.n}`, app_id: app, type: b.type, object_id: b.object_id || null, params: b.params || {}, status: 'queued' } };
+ state.jobs.set(job.pub.id, job);
+ return send(res, 202, { job: job.pub });
+ }
+ const job = state.jobs.get(id);
+ if (!id) return send(res, 200, { jobs: [...state.jobs.values()].map((j) => j.pub).filter((j) => !u.searchParams.get('status') || j.status === u.searchParams.get('status')), next_cursor: null });
+ if (!job) return problem(res, 404, 'media.job.not_found', 'No such job');
+ if (req.method === 'GET') { if (++job.polls >= 3 && job.pub.status === 'queued') job.pub.status = 'succeeded'; return send(res, 200, { job: job.pub }); }
+ if (rest === 'approve') { job.pub.status = 'queued'; return send(res, 200, { job: job.pub }); }
+ if (rest === 'cancel') { job.pub.status = 'cancelled'; return send(res, 200, { job: job.pub }); }
+ }
+ // ── objects ──
+ if (req.method === 'POST' && !id) {
+ if (!authed) return problem(res, 401, 'token.missing', 'no');
+ const b = JSON.parse(body);
+ if (!b.multipart && b.size_bytes > singleMax) return problem(res, 413, 'media.object.too_large', `Single-part uploads are limited to ${singleMax} bytes; send multipart: true for larger objects`);
+ const oid = `med_${String(++state.n).padStart(26, '0')}`;
+ const obj = { id: oid, app_id: app, kind: b.kind, visibility: b.visibility, size_bytes: b.size_bytes, mime_type: b.mime_type || null, lifecycle_status: 'uploading', expected: b.content_hash || null, bytes: null, init: b, headers: req.headers };
+ state.objects.set(oid, obj);
+ const out = { id: oid, object: pub(obj), upload: { complete_url: `${base}/api/v2/${app}/objects/${oid}/complete`, multipart_url: `${base}/api/v2/${app}/objects/${oid}/multipart` } };
+ if (b.multipart) out.upload = { ...out.upload, method: 'multipart', url: null, multipart: newSession(obj, b.part_size) };
+ else Object.assign(out.upload, { method: 'PUT', url: `${base}/api/v2/${app}/objects/${oid}/content?token=put-${oid}`, token: `put-${oid}` });
+ return send(res, 201, out);
+ }
+ if (req.method === 'GET' && !id) {
+ if (!authed) return problem(res, 401, 'token.missing', 'no');
+ const all = [...state.objects.values()].filter((o) => o.lifecycle_status !== 'deleted').sort((a, b) => (a.id < b.id ? 1 : -1));
+ const cursor = u.searchParams.get('cursor');
+ const limit = Number(u.searchParams.get('limit') || 50);
+ const from = cursor ? all.filter((o) => o.id < cursor) : all;
+ const page = from.slice(0, limit);
+ return send(res, 200, { objects: page.map(pub), next_cursor: from.length > limit ? page[page.length - 1].id : null, limit });
+ }
+ const obj = state.objects.get(id);
+ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');
+ if (rest === 'content' && req.method === 'PUT') {
+ if (authed || token !== `put-${id}`) return problem(res, 401, 'media.upload_token.invalid', 'presigned only here');
+ if (body.length !== obj.size_bytes) return problem(res, 400, 'media.object.size_mismatch', 'size');
+ obj.bytes = body; obj.putType = req.headers['content-type'];
+ return send(res, 200, { id, size_bytes: body.length, content_hash: sha(body) });
+ }
+ if (rest === 'complete') {
+ if (!authed) return problem(res, 401, 'token.missing', 'no');
+ const b = body.length ? JSON.parse(body) : {};
+ if ((b.content_hash || obj.expected) && sha(obj.bytes) !== (b.content_hash || obj.expected)) return problem(res, 422, 'media.object.hash_mismatch', 'hash');
+ obj.lifecycle_status = 'ready';
+ return send(res, 200, pub(obj));
+ }
+ if (rest && rest.startsWith('multipart/')) {
+ const [, uid, what, n] = rest.split('/');
+ const s = state.sessions.get(uid);
+ if (!s || s.objectId !== id) return problem(res, 404, 'media.upload.not_found', 'no');
+ if (!authed && token !== s.token) return problem(res, 401, 'media.upload_token.invalid', 'no');
+ s.calls.push({ method: req.method, what: what || 'status', authed, n: n && Number(n) });
+ if (!what && req.method === 'GET') return send(res, 200, sessionPub(s));
+ if (what === 'parts') {
+ const k = Number(n);
+ const want = k < s.parts_expected ? s.part_size : s.total_size - s.part_size * (s.parts_expected - 1);
+ if (failPart && failPart(k, s)) { state.failures++; res.socket.destroy(); return; }
+ if (body.length !== want) return problem(res, 400, 'media.upload.part_size_mismatch', 'size');
+ if (req.headers['x-content-sha256'] && req.headers['x-content-sha256'] !== sha(body)) return problem(res, 400, 'media.upload.part_hash_mismatch', 'sha');
+ s.parts.set(k, body);
+ return send(res, 200, { part_number: k, size_bytes: body.length, sha256: sha(body) });
+ }
+ if (what === 'complete') {
+ const b = JSON.parse(body || '{}');
+ if (sessionPub(s).missing.length) return problem(res, 409, 'media.upload.parts_missing', 'missing');
+ for (const c of b.parts || []) if (sha(s.parts.get(c.part_number)) !== c.sha256) return problem(res, 400, 'media.upload.part_hash_mismatch', `part ${c.part_number}`);
+ obj.bytes = Buffer.concat([...s.parts.keys()].sort((x, y) => x - y).map((k) => s.parts.get(k)));
+ s.completeBody = b;
+ if (b.content_hash && b.content_hash !== sha(obj.bytes)) return problem(res, 422, 'media.object.hash_mismatch', 'hash');
+ obj.lifecycle_status = 'ready';
+ s.status = 'completed';
+ return send(res, 200, pub(obj));
+ }
+ }
+ if (!authed) return problem(res, 401, 'token.missing', 'no');
+ if (rest === 'download') return send(res, 200, { url: `${base}/o/${id}?exp=1&sig=s&ttl=${u.searchParams.get('ttl')}`, expires_at: '2030-01-01T00:00:00.000Z', public: false, format: u.searchParams.get('format') });
+ if (req.method === 'GET' && !rest) return send(res, 200, pub(obj));
+ if (req.method === 'DELETE' && !rest) { obj.lifecycle_status = 'deleted'; return send(res, 200, pub(obj)); }
+ return send(res, 404, {});
+ });
+ base = srv.url;
+ function pub(o) { return { id: o.id, app_id: o.app_id, kind: o.kind, visibility: o.visibility, size_bytes: o.size_bytes, mime_type: o.mime_type, lifecycle_status: o.lifecycle_status, content_hash: o.bytes ? sha(o.bytes) : null }; }
+ function newSession(obj, partSize) {
+ const uid = `mup_${++state.n}`;
+ const s = { upload_id: uid, objectId: obj.id, part_size: partSize, total_size: obj.size_bytes, parts_expected: Math.ceil(obj.size_bytes / partSize), token: `mp1.${uid}`, parts: new Map(), calls: [], status: 'active' };
+ state.sessions.set(uid, s);
+ const u = `${base}/api/v2/${obj.app_id}/objects/${obj.id}/multipart/${uid}`;
+ return { ...sessionPub(s, false), token: s.token, part_url_template: `${u}/parts/{part_number}?token=${s.token}`, status_url: `${u}?token=${s.token}`, complete_url: `${u}/complete?token=${s.token}`, abort_url: `${u}?token=${s.token}` };
+ }
+ function sessionPub(s, withParts = true) {
+ const out = { upload_id: s.upload_id, object_id: s.objectId, status: s.status, part_size: s.part_size, total_size: s.total_size, parts_expected: s.parts_expected };
+ if (!withParts) return out;
+ const parts = [...s.parts.entries()].sort((a, b) => a[0] - b[0]).map(([k, b]) => ({ part_number: k, size_bytes: b.length, sha256: sha(b) }));
+ const missing = [];
+ for (let k = 1; k <= s.parts_expected; k++) if (!s.parts.has(k)) missing.push(k);
+ return { ...out, parts, missing };
+ }
+ return { srv, state, newSession };
+}
+
+const tokenClient = () => {
+ const seen = [];
+ return { seen, getToken: async (ctx) => { seen.push(ctx.audience); return 'svc-token'; }, invalidate() {} };
+};
+
+run([
+ ['discovery: the descriptor gives Media\'s live origin; none (or a planned one) is an error, never a guess', async () => {
+ const m = await mockMedia();
+ const tc = tokenClient();
+ const objects = createObjectsClient({ app: 'demo', tokenClient: tc, network: m.srv.url });
+ assert.equal(await objects.baseUrl(), m.srv.url);
+ const obj = await objects.upload('hi', { mimeType: 'text/plain' });
+ assert.equal(obj.lifecycle_status, 'ready');
+ assert.deepEqual([...new Set(tc.seen)], ['openvibe.media'], 'tokens are asked for audience openvibe.media');
+ await m.srv.close();
+
+ const planned = await mockMedia({ descriptor: () => ({ contracts: { version: '0.30.0' }, services: [{ id: 'media', status: 'planned', origin: null, planned_origin: 'https://openvibe.media' }] }) });
+ const p = createObjectsClient({ app: 'demo', tokenClient: tokenClient(), network: planned.srv.url });
+ await assert.rejects(p.get('med_x'), (e) => e.code === 'sdk.service_unavailable' && /planned at https:\/\/openvibe.media/.test(e.message));
+ await planned.srv.close();
+ const none = await mockMedia({ descriptor: () => ({ contracts: { version: '0.30.0' }, services: [{ id: 'network', status: 'stable', origin: 'https://openvibe.network' }] }) });
+ await assert.rejects(createObjectsClient({ app: 'demo', tokenClient: tokenClient(), network: none.srv.url }).list(), (e) => e.code === 'sdk.service_unavailable');
+ await none.srv.close();
+ assert.throws(() => createObjectsClient({ tokenClient: tokenClient() }), TypeError);
+ assert.throws(() => createObjectsClient({ app: 'demo' }), TypeError);
+ }],
+
+ ['single upload: init with size, type and sha256; the bytes go to the presigned URL without a credential; complete', async () => {
+ const m = await mockMedia();
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });
+ const bytes = crypto.randomBytes(5000);
+ const progress = [];
+ const obj = await objects.upload(bytes, { kind: 'screenshot', visibility: 'public', mimeType: 'image/png', filename: 'a.png', metadata: { a: 1 }, onProgress: (p) => progress.push(p) });
+ assert.deepEqual([obj.lifecycle_status, obj.size_bytes, obj.content_hash], ['ready', 5000, sha(bytes)]);
+ const init = m.srv.requests.find((r) => r.method === 'POST' && r.url === '/api/v2/demo/objects');
+ assert.deepEqual(JSON.parse(init.body), { kind: 'screenshot', visibility: 'public', size_bytes: 5000, mime_type: 'image/png', filename: 'a.png', metadata: { a: 1 }, content_hash: sha(bytes) });
+ assert.equal(init.headers.authorization, 'Bearer svc-token');
+ assert.equal(init.headers['x-ov-subject'], 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');
+ const put = m.srv.requests.find((r) => r.method === 'PUT');
+ assert.equal(put.headers.authorization, undefined, 'the presigned URL carries the credential');
+ assert.equal(put.headers['content-type'], 'image/png');
+ assert.deepEqual(put.body, bytes);
+ assert.equal(progress.at(-1).uploadedBytes, 5000);
+ await assert.rejects(objects.upload(new Uint8Array(0)), TypeError);
+ await assert.rejects(objects.upload({ nope: true }), TypeError);
+ await m.srv.close();
+ }],
+
+ ['multipart by size: parts with sha256, in parallel; complete names every part; the object is the original', async () => {
+ const m = await mockMedia();
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), multipartThreshold: 10000, partSize: 4096, concurrency: 3 });
+ const bytes = crypto.randomBytes(4096 * 4 + 100);
+ const obj = await objects.upload(new Blob([bytes], { type: 'video/webm' }), { kind: 'vod' });
+ const init = JSON.parse(m.srv.requests.find((r) => r.url === '/api/v2/demo/objects').body);
+ assert.deepEqual([init.multipart, init.part_size, init.mime_type, init.content_hash], [true, 4096, 'video/webm', sha(bytes)]);
+ const s = [...m.state.sessions.values()][0];
+ assert.equal(s.parts_expected, 5);
+ const puts = m.srv.requests.filter((r) => r.method === 'PUT');
+ assert.equal(puts.length, 5);
+ assert.ok(puts.every((r) => !r.headers.authorization && r.headers['x-content-sha256'] === sha(r.body)), 'no credential, each part with its sha256');
+ assert.deepEqual(s.completeBody.parts.map((p) => p.part_number), [1, 2, 3, 4, 5]);
+ assert.equal(s.completeBody.content_hash, sha(bytes));
+ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);
+ assert.equal(obj.lifecycle_status, 'ready');
+ await m.srv.close();
+ }],
+
+ ['Media refusing a single part (413) sends it as multipart instead', async () => {
+ const m = await mockMedia({ singleMax: 1000 });
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient(), partSize: 1024 });
+ const bytes = crypto.randomBytes(3000);
+ const obj = await objects.upload(bytes);
+ assert.equal(obj.lifecycle_status, 'ready');
+ const inits = m.srv.requests.filter((r) => r.url === '/api/v2/demo/objects').map((r) => JSON.parse(r.body).multipart);
+ assert.deepEqual(inits, [undefined, true]);
+ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);
+ await m.srv.close();
+ }],
+
+ ['dropped connections: a part that keeps failing is sent again in the next round after reading the session back', async () => {
+ let drops = 0;
+ const m = await mockMedia({ failPart: (k) => k === 2 && drops++ < 4 });
+ const client = createClient({ retryDelayMs: 1, retries: 2 });
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, client, apiKey: 'app-key', multipartThreshold: 100, partSize: 1000 });
+ const bytes = crypto.randomBytes(2500);
+ const obj = await objects.upload(bytes);
+ assert.equal(obj.lifecycle_status, 'ready');
+ assert.equal(m.state.failures, 4, 'three attempts in round 1 (the client retries), one more in round 2');
+ const s = [...m.state.sessions.values()][0];
+ assert.ok(s.calls.filter((c) => c.what === 'status').length >= 2, 'the session was read back');
+ assert.deepEqual(m.state.objects.get(obj.id).bytes, bytes);
+ assert.equal(m.srv.requests[0].headers.authorization, 'Bearer app-key', 'the app key when given');
+ await m.srv.close();
+ }],
+
+ ['a part that never goes up: sdk.upload_incomplete with err.resume; resume() finishes it with the credential', async () => {
+ let broken = true;
+ const m = await mockMedia({ failPart: (k) => k === 3 && broken });
+ const client = createClient({ retryDelayMs: 1, retries: 0, token: 'svc-token' });
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, client, multipartThreshold: 100, partSize: 1000, resumeRounds: 1 });
+ const bytes = crypto.randomBytes(2500);
+ const err = await objects.upload(bytes).then(() => null, (e) => e);
+ assert.equal(err && err.code, 'sdk.upload_incomplete');
+ assert.deepEqual(err.resume.missing, [3]);
+ broken = false;
+ const s = m.state.sessions.get(err.resume.uploadId);
+ s.parts.set(2, Buffer.alloc(1000)); // a part that differs from our bytes: sent again
+ s.calls.length = 0;
+ const done = await objects.resume(err.resume, bytes);
+ assert.equal(done.lifecycle_status, 'ready');
+ assert.deepEqual(s.calls.filter((c) => c.what === 'parts').map((c) => [c.n, c.authed]).sort(), [[2, true], [3, true]], 'only the stale and the missing part, with the credential');
+ assert.deepEqual(m.state.objects.get(err.resume.objectId).bytes, bytes);
+ await assert.rejects(objects.resume(err.resume, bytes), (e) => e.code === 'sdk.upload_incomplete', 'a completed session cannot be resumed');
+ await m.srv.close();
+ }],
+
+ ['get, signedUrl, delete, list and iterate', async () => {
+ const m = await mockMedia();
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, apiKey: 'k', actingUserId: 7 });
+ const a = await objects.upload('one');
+ await objects.upload('two');
+ await objects.upload('three');
+ assert.equal((await objects.get(a.id)).id, a.id);
+ assert.equal(await objects.get('med_missing'), null);
+ const link = await objects.signedUrl(a.id, { ttl: 120 });
+ assert.deepEqual([link.public, link.format, new URL(link.url).searchParams.get('ttl')], [false, 'json', '120']);
+ const page = await objects.list({ limit: 2 });
+ assert.equal(page.objects.length, 2);
+ assert.ok(page.next_cursor);
+ const all = [];
+ for await (const o of objects.iterate({ limit: 2 })) all.push(o.id);
+ assert.equal(all.length, 3);
+ assert.equal(await objects.delete(a.id), true);
+ assert.equal(await objects.delete('med_missing'), false);
+ assert.ok(m.srv.requests.filter((r) => r.headers.authorization).every((r) => r.headers['x-ov-user-id'] === '7'), 'X-OV-User-Id on every credentialed call');
+ await m.srv.close();
+ }],
+
+ ['jobs: create with an Idempotency-Key, get, approve, cancel, wait', async () => {
+ const m = await mockMedia();
+ const objects = createObjectsClient({ app: 'demo', baseUrl: m.srv.url, tokenClient: tokenClient() });
+ const job = await objects.jobs.create({ type: 'object.remux', objectId: 'med_1', idempotencyKey: 'remux-1' });
+ assert.deepEqual([job.type, job.object_id, job.status], ['object.remux', 'med_1', 'queued']);
+ assert.equal(m.srv.requests.at(-1).headers['idempotency-key'], 'remux-1');
+ assert.equal((await objects.jobs.create({ type: 'object.remux', objectId: 'med_1', idempotencyKey: 'remux-1' })).id, job.id, 'a repeat answers the same job');
+ const done = await objects.jobs.wait(job.id, { intervalMs: 5 });
+ assert.equal(done.status, 'succeeded');
+ assert.equal(await objects.jobs.get('mjob_nope'), null);
+ const other = await objects.jobs.create({ type: 'invariant.scan' });
+ assert.equal((await objects.jobs.approve(other.id)).status, 'queued');
+ assert.equal((await objects.jobs.cancel(other.id)).status, 'cancelled');
+ assert.equal((await objects.jobs.list({ status: 'cancelled' })).jobs.length, 1);
+ await assert.rejects(objects.jobs.create({}), TypeError);
+ await m.srv.close();
+ }],
+]);
@@ -59,3 +59,124 @@ export interface MediaClient {
@@ -59,3 +59,124 @@ export interface MediaClient {
*/
*/
export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;
export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;
export declare const DEFAULT_PUBLIC_ORIGIN: string;
export declare const DEFAULT_PUBLIC_ORIGIN: string;
+
+/** A Media object (object API v2, `/api/v2/:app/objects`): Media's public shape. */
+export interface MediaObject {
+ id: string;
+ media_ref?: { media_id: string };
+ legacy_ref?: string | null;
+ app_id: string;
+ namespace?: string;
+ kind: 'vod' | 'clip' | 'file' | 'thumbnail' | 'screenshot' | 'avatar' | 'asset' | string;
+ owner?: { subject: string | null; app: string | null; user_id: number | null };
+ visibility: 'public' | 'unlisted' | 'private';
+ lifecycle_status: 'uploading' | 'ready' | 'failed' | 'archived' | 'deleted' | string;
+ mime_type: string | null;
+ size_bytes: number;
+ content_hash: string | null;
+ metadata?: Record<string, unknown>;
+ held?: boolean;
+ /** Public/unlisted and ready: the public URL; null otherwise (private, sandbox). */
+ public_url?: string | null;
+ sandbox?: true;
+ locations?: Array<{ provider: 'local' | 'b2' | 'r2'; storage_class: string | null; state: string; size_bytes: number | null; verified_at: string | null; canonical: boolean }>;
+ created_at?: string;
+ updated_at?: string;
+ deleted_at?: string | null;
+ [key: string]: unknown;
+}
+
+/** A Media job (`/api/v2/:app/jobs`). */
+export interface MediaJob {
+ id: string;
+ app_id: string;
+ object_id: string | null;
+ type: 'thumbnail.regenerate' | 'invariant.scan' | 'object.split' | 'object.remux' | string;
+ status: 'proposed' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
+ params: Record<string, unknown>;
+ result: unknown;
+ error: string | null;
+ error_code: string | null;
+ attempts: number;
+ max_attempts: number;
+ cancel_requested: boolean;
+ idempotency_key: string | null;
+ created_at: string;
+ finished_at: string | null;
+ [key: string]: unknown;
+}
+
+export interface ObjectUploadOptions {
+ kind?: MediaObject['kind'];
+ visibility?: MediaObject['visibility'];
+ mimeType?: string;
+ contentType?: string;
+ filename?: string;
+ metadata?: Record<string, unknown>;
+ /** Expected sha256 (hex) for Media to verify; computed when omitted and the data is at most hashMaxBytes; false: none. */
+ contentHash?: string | false;
+ userId?: number;
+ subject?: string | null;
+ actingUserId?: string | number | null;
+ /** 'auto' (default): multipart above multipartThreshold, or when Media refuses one part. */
+ multipart?: 'auto' | boolean;
+ partSize?: number;
+ uploadTtl?: number;
+ signal?: AbortSignal;
+ onProgress?(p: { uploadedBytes: number; totalBytes: number; part?: number }): void;
+}
+
+/** err.resume of an sdk.upload_incomplete error. */
+export interface ObjectUploadRef { objectId: string; uploadId: string; missing?: number[] }
+
+export interface ObjectsClient {
+ app: string;
+ /** Media's origin: the baseUrl option, or the platform descriptor's live origin for `media`. */
+ baseUrl(): Promise<string>;
+ upload(data: UploadBody, opts?: ObjectUploadOptions): Promise<MediaObject>;
+ resume(ref: ObjectUploadRef, data: UploadBody, opts?: { signal?: AbortSignal; onProgress?: ObjectUploadOptions['onProgress']; contentHash?: string | false }): Promise<MediaObject>;
+ get(id: string): Promise<MediaObject | null>;
+ signedUrl(id: string, opts?: { ttl?: number }): Promise<{ url: string; expires_at: string | null; public: boolean }>;
+ delete(id: string): Promise<boolean>;
+ list(opts?: { kind?: string; visibility?: string; status?: string; owner?: string; userId?: number; limit?: number; cursor?: string; includeDeleted?: boolean }): Promise<{ objects: MediaObject[]; next_cursor: string | null; limit: number }>;
+ iterate(opts?: { kind?: string; visibility?: string; status?: string; owner?: string; userId?: number; limit?: number; includeDeleted?: boolean }): AsyncGenerator<MediaObject, void, unknown>;
+ jobs: {
+ create(opts: { type: MediaJob['type']; objectId?: string; params?: Record<string, unknown>; idempotencyKey?: string; maxAttempts?: number }): Promise<MediaJob>;
+ get(id: string): Promise<MediaJob | null>;
+ list(opts?: { status?: MediaJob['status']; type?: string; objectId?: string; limit?: number; cursor?: string }): Promise<{ jobs: MediaJob[]; next_cursor: string | null; limit: number }>;
+ approve(id: string): Promise<MediaJob>;
+ cancel(id: string): Promise<MediaJob>;
+ wait(id: string, opts?: { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal }): Promise<MediaJob | null>;
+ };
+ client: OpenVibeClient;
+}
+
+/**
+ * Media's object API v2. Credentials: tokenClient (createServiceTokenClient; media.object.upload and
+ * media.object.read for namespace `app`), apiKey, or a client carrying its own. Without baseUrl the Media
+ * origin comes from the platform descriptor (live services only; otherwise sdk.service_unavailable).
+ */
+export declare function createObjectsClient(opts: {
+ app: string;
+ baseUrl?: string;
+ tokenClient?: { getToken(ctx?: { audience?: string; scope?: string | string[] }): Promise<string>; invalidate?(ctx?: { audience?: string }): void };
+ apiKey?: string;
+ client?: OpenVibeClient;
+ network?: string;
+ discoveryUrl?: string;
+ fetch?: typeof fetch;
+ actingUserId?: string | number;
+ subject?: string;
+ /** Default 64 MiB. */
+ multipartThreshold?: number;
+ /** Default 16 MiB (Media accepts 5-256 MB by default). */
+ partSize?: number;
+ /** Parts in flight at once; default 4. */
+ concurrency?: number;
+ /** Whole-object sha256 is computed up to this size; default 256 MiB. */
+ hashMaxBytes?: number;
+ /** Extra rounds for parts that failed; default 3. */
+ resumeRounds?: number;
+ timeoutMs?: number;
+ partTimeoutMs?: number;
+}): ObjectsClient;