openvibers/openvibe.sdk Commits

0.5.0: Media object API v2 client (createObjectsClient in openvibe-sdk/media)

OP OpenVibers committed 23 Sept 2026 · 7d3eff3d1a · 12 files, +1111 −21

createObjectsClient({ app, baseUrl?, tokenClient | apiKey | client }) wraps /api/v2/:app/objects and
/api/v2/:app/jobs:
- upload() picks single or multipart by size: single = init, the bytes to Media's presigned PUT URL
  (no credential on it), complete; multipart above multipartThreshold (or when Media answers 413 to one
  part) = parts in parallel with X-Content-SHA256, the session read back after each round and missing
  parts sent again, complete naming every part's sha256; the whole object's sha256 for Media to verify.
  A part that cannot be sent ends in sdk.upload_incomplete with err.resume.
- resume() continues a session from another process with the client's credential, re-sending parts
  whose sha256 differs from the caller's bytes.
- get, signedUrl, delete, list, iterate; jobs.create (Idempotency-Key), get, list, approve, cancel, wait.
- Without baseUrl, Media's origin comes from the platform descriptor (/.well-known/openvibe), which
  gives origins only for live services; no live origin is sdk.service_unavailable, never a guess.

Types, ESM entry and browser bundle regenerated; test/objects.test.js runs it against a mock of
Media v2 (discovery, single, multipart, 413 fallback, dropped parts, resume, reads, jobs, headers).
CHANGELOG 0.5.0; not tagged.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0c1520c..f75c05c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,40 @@
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.
++## 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:
ddiff --git a/README.md b/README.md
iindex 6d26510..1d97032 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 345703d..a3ed1f1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c559ad1..6a752a8 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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, publicO
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) {
'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 = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 15: src/projects.js ─────────────────────────────────────────────
++// ── 16: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
ddiff --git a/esm/media.mjs b/esm/media.mjs
iindex e3f889f..a592b37 100644
---- a/esm/media.mjs
++++ b/esm/media.mjs
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 22b7f1b..bca0a96 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 15effb9..4d4036f 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 12ff9fc..182fb01 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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,
ddiff --git a/src/media.js b/src/media.js
iindex 946604a..68c0fb7 100644
---- a/src/media.js
++++ b/src/media.js
@@@ -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, publicO
return { app, files, urls, upload: files.upload };
}
--module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
++module.exports = { createMediaClient, createObjectsClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
ddiff --git a/src/objects.js b/src/objects.js
nnew file mode 100644
iindex 0000000..24bca25
---- /dev/null
++++ b/src/objects.js
@@@ -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 };
ddiff --git a/test/objects.test.js b/test/objects.test.js
nnew file mode 100644
iindex 0000000..36de2f9
---- /dev/null
++++ b/test/objects.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/types/media.d.ts b/types/media.d.ts
iindex 052a67f..f34696d 100644
---- a/types/media.d.ts
++++ b/types/media.d.ts
@@@ -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;