openvibers/openvibe.media Commits

Retention holds (WS-G task 10), on the existing media_holds (no second table): a note column (added to existing databases), placed_by/placed_at in every answer (created_by/created_at kept), and staff routes on the app-key admin router: GET/POST /api/v1/:app/admin/storage/holds (by object_id, legacy ref, vod_id or clip_id; reason required; a row with no object yet is projected first; refused to calls acting for a user) and POST /holds/:id/release (also DELETE); every placement and release is an [Admin] log line and the row keeps who and when. Clips follow their VOD: a clip cut from a held VOD is held through clip_of or its row's vod_id, even before it has an object (database.js heldSql, one rule for model.isHeld and the new _v2 delete triggers, which replace the first-version ones in one transaction; idx_clips_object). Newly guarded paths: moveToHot refuses a held VOD (it flipped the row to local and dropped the R2 copy; a clip cut then uses the cloud copy), finalize settles a held empty or zero-byte recording as failed and keeps its row and file instead of deleting them, the boot junk sweep skips held rows, the paste DELETE answers 409 and the paste bulk delete skips held screenshots. v2 GET /:id/holds lists inherited_holds. test/retention-holds.test.js

OP OpenVibers committed 25 Sept 2026 · a63bb3a04e · 14 files, +510 −43


Changes

ddiff --git a/README.md b/README.md
iindex 598fb5c..4705373 100644
---- a/README.md
++++ b/README.md
@@@ -278,6 +278,9 @@ directories are shared across apps) and responses carry a `note` saying so.
| POST | `/admin/storage/tiers/bulk-move` | `{ ids: [...], target }` (max 50) → `{ moved, bytes, errors? }` |
| GET | `/admin/storage/tiers/policy` | **read-only R2 policy**: each threshold (`r2Enabled`, `r2MinViews`, `r2RecentAccessDays`, `r2MaxIdleDays`, `r2MaxPerSweep`) as `{ value, default, source: default\|setting }`, the promote and demote rules in words, the R2/B2 provider status, and this app's decisions: `last_24h` counts per action and outcome and the 20 most recent |
| GET | `/admin/storage/tiers/decisions?vod_id&action&outcome&limit&before_id` | this app's R2 promote/demote decisions, newest first: `{ id, decided_at, vod_id, object_id, action, from_provider, to_provider, outcome: done\|already\|refused\|failed, trigger: sweep\|admin\|clip\|drill\|manual, reason, inputs, thresholds, error }` → `{ decisions, next_before_id }` |
++| GET | `/admin/storage/holds?all&object_id&vod_id&clip_id&limit&before_id` | this app's retention holds, newest first (active only unless `all=1`) |
++| POST | `/admin/storage/holds` | `{ object_id \| vod_id \| clip_id, reason, kind?, note?, placed_by? }` → 201: the object is kept (no delete, no tier move) and so are the clips cut from a held VOD. Not for calls acting for a user (403) |
++| POST | `/admin/storage/holds/:holdId/release` | `{ released_by }` (also `DELETE /admin/storage/holds/:holdId`); 409 when already released. Holds stay on record |
| GET | `/admin/storage/buckets` | sanitized bucket status per provider: `{ configured, endpoint, bucket, region, healthy, reachable }` via a live HeadBucket probe — **credentials are never returned** |
### Webhooks (outbound)
@@@ -407,8 +410,11 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
`media.object.upload` / `media.object.read` for namespace `:app`;
`X-OV-Subject` sets the owner.
- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,
-- `admin`, `evidence`; `/:id/holds`, app key only): a held object cannot be
-- deleted by any path (409 / DB trigger) or moved between tiers.
++ `admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`
++ and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held
++ object cannot be deleted by any path (409 / DB trigger) or moved between tiers
++ (`moveToHot` included), and a clip cut from a held VOD is held too
++ ([docs](docs/object-model.md#retention-holds)).
- **Reconciliation** — `node scripts/reconcile-objects.js [--verify] [--hash]`:
read-only by default; `--verify` HEADs B2/R2 and records location states.
Reports missing canonical copies, lost local files, size/hash mismatches,
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 990ffbf..e3aa6dd 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -22,7 +22,7 @@ Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`,
| `media_variants` | `object_id`, `variant_name`, `derived_object_id`, `recipe`. In use: `thumbnail` |
| `media_jobs` | derivative/maintenance jobs: `id` (mjob_…), `app_id`, `object_id`, `job_type`, `status`, `idempotency_key`, `params`, `result`, `attempts`/`max_attempts`, `run_after`, `lease_until`, `checkpoint`, `error`/`error_code`, `cancel_requested`, `created_by`, `decided_by`. See [Jobs](#jobs) |
| `media_uploads`, `media_upload_parts` | multipart upload sessions and the parts received (size, sha256). See [Multipart uploads](#multipart-uploads) |
--| `media_holds` | retention holds, see [Holds](#retention-holds) |
++| `media_holds` | retention holds (with a staff `note`), see [Holds](#retention-holds) |
| `media_invariant_violations` | public playback objects over the size policy, see [Invariant](#public-object-size-invariant) |
Location `state`:
@@@ -198,8 +198,8 @@ All routes live under `/api/v2/:app/objects`.
| DELETE | `/:id` | soft delete: `lifecycle_status = deleted`, and the bytes are kept for `MEDIA_DELETE_RETENTION_DAYS` (default 30). 409 `media.object.held` under a hold. 409 `media.object.legacy_managed` for projected objects, which are deleted through their v1 route |
| POST | `/:id/restore` | undo a soft delete within the retention period (410 once purged) |
| GET | `/:id/download` | public or unlisted: 302 to the public location (the inherited URL for projected objects such as `/v/:id`, `/c/:id`, `/f/:key`, `/t/:name`, `/p/:slug/screenshot`; otherwise `/o/:id`). Private: `{ url, expires_at }` signed for `?ttl` seconds (30 to 3600, default `MEDIA_SIGNED_URL_TTL_S` = 300), or a 302 with `?redirect=1`. `?format=json` always answers JSON. 410 when deleted |
--| GET | `/:id/holds` | active holds (`?all=1` includes released ones) |
--| POST | `/:id/holds` | **app key only.** `{ kind, reason, created_by }` |
++| GET | `/:id/holds` | active holds (`?all=1` includes released ones), `inherited_holds` (a clip's VOD's) and `held` |
++| POST | `/:id/holds` | **app key only.** `{ kind, reason, note, placed_by \| created_by }` |
| DELETE | `/:id/holds/:holdId` | **app key only.** Releases the hold (`released_at`, `released_by`) |
**Public bytes: `GET /o/:id`.**
@@@ -325,18 +325,40 @@ requests, `/o/:id`) are unchanged: Live's DVR player reads a recording while it
## Retention holds
--`media_holds(object_id, kind, reason, created_by, created_at, released_at, released_by)`. The
--`kind` is one of `moderation`, `dmca`, `creator_pin`, `admin`, `evidence`. While any hold is
--unreleased:
++`media_holds(object_id, kind, reason, created_by, created_at, released_at, released_by, note)`: one
++table for the v2 routes (`/:id/holds`) and the staff routes (`/api/v1/:app/admin/storage/holds`).
++`created_by` / `created_at` are who placed the hold and when; the APIs also answer them as `placed_by` /
++`placed_at`. `note` is free text for staff. The `kind` is one of `moderation`, `dmca`, `creator_pin`,
++`admin`, `evidence`. Test: `test/retention-holds.test.js`.
++
++**Clips follow their VOD.** A clip cut from a held VOD is held too: through its `clip_of` relationship,
++or its row's `vod_id` when the clip has no object yet. One SQL rule (`database.js heldSql`) serves the
++triggers and `model.isHeld()`. `GET /:id/holds` on a clip lists the VOD's holds under `inherited_holds`.
++A hold on a clip does not hold its VOD.
++
++While any hold applies:
- **No delete path works.**
- v2 `DELETE` answers 409.
-- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held`.
-- - Quarantine cleanup skips the object.
++ - The v1 deletes for vods, clips, files and pastes, and the admin bulk delete, answer 409 or report `held`; the paste bulk delete skips it.
++ - Quarantine cleanup and the boot junk sweep skip the object.
++ - Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- `deleteVodObjects` keeps the bytes.
- Paste screenshot removal keeps the file, and censor answers 409.
-- - A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered.
--- **No tier move.** `moveToCold`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }`, and the sweep counts these as `skippedHeld` instead of errors. A hold freezes an object's placement. `moveToHot` (restoring a copy to local disk) is still allowed.
++ - A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
++- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
++
++**Staff routes** (`/api/v1/:app/admin/storage/holds`, the app key like every admin route; a call acting
++for one of the app's users, `X-OV-User-Id`, gets 403):
++
++| method | path | notes |
++|---|---|---|
++| GET | `/holds` | this app's holds, newest first; `?all=1` includes released ones; `?object_id` / `?vod_id` / `?clip_id`; `?limit` (≤ 200) and `?before_id` page |
++| POST | `/holds` | `{ object_id (med_… or legacy ref) \| vod_id \| clip_id, reason (required), kind (default admin), note, placed_by }` → 201. A v1 row with no object yet is projected first; a clips-only recording (never published) has none: 409. The answer names the object and, for a VOD, `clips_protected` |
++| POST | `/holds/:holdId/release` | `{ released_by }` → the hold with `released_at` / `released_by`; 409 `media.hold.released` when it was released already. `DELETE /holds/:holdId` does the same |
++
++Released holds stay on record. Every placement and release is also logged as an `[Admin]` line (the
++admin routes' log), with who, the object, the kind and the reason.
## Reconciliation
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 6b21ef6..ceac9cc 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -21,6 +21,13 @@
* decisions (media_tier_decisions) with 24-hour counts
* GET /tiers/decisions this app's R2 promote/demote decisions, newest first
* (?vod_id&action=promote|demote&outcome&limit≤200&before_id)
++ * GET /holds this app's retention holds, newest first (?all=1 includes released;
++ * ?object_id|vod_id|clip_id; ?limit≤200&before_id)
++ * POST /holds place one: { object_id (med_… or legacy ref) | vod_id | clip_id, reason, kind
++ * (default admin), note, placed_by } → 201. A VOD's hold also protects the clips
++ * cut from it. The hold row (placed_by/at, released_by/at, note) is the record,
++ * and each change is logged as an [Admin] line
++ * POST /holds/:holdId/release { released_by } (also DELETE /holds/:holdId); 409 when already released
*/
'use strict';
@@@ -465,6 +472,117 @@ router.post('/tiers/bulk-move', async (req, res) => {
}
});
++// ═══════════════════════════════════════════════════════════════
++// Retention holds (staff) — on media_holds, the same holds as /api/v2/:app/objects/:id/holds
++// ═══════════════════════════════════════════════════════════════
++
++const objectsModel = () => require('../objects/model');
++
++/** Staff actions are the app's own (its server fronts its admins), never a call acting for one of its users. */
++function _staffOnly(req, res) {
++ if (req.authType === 'app') return true;
++ res.status(403).json({ error: 'Retention holds are staff actions: call with the app key, not on behalf of a user', code: 'media.hold.forbidden' });
++ return false;
++}
++
++/**
++ * The object a hold request names, in this app: { obj } | { error, status }. object_id is a med_ id or a
++ * legacy ref; vod_id / clip_id name a v1 row, which is projected first when it has no object yet.
++ */
++function _holdTarget(appId, b, { project = true } = {}) {
++ const m = objectsModel();
++ if (b.object_id != null && b.object_id !== '') {
++ const obj = m.resolveObject(String(b.object_id), appId);
++ return obj ? { obj } : { status: 404, error: 'No such object in this app' };
++ }
++ for (const [field, table, kind] of [['vod_id', 'vods', 'vod'], ['clip_id', 'clips', 'clip']]) {
++ if (b[field] == null || b[field] === '') continue;
++ const id = parseInt(b[field], 10);
++ const row = Number.isFinite(id) ? db.get(`SELECT id, object_id FROM ${table} WHERE id = ? AND app_id = ?`, [id, appId]) : null;
++ if (!row) return { status: 404, error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` };
++ let objectId = row.object_id;
++ if (!objectId && project) { const r = m.safeSync(kind, id); objectId = r && r.id; }
++ if (!objectId) return { status: 409, error: `${kind} ${id} has no media object to hold (a clips-only recording is never published)` };
++ return { obj: m.getObject(objectId) };
++ }
++ return { status: 400, error: 'object_id, vod_id or clip_id required' };
++}
++
++/** A hold with the object it is on and, for a VOD, how many clips cut from it it protects. */
++function _holdOut(h) {
++ const m = objectsModel();
++ const obj = m.getObject(h.object_id);
++ const out = { ...m.holdPublic(h), object: obj ? { id: obj.id, kind: obj.kind, legacy_ref: obj.legacy_ref || null, lifecycle_status: obj.lifecycle_status } : null };
++ if (obj && obj.kind === 'vod') {
++ out.clips_protected = db.get(`SELECT COUNT(*) AS n FROM clips c WHERE c.vod_id IN (SELECT id FROM vods WHERE object_id = @o)
++ OR c.object_id IN (SELECT from_object_id FROM media_relationships WHERE to_object_id = @o AND relation = 'clip_of')`, { o: obj.id }).n;
++ }
++ return out;
++}
++
++router.get('/holds', (req, res) => {
++ try {
++ const q = req.query;
++ const conds = ['o.app_id = ?'], params = [req.appId];
++ if (!['1', 'true'].includes(String(q.all || ''))) conds.push('h.released_at IS NULL');
++ if (q.object_id != null || q.vod_id != null || q.clip_id != null) {
++ const t = _holdTarget(req.appId, { object_id: q.object_id, vod_id: q.vod_id, clip_id: q.clip_id }, { project: false });
++ if (!t.obj) return res.status(t.status).json({ error: t.error });
++ conds.push('h.object_id = ?'); params.push(t.obj.id);
++ }
++ if (q.before_id != null) { conds.push('h.id < ?'); params.push(parseInt(q.before_id, 10) || 0); }
++ const limit = Math.min(Math.max(parseInt(q.limit, 10) || 50, 1), 200);
++ const rows = db.all(`SELECT h.* FROM media_holds h JOIN media_objects o ON o.id = h.object_id WHERE ${conds.join(' AND ')} ORDER BY h.id DESC LIMIT ?`, [...params, limit + 1]);
++ const page = rows.slice(0, limit);
++ res.json({ holds: page.map(_holdOut), next_before_id: rows.length > limit ? page[page.length - 1].id : null, limit });
++ } catch (err) {
++ console.error('[Admin] Hold list error:', err.message);
++ res.status(500).json({ error: 'Failed to list holds' });
++ }
++});
++
++router.post('/holds', (req, res) => {
++ try {
++ if (!_staffOnly(req, res)) return;
++ const b = req.body || {};
++ const m = objectsModel();
++ const kind = b.kind == null || b.kind === '' ? 'admin' : String(b.kind);
++ if (!m.HOLD_KINDS.includes(kind)) return res.status(400).json({ error: `kind must be one of ${m.HOLD_KINDS.join(', ')}` });
++ const reason = String(b.reason || '').trim();
++ if (!reason) return res.status(400).json({ error: 'reason required (why the object must be kept)' });
++ const t = _holdTarget(req.appId, b);
++ if (!t.obj) return res.status(t.status).json({ error: t.error });
++ const by = String(b.placed_by || b.created_by || `app:${req.appId}`).slice(0, 200);
++ const hold = m.placeHold({ object_id: t.obj.id, kind, reason, created_by: by, note: b.note });
++ const out = _holdOut(hold);
++ console.log(`[Admin] Retention hold ${hold.id} placed on ${t.obj.id} (${t.obj.legacy_ref || t.obj.kind}) by ${by} (${req.appId}): ${kind}, ${JSON.stringify(reason.slice(0, 200))}`
++ + (out.clips_protected ? `; protects ${out.clips_protected} clip(s) cut from it` : ''));
++ res.status(201).json(out);
++ } catch (err) {
++ console.error('[Admin] Hold place error:', err.message);
++ res.status(500).json({ error: 'Failed to place the hold' });
++ }
++});
++
++function _releaseHold(req, res) {
++ try {
++ if (!_staffOnly(req, res)) return;
++ const hold = db.get('SELECT h.* FROM media_holds h JOIN media_objects o ON o.id = h.object_id WHERE h.id = ? AND o.app_id = ?',
++ [parseInt(req.params.holdId, 10) || 0, req.appId]);
++ if (!hold) return res.status(404).json({ error: 'No such hold in this app' });
++ if (hold.released_at) return res.status(409).json({ error: 'The hold was released already', code: 'media.hold.released', hold: _holdOut(hold) });
++ const by = String((req.body && req.body.released_by) || `app:${req.appId}`).slice(0, 200);
++ const out = _holdOut(objectsModel().releaseHold(hold.id, by));
++ console.log(`[Admin] Retention hold ${hold.id} on ${hold.object_id} released by ${by} (${req.appId})`);
++ res.json(out);
++ } catch (err) {
++ console.error('[Admin] Hold release error:', err.message);
++ res.status(500).json({ error: 'Failed to release the hold' });
++ }
++}
++router.post('/holds/:holdId/release', _releaseHold);
++router.delete('/holds/:holdId', _releaseHold);
++
// ── GET /buckets — bucket usage scan + cost estimate + reachability ──
// Full ListObjectsV2 walk per provider (10-min server cache; ?force=1 rescans).
// Response shape matches the legacy admin UI: { usage, costs, cachedAt } + buckets probe.
ddiff --git a/server/db/database.js b/server/db/database.js
iindex bd28605..1a5b112 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -65,6 +65,7 @@ function migrateColumns() {
pastes: [['object_id', 'TEXT']],
// Developer-project tenants (ADR-014): project_id prj_<ULID>, env sandbox|production. NULL on first-party tenants.
apps: [['project_id', 'TEXT'], ['env', 'TEXT']],
++ media_holds: [['note', 'TEXT']],
};
for (const [table, cols] of Object.entries(wanted)) {
const existing = database.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
@@@ -105,20 +106,44 @@ function migrateJobsTable(schema) {
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_jobs_idem ON media_jobs(app_id, idempotency_key) WHERE idempotency_key IS NOT NULL;`);
}
++/**
++ * SQL: is the object named by the SQL expression `ref` under an unreleased retention hold? Its own
++ * hold, or, for a clip, its source VOD's (through the clip_of relationship, or the clip row's vod_id).
++ * One rule for the delete triggers below and objects/model.js isHeld().
++ */
++function heldSql(ref) {
++ return `EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = ${ref}
++ OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = ${ref} AND r.relation = 'clip_of')
++ OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = ${ref})))`;
++}
++
/**
* Object-model guards that must hold on EVERY delete path, including the many
* inherited ones that DELETE a vods/clips/files/pastes row directly:
-- * - a row whose object is under an unreleased retention hold cannot be deleted;
++ * - a row whose object is under an unreleased retention hold cannot be deleted, and a clip whose
++ * source VOD is held cannot either (even before the clip has an object);
* - deleting a projected row marks its media_object deleted (bytes accounting
* and reconciliation keep working without touching each call site).
-- * Created here, after migrateColumns, because they reference object_id.
++ * Created here, after migrateColumns, because they reference object_id. The hold guards are the _v2
++ * triggers (clips follow their VOD's hold); the first-version ones are dropped.
*/
function ensureObjectTriggers() {
-- const held = (ref) => `EXISTS (SELECT 1 FROM media_holds WHERE object_id = ${ref} AND released_at IS NULL)`;
++ // One transaction: replacing a first-version hold guard never leaves a moment without one.
++ database.transaction(_ensureObjectTriggers)();
++}
++
++function _ensureObjectTriggers() {
++ const held = heldSql;
++ // A clip's lookups by object id (the hold rule above) use this index.
++ database.exec('CREATE INDEX IF NOT EXISTS idx_clips_object ON clips(object_id)');
++ for (const old of ['trg_vods_hold_guard', 'trg_clips_hold_guard', 'trg_files_hold_guard', 'trg_pastes_hold_guard', 'trg_media_objects_hold_guard', 'trg_media_objects_hold_delete']) {
++ database.exec(`DROP TRIGGER IF EXISTS ${old}`);
++ }
++ const vodHeld = `(OLD.vod_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h JOIN vods v ON v.object_id = h.object_id WHERE v.id = OLD.vod_id AND h.released_at IS NULL))`;
for (const table of ['vods', 'clips', 'files', 'pastes']) {
database.exec(`
-- CREATE TRIGGER IF NOT EXISTS trg_${table}_hold_guard BEFORE DELETE ON ${table}
-- WHEN OLD.object_id IS NOT NULL AND ${held('OLD.object_id')}
++ CREATE TRIGGER IF NOT EXISTS trg_${table}_hold_guard_v2 BEFORE DELETE ON ${table}
++ WHEN (OLD.object_id IS NOT NULL AND ${held('OLD.object_id')})${table === 'clips' ? ` OR ${vodHeld}` : ''}
BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END;
CREATE TRIGGER IF NOT EXISTS trg_${table}_object_deleted AFTER DELETE ON ${table}
WHEN OLD.object_id IS NOT NULL
@@@ -129,10 +154,10 @@ function ensureObjectTriggers() {
END;`);
}
database.exec(`
-- CREATE TRIGGER IF NOT EXISTS trg_media_objects_hold_guard BEFORE UPDATE OF lifecycle_status ON media_objects
++ CREATE TRIGGER IF NOT EXISTS trg_media_objects_hold_guard_v2 BEFORE UPDATE OF lifecycle_status ON media_objects
WHEN NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status != 'deleted' AND ${held('OLD.id')}
BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END;
-- CREATE TRIGGER IF NOT EXISTS trg_media_objects_hold_delete BEFORE DELETE ON media_objects
++ CREATE TRIGGER IF NOT EXISTS trg_media_objects_hold_delete_v2 BEFORE DELETE ON media_objects
WHEN ${held('OLD.id')}
BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END;`);
// media.object.visibility_changed / media.object.deleted: staged in the changing transaction,
@@@ -787,7 +812,7 @@ function importLegacyRows(table, rows, appId = 'live') {
}
module.exports = {
-- getDb, run, get, all, close, syncObject: _syncObject,
++ getDb, run, get, all, close, syncObject: _syncObject, heldSql,
getSetting, setSetting,
// apps
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins, projectTenantId, ensureProjectTenant, isSandboxTenant,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex c72d808..540d46a 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -335,7 +335,10 @@ CREATE TABLE IF NOT EXISTS media_upload_parts (
PRIMARY KEY (upload_id, part_number)
);
---- Retention holds: an object with an unreleased hold cannot be deleted or moved between tiers.
++-- Retention holds: an object with an unreleased hold cannot be deleted or moved between tiers, and a
++-- clip follows its source VOD's hold (database.js heldSql). created_by / created_at are who placed it and
++-- when (placed_by / placed_at in the API); note is free text for staff. Placed and released through
++-- /api/v2/:app/objects/:id/holds and the staff routes /api/v1/:app/admin/storage/holds.
CREATE TABLE IF NOT EXISTS media_holds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_id TEXT NOT NULL,
@@@ -344,7 +347,8 @@ CREATE TABLE IF NOT EXISTS media_holds (
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
released_at DATETIME,
-- released_by TEXT
++ released_by TEXT,
++ note TEXT
);
CREATE INDEX IF NOT EXISTS idx_media_holds_object ON media_holds(object_id, released_at);
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 2ed7f32..79035a2 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -99,14 +99,34 @@ function listHolds(objectId, { includeReleased = false } = {}) {
return db.all(`SELECT * FROM media_holds WHERE object_id = ?${includeReleased ? '' : ' AND released_at IS NULL'} ORDER BY id`, [objectId]);
}
++/** Unreleased holds a clip inherits from its source VOD (clip_of, or the clip row's vod_id), each with inherited_from. */
++function inheritedHolds(objectId) {
++ if (!objectId) return [];
++ return db.all(`SELECT h.*, h.object_id AS inherited_from FROM media_holds h WHERE h.released_at IS NULL AND h.object_id != @id AND (
++ h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = @id AND r.relation = 'clip_of')
++ OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = @id)) ORDER BY h.id`, { id: objectId });
++}
++
++/** Under an unreleased hold: its own, or (a clip) its source VOD's. The delete triggers apply the same rule (db.heldSql). */
function isHeld(objectId) {
if (!objectId) return false;
-- return !!db.get('SELECT 1 AS x FROM media_holds WHERE object_id = ? AND released_at IS NULL LIMIT 1', [objectId]);
++ return !!db.get(`SELECT ${db.heldSql('@id')} AS held`, { id: objectId }).held;
}
--/** Hold check for an inherited row (vods/clips/files/pastes) — false when it has no object yet. */
++/**
++ * Hold check for an inherited row (vods/clips/files/pastes). A clip row also follows its source VOD's
++ * hold before it has an object of its own; any other row with no object yet is not held.
++ */
function isHeldRow(row) {
-- try { return !!(row && row.object_id && isHeld(row.object_id)); } catch { return false; }
++ try {
++ if (!row) return false;
++ if (row.object_id && isHeld(row.object_id)) return true;
++ if (row.vod_id != null) {
++ const vod = db.get('SELECT object_id FROM vods WHERE id = ?', [row.vod_id]);
++ return !!(vod && vod.object_id && isHeld(vod.object_id));
++ }
++ return false;
++ } catch { return false; }
}
// ── Writes ───────────────────────────────────────────────────
@@@ -449,13 +469,20 @@ function afterTierMove(vodId, verifiedProviders = []) {
// ── Holds ────────────────────────────────────────────────────
--function placeHold({ object_id, kind, reason = '', created_by = null }) {
++/** Place a hold. created_by (or placed_by) is who placed it; note is free text for staff (≤ 2000). */
++function placeHold({ object_id, kind, reason = '', created_by = null, placed_by = null, note = null }) {
if (!HOLD_KINDS.includes(kind)) throw new Error(`hold kind must be one of ${HOLD_KINDS.join(', ')}`);
-- const r = db.run('INSERT INTO media_holds (object_id, kind, reason, created_by) VALUES (?, ?, ?, ?)',
-- [object_id, kind, String(reason || '').slice(0, 1000), created_by]);
++ const r = db.run('INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, ?, ?, ?, ?)',
++ [object_id, kind, String(reason || '').slice(0, 1000), placed_by ?? created_by, note == null || note === '' ? null : String(note).slice(0, 2000)]);
return db.get('SELECT * FROM media_holds WHERE id = ?', [r.lastInsertRowid]);
}
++/** A hold as the APIs answer it: the row, plus placed_by / placed_at (the names staff tools use for created_by / created_at). */
++function holdPublic(h) {
++ if (!h) return null;
++ return { ...h, note: h.note ?? null, placed_by: h.created_by ?? null, placed_at: h.created_at ?? null };
++}
++
function releaseHold(holdId, releasedBy = null) {
db.run('UPDATE media_holds SET released_at = CURRENT_TIMESTAMP, released_by = ? WHERE id = ? AND released_at IS NULL', [releasedBy, holdId]);
return db.get('SELECT * FROM media_holds WHERE id = ?', [holdId]);
@@@ -582,10 +609,10 @@ function objectPublic(obj, { locations = true } = {}) {
module.exports = {
KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,
mimeFor, parseJson, parseLegacyRef, thumbFileFromUrl,
-- getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, isHeld, isHeldRow,
++ getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,
createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,
syncVod, syncClip, syncFile, syncPaste, sync, safeSync, afterTierMove,
-- placeHold, releaseHold,
++ placeHold, releaseHold, holdPublic,
objectFilePath, softDelete, restore, purgeExpired, usedBytes,
legacyPublicUrl, objectPublic,
};
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 2e061ba..66b7f15 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -16,9 +16,10 @@
* DELETE /:id soft delete (bytes kept MEDIA_DELETE_RETENTION_DAYS); 409 when held
* POST /:id/restore undo a soft delete inside the retention period
* GET /:id/download 302 to the public location, or a signed short-lived URL for private objects
-- * GET /:id/holds retention holds (?all=1 includes released)
-- * POST /:id/holds place a hold { kind, reason, created_by } (app key only)
-- * DELETE /:id/holds/:holdId release it (app key only)
++ * GET /:id/holds retention holds (?all=1 includes released), and the holds a clip inherits from its VOD
++ * POST /:id/holds place a hold { kind, reason, note, placed_by | created_by } (app key only)
++ * DELETE /:id/holds/:holdId release it (app key only)
++ * Staff place and release holds by vod/clip id at /api/v1/:app/admin/storage/holds.
*
* Auth: the app's API key, or a Network service token granting media.object.upload
* (writes) / media.object.read (reads) for namespace = :app. Developer-project tenants
@@@ -572,7 +573,13 @@ router.get('/:id/download', read, (req, res) => {
router.get('/:id/holds', read, (req, res) => {
const obj = load(req, res);
if (!obj) return;
-- res.json({ object_id: obj.id, holds: model.listHolds(obj.id, { includeReleased: ['1', 'true'].includes(String(req.query.all || '')) }) });
++ res.json({
++ object_id: obj.id,
++ holds: model.listHolds(obj.id, { includeReleased: ['1', 'true'].includes(String(req.query.all || '')) }).map(model.holdPublic),
++ // A clip is held while its source VOD is (released holds are the VOD's to list).
++ inherited_holds: model.inheritedHolds(obj.id).map(model.holdPublic),
++ held: model.isHeld(obj.id),
++ });
});
router.post('/:id/holds', appOnly, (req, res) => {
@@@ -580,8 +587,11 @@ router.post('/:id/holds', appOnly, (req, res) => {
if (!obj) return;
const b = req.body || {};
if (!model.HOLD_KINDS.includes(b.kind)) return problem(res, 400, 'media.hold.invalid', `kind must be one of ${model.HOLD_KINDS.join(', ')}`);
-- const by = b.created_by ? String(b.created_by).slice(0, 200) : `app:${req.appId}${req.userId != null ? `:user:${req.userId}` : ''}`;
-- res.status(201).json(model.placeHold({ object_id: obj.id, kind: b.kind, reason: b.reason || '', created_by: by }));
++ const named = b.placed_by || b.created_by;
++ const by = named ? String(named).slice(0, 200) : `app:${req.appId}${req.userId != null ? `:user:${req.userId}` : ''}`;
++ const hold = model.placeHold({ object_id: obj.id, kind: b.kind, reason: b.reason || '', created_by: by, note: b.note });
++ console.log(`[Objects] Retention hold ${hold.id} placed on ${obj.id} (${obj.legacy_ref || obj.kind}) by ${by} (${req.appId}): ${hold.kind}`);
++ res.status(201).json(model.holdPublic(hold));
});
router.delete('/:id/holds/:holdId', appOnly, (req, res) => {
@@@ -590,7 +600,9 @@ router.delete('/:id/holds/:holdId', appOnly, (req, res) => {
const hold = db.get('SELECT * FROM media_holds WHERE id = ? AND object_id = ?', [parseInt(req.params.holdId, 10), obj.id]);
if (!hold) return problem(res, 404, 'media.hold.not_found', 'No such hold on this object');
const by = (req.body && req.body.released_by) ? String(req.body.released_by).slice(0, 200) : `app:${req.appId}`;
-- res.json(model.releaseHold(hold.id, by));
++ const out = model.releaseHold(hold.id, by);
++ if (!hold.released_at) console.log(`[Objects] Retention hold ${hold.id} on ${obj.id} released by ${by} (${req.appId})`);
++ res.json(model.holdPublic(out));
});
// ── Public bytes: GET /o/:id ─────────────────────────────────
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 322cefd..5f042d0 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -391,6 +391,8 @@ router.post('/bulk', tenantAuth(), (req, res) => {
const paste = db.getPasteBySlug(String(slug), req.appId);
if (!paste) { skipped++; continue; }
if (action === 'delete') {
++ // A held screenshot is skipped (its row and bytes stay), not failed halfway.
++ if (require('../objects/model').isHeldRow(paste)) { skipped++; continue; }
removePasteScreenshot(paste);
db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
} else {
@@@ -635,6 +637,7 @@ router.delete('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
return res.status(403).json({ error: 'Not authorized for this paste' });
}
++ if (require('../objects/model').isHeldRow(paste)) return res.status(409).json({ error: 'Screenshot is under a retention hold', code: 'media.object.held' });
removePasteScreenshot(paste);
db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
ddiff --git a/server/vod/clip-jobs.js b/server/vod/clip-jobs.js
iindex 3b56b85..55f53eb 100644
---- a/server/vod/clip-jobs.js
++++ b/server/vod/clip-jobs.js
@@@ -64,7 +64,7 @@ async function _recutClip(clipId, { reason = 'recut' } = {}) {
console.log(`[Clips] clip ${clipId}: pulling vod ${vod.id} (${(vod.file_size / 1048576).toFixed(0)} MB) back to local disk for a reliable cut`);
const r = await vodStorage.moveToHot(vod.id, { trigger: 'clip', reason: `fetched to local disk to cut clip ${clipId}` }).catch(e => ({ ok: false, error: e.message }));
if (r && r.ok) source = await vodStorage.resolveMediaSource(db.get('SELECT * FROM vods WHERE id = ?', [vod.id]));
-- else console.warn(`[Clips] clip ${clipId}: hot fetch failed: ${r && r.error}`);
++ else console.warn(`[Clips] clip ${clipId}: hot fetch ${r && r.held ? 'refused (the VOD is under a retention hold, its placement stays)' : `failed: ${r && r.error}`} — cutting from the cloud copy`);
} else {
console.log(`[Clips] clip ${clipId}: not enough free disk to pull vod ${vod.id} home — cutting from the cloud copy`);
}
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 79f6470..3b2c816 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -204,7 +204,19 @@ async function _doFinalize(vodId, opts) {
return null;
}
++ // A recording under a retention hold is never deleted here (the empty-row and zero-byte cases
++ // below): it is settled as failed and quarantined instead, file and row kept.
++ const keepHeld = (status) => {
++ console.warn(`[VOD] vod ${vodId}: ${status} recording under a retention hold — keeping its row and file, quarantined`);
++ _commitVodOutcome(vod, 'vod.failed', () => db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?,
++ quarantined_at = COALESCE(quarantined_at, datetime('now')), is_public = 0, last_health_scan_at = datetime('now') WHERE id = ?`,
++ [status, JSON.stringify([status, 'retention_hold']), vodId]));
++ return null;
++ };
++ const held = require('../objects/model').isHeldRow(vod);
++
if (!filePath || !fs.existsSync(filePath)) {
++ if (held) return keepHeld('missing_file');
// No media was ever written (ingest started but produced nothing, or the
// process died before ffmpeg opened the file). Nothing to review — delete
// the row so a 0:00 ghost never reaches listings.
@@@ -225,6 +237,7 @@ async function _doFinalize(vodId, opts) {
// nothing to review, so treat it like a missing file: report the failure and
// delete the row and file — a 0:00 ghost must never reach listings.
if (tools.getFileSizeSafe(filePath) === 0) {
++ if (held) return keepHeld('zero_byte');
console.warn(`[VOD] vod ${vodId}: zero-byte recording — deleting empty recording`);
try { fs.unlinkSync(filePath); } catch { /* */ }
try { tools.cleanupSeekableFile(filePath); } catch { /* */ }
ddiff --git a/server/vod/health-job.js b/server/vod/health-job.js
iindex 3d93954..889ead1 100644
---- a/server/vod/health-job.js
++++ b/server/vod/health-job.js
@@@ -239,9 +239,16 @@ function sweepJunkVods() {
AND COALESCE(file_size, 0) < 10000000
AND (storage_provider IS NULL OR storage_provider = 'local')
AND COALESCE(health_status, 'unknown') IN ('unknown', '')`);
-- let deleted = 0, quarantined = 0;
++ let deleted = 0, quarantined = 0, held = 0;
++ const objects = require('../objects/model');
for (const v of rows) {
const hasFile = v.file_path && fs.existsSync(v.file_path) && (() => { try { return fs.statSync(v.file_path).size > 0; } catch { return false; } })();
++ // A held row is never deleted (and its file never unlinked): it is left for its hold's owner.
++ if (!hasFile && objects.isHeldRow(db.get('SELECT object_id FROM vods WHERE id = ?', [v.id]))) {
++ console.log(`[VOD-Health] Junk sweep: vod ${v.id} has no media but is under a retention hold — not deleting`);
++ held++;
++ continue;
++ }
if (!hasFile) {
try { if (v.file_path && fs.existsSync(v.file_path)) fs.unlinkSync(v.file_path); } catch { /* */ }
db.run('DELETE FROM vods WHERE id = ?', [v.id]);
@@@ -252,7 +259,7 @@ function sweepJunkVods() {
quarantined++;
}
}
-- if (deleted || quarantined) console.log(`[VOD-Health] Junk sweep: deleted ${deleted} empty VODs, quarantined ${quarantined} short ones`);
++ if (deleted || quarantined || held) console.log(`[VOD-Health] Junk sweep: deleted ${deleted} empty VODs, quarantined ${quarantined} short ones${held ? `, kept ${held} under a retention hold` : ''}`);
} catch (e) { console.warn('[VOD-Health] junk sweep error:', e.message); }
}
ddiff --git a/server/vod/orphans-report.js b/server/vod/orphans-report.js
iindex 391382e..df568a2 100644
---- a/server/vod/orphans-report.js
++++ b/server/vod/orphans-report.js
@@@ -207,7 +207,7 @@ function locationWanted(l) {
const LOCATION_ROWS = `SELECT l.provider, l.bucket, l.key, o.id AS object_id, o.lifecycle_status, o.legacy_ref,
CASE WHEN json_valid(o.metadata) THEN json_extract(o.metadata, '$.purged_at') END AS purged,
-- EXISTS (SELECT 1 FROM media_holds h WHERE h.object_id = o.id AND h.released_at IS NULL) AS held
++ ${db.heldSql('o.id')} AS held
FROM media_locations l LEFT JOIN media_objects o ON o.id = l.object_id`;
const sidecarsOf = (p) => [p.replace(/\.webm$/, '.seekable.webm'), p.replace(/\.mp4$/, '.seekable.mp4'), p.replace(/\.webm$/, '.master.mkv')].filter(x => x !== p);
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
iindex 01ecdb3..9381ffd 100644
---- a/server/vod/vod-storage.js
++++ b/server/vod/vod-storage.js
@@@ -230,8 +230,10 @@ function endpointFor(name) {
return (PROVIDER_ENV[name] && PROVIDER_ENV[name].endpoint) || null;
}
--// Object model (Wave 4): holds freeze an object's placement, and every tier move
--// re-projects the VOD's media_locations. Lazy — the model requires this module.
++// Object model (Wave 4): holds freeze an object's placement (moveToCold, moveToHot, promoteToR2 and
++// demoteFromR2 refuse a held VOD with { ok: false, held: true }; deleteVodObjects keeps a held object's
++// bytes, a clip's too while its source VOD is held), and every tier move re-projects the VOD's
++// media_locations. Lazy — the model requires this module.
const objects = () => require('../objects/model');
function _held(vod) { return objects().isHeldRow(vod); }
function _moved(vodId, verified) { objects().afterTierMove(vodId, verified); }
@@@ -553,6 +555,8 @@ async function moveToHot(vodId, ctx = {}) {
async function _moveToHot(vodId) {
const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
++ // A hold freezes placement: restoring would flip the row to local and drop an R2 copy.
++ if (_held(vod)) return { ok: false, held: true, error: 'VOD is under a retention hold' };
const key = keyForVod(vod);
const local = localPathForVod(vod);
ddiff --git a/test/retention-holds.test.js b/test/retention-holds.test.js
nnew file mode 100644
iindex 0000000..af83bec
---- /dev/null
++++ b/test/retention-holds.test.js
@@@ -0,0 +1,226 @@
++'use strict';
++// Retention holds (media_holds; WS-G task 10): the staff routes /api/v1/:app/admin/storage/holds place and
++// release them (app key only, never acting for a user; each change logged, the row keeps placed_by/at,
++// released_by/at and a note). A held object cannot be deleted by any path (v1 routes, the admin bulk
++// delete, deleteVodObjects, finalize's empty-recording deletes, the junk sweep, paste deletes, row SQL
++// through the triggers) and is never moved between tiers (moveToCold, moveToHot, promoteToR2,
++// demoteFromR2, the admin move). A clip cut from a held VOD is held too, before it has an object of its
++// own as well. Release lifts all of it. An old database gets the note column and the new triggers.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-holds-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++Object.assign(process.env, {
++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ OBJECTS_PATH: dir('objects'), THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'),
++ MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',
++});
++for (const k of Object.keys(process.env)) if (/^MEDIA_(B2|R2)_/.test(k)) delete process.env[k];
++delete process.env.PASTES_FROZEN_APPS;
++
++const db = require('../server/db/database');
++require('../server/views/service').ensureSchema();
++const model = require('../server/objects/model');
++const vodStorage = require('../server/vod/vod-storage');
++const express = require('express');
++
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++db.upsertApp({ app_id: 'games', api_key: 'games-key' });
++const raw = () => db.getDb();
++const file = (d, name, bytes = 64) => { const p = path.join(d, name); fs.writeFileSync(p, Buffer.alloc(bytes, 3)); return p; };
++const insVod = (id, name, { app = 'live', bytes = 64, recording = 0, clipsOnly = 0, duration = 60 } = {}) => raw().prepare(
++ `INSERT INTO vods (id, app_id, title, file_path, file_size, is_public, visibility, duration_seconds, is_recording, clips_only, storage_provider)
++ VALUES (?, ?, ?, ?, ?, 1, 'public', ?, ?, ?, 'local')`).run(id, app, `vod ${id}`, name ? file(process.env.VOD_PATH, name, bytes) : null, bytes, duration, recording, clipsOnly);
++const insClip = (id, vodId, name) => raw().prepare(`INSERT INTO clips (id, app_id, vod_id, title, file_path, is_public, visibility, status, duration_seconds)
++ VALUES (?, 'live', ?, ?, ?, 1, 'public', 'ready', 5)`).run(id, vodId, `clip ${id}`, file(process.env.CLIPS_PATH, name, 32));
++
++// ── An old database: no note column, the first-version triggers ──
++insVod(99, 'vod-live-99-1.webm');
++model.sync('vod', 99);
++raw().exec('ALTER TABLE media_holds DROP COLUMN note');
++for (const t of ['vods', 'clips', 'files', 'pastes']) raw().exec(`DROP TRIGGER IF EXISTS trg_${t}_hold_guard_v2`);
++raw().exec(`DROP TRIGGER IF EXISTS trg_media_objects_hold_guard_v2; DROP TRIGGER IF EXISTS trg_media_objects_hold_delete_v2;
++ CREATE TRIGGER trg_clips_hold_guard BEFORE DELETE ON clips WHEN OLD.object_id IS NOT NULL
++ AND EXISTS (SELECT 1 FROM media_holds WHERE object_id = OLD.object_id AND released_at IS NULL)
++ BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END;`);
++db.close();
++const cols = db.getDb().prepare('PRAGMA table_info(media_holds)').all().map(c => c.name);
++assert.ok(cols.includes('note'), 'the note column is added to an existing media_holds');
++const triggers = raw().prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE '%hold%' ORDER BY name").all().map(r => r.name);
++assert.deepStrictEqual(triggers, ['trg_clips_hold_guard_v2', 'trg_files_hold_guard_v2', 'trg_media_objects_hold_delete_v2', 'trg_media_objects_hold_guard_v2', 'trg_pastes_hold_guard_v2', 'trg_vods_hold_guard_v2'],
++ 'the first-version guards are replaced');
++console.log('✅ migration: media_holds gains note; the hold triggers are replaced by the clip-aware ones');
++
++// ── Fixtures ──
++insVod(1, 'vod-live-1-1.webm');
++insVod(2, 'vod-live-2-2.webm');
++insVod(3, null, { clipsOnly: 1 });
++insVod(4, 'vod-games-4-4.webm', { app: 'games' });
++insVod(5, 'vod-live-5-5.webm'); // no object yet: the staff route projects it
++for (const id of [1, 2, 4]) model.sync('vod', id);
++insClip(11, 1, 'clip-11.webm');
++model.sync('clip', 11);
++insClip(12, 1, 'clip-12.webm'); // cut from VOD 1, not projected yet
++insClip(13, 2, 'clip-13.webm');
++model.sync('clip', 13);
++const vod = (id) => db.get('SELECT * FROM vods WHERE id = ?', [id]);
++const clip = (id) => db.get('SELECT * FROM clips WHERE id = ?', [id]);
++raw().prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility, screenshot_path)
++ VALUES ('shot1', 'live', 'S', 'screenshot', '', 'text', 'public', ?)`).run(file(dir('pastes/screenshots'), 'shot1.png', 20));
++model.sync('paste', db.get("SELECT id FROM pastes WHERE slug = 'shot1'").id);
++
++const app = express();
++app.use(express.json());
++app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
++app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
++app.use('/api/v2/:app/objects', require('../server/objects/routes'));
++
++(async () => {
++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const call = (method, p, body, headers = {}) => new Promise((resolve, reject) => {
++ const data = body ? JSON.stringify(body) : '';
++ const rq = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method,
++ headers: { authorization: 'Bearer live-key', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data), ...headers } },
++ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: b ? JSON.parse(b) : {} })); });
++ rq.on('error', reject);
++ rq.end(data);
++ });
++ const H = '/api/v1/live/admin/storage/holds';
++
++ // ── Staff routes: place ──
++ let r = await call('POST', H, { vod_id: 1, reason: 'DMCA notice #7 — keep the evidence', kind: 'dmca', note: 'counter-notice window ends Oct 9', placed_by: 'staff:alex' });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ const hold = r.body;
++ assert.deepStrictEqual([hold.kind, hold.placed_by, hold.created_by, hold.note, hold.released_at, hold.object.id, hold.object.legacy_ref],
++ ['dmca', 'staff:alex', 'staff:alex', 'counter-notice window ends Oct 9', null, vod(1).object_id, 'legacy:live:vod:1']);
++ assert.ok(hold.placed_at, 'placed_at is recorded');
++ assert.strictEqual(hold.clips_protected, 2, 'both clips cut from the VOD are protected');
++ assert.strictEqual((await call('POST', H, { vod_id: 2 })).status, 400, 'a reason is required');
++ assert.strictEqual((await call('POST', H, { vod_id: 2, reason: 'x', kind: 'whim' })).status, 400, 'known kinds only');
++ assert.strictEqual((await call('POST', H, { reason: 'x' })).status, 400, 'a target is required');
++ assert.strictEqual((await call('POST', H, { vod_id: 4, reason: 'x' })).status, 404, "another app's VOD is not found");
++ assert.strictEqual((await call('POST', H, { vod_id: 3, reason: 'x' })).status, 409, 'a clips-only recording has no object to hold');
++ assert.strictEqual((await call('POST', H, { vod_id: 2, reason: 'x' }, { 'x-ov-user-id': '5' })).status, 403, 'never acting for a user');
++ assert.strictEqual((await call('POST', '/api/v1/games/admin/storage/holds', { vod_id: 2, reason: 'x' }, { authorization: 'Bearer games-key' })).status, 404, "games cannot hold live's VOD");
++ r = await call('POST', H, { vod_id: 5, reason: 'moderation review' });
++ assert.strictEqual(r.status, 201);
++ assert.ok(vod(5).object_id && r.body.object.id === vod(5).object_id, 'a row with no object yet is projected, then held');
++ assert.strictEqual(r.body.placed_by, 'app:live', 'placed_by defaults to the app');
++ const hold5 = r.body.id;
++ console.log('✅ staff routes place holds by vod/clip/object id (reason required, app key only, own app only)');
++
++ // ── A held VOD: no delete path works ──
++ r = await call('DELETE', '/api/v1/live/vods/1');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held']);
++ r = await call('DELETE', '/api/v1/live/admin/storage/vods/bulk', { ids: [1] });
++ assert.deepStrictEqual([r.body.deleted, r.body.results[0].error], [0, 'VOD is under a retention hold']);
++ await vodStorage.deleteVodObjects(vod(1));
++ assert.ok(fs.existsSync(vod(1).file_path), 'deleteVodObjects keeps held bytes');
++ assert.throws(() => db.run('DELETE FROM vods WHERE id = 1'), /retention hold/, 'row SQL is refused by the trigger');
++ assert.throws(() => db.run("UPDATE media_objects SET lifecycle_status = 'deleted' WHERE id = ?", [vod(1).object_id]), /retention hold/);
++ // …nor a tier move of any kind (placement is frozen).
++ assert.deepStrictEqual([(await vodStorage.moveToCold(1)).held, (await vodStorage.promoteToR2(1)).held], [true, true]);
++ raw().prepare("UPDATE vods SET storage_provider = 'r2' WHERE id = 1").run();
++ const hot = await vodStorage.moveToHot(1);
++ assert.deepStrictEqual([hot.ok, hot.held], [false, true], 'moveToHot refuses too (it would drop the R2 copy)');
++ assert.strictEqual(vod(1).storage_provider, 'r2', 'the row is not flipped');
++ assert.strictEqual((await vodStorage.demoteFromR2(1)).held, true);
++ raw().prepare("UPDATE vods SET storage_provider = 'local' WHERE id = 1").run();
++ for (const target of ['local', 'b2', 'r2']) {
++ r = await call('POST', '/api/v1/live/admin/storage/tiers/move', { vod_id: 1, target });
++ assert.strictEqual(r.body.held, true, `the admin move to ${target} is refused`);
++ }
++ console.log('✅ a held VOD: every delete path and every tier move (including moveToHot) is refused');
++
++ // ── Clips cut from a held VOD follow its hold ──
++ assert.strictEqual(model.isHeld(clip(11).object_id), true, 'the clip object inherits the VOD hold');
++ r = await call('DELETE', '/api/v1/live/clips/11');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held']);
++ await vodStorage.deleteVodObjects(clip(11));
++ assert.ok(fs.existsSync(clip(11).file_path), 'the clip keeps its bytes');
++ assert.throws(() => db.run('DELETE FROM clips WHERE id = 11'), /retention hold/);
++ assert.strictEqual(clip(12).object_id, null);
++ assert.strictEqual(model.isHeldRow(clip(12)), true, 'a clip with no object yet follows its VOD by vod_id');
++ assert.throws(() => db.run('DELETE FROM clips WHERE id = 12'), /retention hold/, 'the trigger covers it too');
++ r = await call('GET', `/api/v2/live/objects/${clip(11).object_id}/holds`);
++ assert.deepStrictEqual([r.body.held, r.body.holds.length, r.body.inherited_holds.length, r.body.inherited_holds[0].inherited_from],
++ [true, 0, 1, vod(1).object_id], 'the v2 holds answer shows the inherited hold');
++ assert.strictEqual((await call('GET', `/api/v2/live/objects/${clip(11).object_id}`)).body.held, true);
++ assert.strictEqual(model.isHeld(clip(13).object_id), false, "another VOD's clip is not held");
++ console.log('✅ clips cut from a held VOD are held (also before they have an object); others are not');
++
++ // ── Finalize and the junk sweep keep held recordings ──
++ const finalize = require('../server/vod/finalize');
++ insVod(20, 'vod-live-20-0.webm', { bytes: 0, recording: 1, duration: 0 });
++ model.sync('vod', 20);
++ model.placeHold({ object_id: vod(20).object_id, kind: 'moderation', reason: 'live report', created_by: 'staff:mod' });
++ await finalize.finalizeVod(20);
++ assert.ok(vod(20) && fs.existsSync(vod(20).file_path), 'a held zero-byte recording keeps its row and file');
++ assert.deepStrictEqual([vod(20).is_recording, vod(20).health_status], [0, 'zero_byte'], 'settled as failed instead');
++ insVod(21, null, { recording: 1, duration: 0 });
++ raw().prepare("UPDATE vods SET file_path = ? WHERE id = 21").run(path.join(process.env.VOD_PATH, 'never-written.webm'));
++ model.sync('vod', 21);
++ model.placeHold({ object_id: vod(21).object_id, kind: 'evidence', reason: 'x' });
++ await finalize.finalizeVod(21);
++ assert.deepStrictEqual([!!vod(21), vod(21) && vod(21).health_status], [true, 'missing_file'], 'a held recording with no file keeps its row');
++ insVod(22, null, { recording: 1, duration: 0 });
++ await finalize.finalizeVod(22);
++ assert.strictEqual(vod(22), undefined, 'an unheld empty recording is still deleted');
++ // Junk sweep: public, finished, 0:00, no media.
++ insVod(23, 'vod-live-23-0.webm', { bytes: 0, duration: 0 });
++ raw().prepare("UPDATE vods SET health_status = 'unknown' WHERE id IN (23)").run();
++ model.sync('vod', 23);
++ model.placeHold({ object_id: vod(23).object_id, kind: 'admin', reason: 'x' });
++ insVod(24, 'vod-live-24-0.webm', { bytes: 0, duration: 0 });
++ require('../server/vod/health-job').start();
++ require('../server/vod/health-job').stop();
++ assert.ok(vod(23) && fs.existsSync(vod(23).file_path), 'the junk sweep keeps a held row and its file');
++ assert.strictEqual(vod(24), undefined, 'and still removes an unheld one');
++ console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
++
++ // ── A held screenshot paste ──
++ const shot = db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
++ model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
++ r = await call('DELETE', '/api/v1/live/pastes/shot1');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held']);
++ r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });
++ assert.deepStrictEqual([r.status, r.body.done, r.body.skipped], [200, 0, 1], 'bulk delete skips it');
++ assert.ok(fs.existsSync(shot.screenshot_path) && db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
++ console.log('✅ a held screenshot paste: DELETE answers 409, bulk delete skips it');
++
++ // ── Listing and release ──
++ r = await call('GET', H);
++ assert.ok(r.body.holds.every(h => !h.released_at) && r.body.holds.some(h => h.id === hold.id), 'active holds, newest first');
++ assert.strictEqual(r.body.holds[0].id > r.body.holds[r.body.holds.length - 1].id, true);
++ r = await call('GET', `${H}?vod_id=1`);
++ assert.deepStrictEqual(r.body.holds.map(h => h.id), [hold.id]);
++ r = await call('POST', `${H}/${hold.id}/release`, { released_by: 'staff:alex' });
++ assert.deepStrictEqual([r.status, r.body.released_by, !!r.body.released_at, r.body.note], [200, 'staff:alex', true, 'counter-notice window ends Oct 9']);
++ assert.strictEqual((await call('POST', `${H}/${hold.id}/release`, {})).status, 409, 'a released hold cannot be released again');
++ assert.strictEqual((await call('DELETE', `/api/v1/games/admin/storage/holds/${hold5}`, {}, { authorization: 'Bearer games-key' })).status, 404, "another app cannot release this app's hold");
++ assert.strictEqual((await call('DELETE', `${H}/${hold5}`)).status, 200, 'DELETE releases too');
++ assert.ok(!(await call('GET', H)).body.holds.some(h => h.id === hold.id), 'released holds leave the active list');
++ assert.ok((await call('GET', `${H}?all=1`)).body.holds.some(h => h.id === hold.id && h.released_by === 'staff:alex'), 'and stay on record');
++ assert.strictEqual(model.isHeld(clip(11).object_id), false, 'releasing the VOD releases its clips');
++ assert.strictEqual((await call('DELETE', '/api/v1/live/clips/11')).status, 200);
++ raw().prepare('DELETE FROM clips WHERE id = 12').run();
++ assert.strictEqual((await call('DELETE', '/api/v1/live/vods/1')).status, 200, 'the VOD can be deleted once released');
++ console.log('✅ release by the staff route: on record with released_by/at and the note; deletes work again');
++
++ // ── The v2 route takes a note too ──
++ r = await call('POST', `/api/v2/live/objects/${vod(2).object_id}/holds`, { kind: 'admin', reason: 'r', note: 'n' });
++ assert.deepStrictEqual([r.status, r.body.note, r.body.placed_by], [201, 'n', 'app:live']);
++ console.log('✅ v2 holds: note and placed_by/placed_at on the same table');
++
++ server.close();
++ db.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('retention holds: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });