openvibers/openvibe.media Commits

Multipart complete is idempotent: completing a session that already completed (a retry after its answer was lost) returns the ready object instead of 409, as the single-part complete does; the SDK objects client (openvibe-sdk 0.5.0) retries it. Tenant CORS allows the SDK's traceparent and X-OpenVibe-Request-Id headers next to X-Upload-Token, X-Content-SHA256 and Idempotency-Key, so a browser can send parts to presigned URLs

OP OpenVibers committed 23 Sept 2026 · 0ffbde54b8 · 5 files, +13 −4


Changes

ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 9d5ba2e..f98af9c 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -203,7 +203,8 @@ client asks with `multipart: true`. Code: `server/objects/multipart.js`.
`parts` list with sha256 values must match what was received. The parts are concatenated into
`OBJECTS_PATH/<app>/<id>` while hashing, the parts are deleted, and the checks of `/complete`
follow (expected hash, invariant, quota, content). If one of those fails, the object stays
-- `uploading` with its content and can be sent again.
++ `uploading` with its content and can be sent again. Completing a completed session again (a
++ retry after a lost answer) returns the object.
5. **Abort** (`DELETE`) deletes the parts; the object stays `uploading`. A single-part PUT is refused
while a session is open (409 `media.upload.multipart_active`).
ddiff --git a/server/auth.js b/server/auth.js
iindex ded5061..a64a867 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -342,7 +342,7 @@ function tenantCors(req, res, next) {
if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
-- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
next();
ddiff --git a/server/index.js b/server/index.js
iindex 537abcb..9d1113e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -79,7 +79,7 @@ app.options(['/api/v1/:app/*', '/api/v2/:app/*'], (req, res) => {
if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
-- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
res.sendStatus(204);
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 87a97de..3c12fa4 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -449,6 +449,12 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req, res) => {
let session = null;
try {
++ // A repeat of a complete that already succeeded (its answer was lost on the way): the object as it is.
++ const already = model.resolveObject(String(req.params.id || ''), req.appId);
++ const prior = already && multipart.getSession(String(req.params.uploadId || ''));
++ if (prior && prior.object_id === already.id && prior.status === 'completed' && already.lifecycle_status === 'ready' && canSee(req, already)) {
++ return res.json(model.objectPublic(already));
++ }
const obj = loadUploading(req, res);
if (!obj) return;
session = loadSession(req, res, obj);
ddiff --git a/test/objects-multipart.test.js b/test/objects-multipart.test.js
iindex 52a0237..3d19315 100644
---- a/test/objects-multipart.test.js
++++ b/test/objects-multipart.test.js
@@@ -190,7 +190,9 @@ const mac = (parts) => crypto.createHmac('sha256', 'test-signing-secret').update
r = await call('GET', local(r.body.url), { bearer: null });
assert.deepStrictEqual([r.status, sha(Buffer.from(await (await fetch(local((await call('GET', `${O}/${mp.id}/download`)).body.url))).arrayBuffer()))], [200, sha(big)]);
r = await call('POST', local(up.complete_url), { bearer: null });
-- assert.strictEqual(r.status, 409, 'completing twice');
++ assert.deepStrictEqual([r.status, r.body.id, r.body.lifecycle_status], [200, mp.id, 'ready'], 'completing again (a lost answer) returns the object');
++ r = await call('POST', `${O}/${mp.id}/complete`);
++ assert.strictEqual(r.status, 200, 'and so does the single-part complete');
console.log('✅ multipart: exact parts, sha256 per part, resumable after a dropped connection, assembled and verified');
// Declare the size later; abort; a session for another object does not open this one; expiry.