openvibers/openvibe.media Commits
Object explorer (roadmap WS-G task 12): openvibe.media/me shows a signed-in person the objects whose owner_subject is their Network subject, in every tenant, and /me/ops gives Network staff the operator views
Changes
@@@ -41,6 +41,8 @@ server/admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)thumbnails/ thumbnail service + /api/v1/:app/thumbnailspublic/routes.js public /v /c /p /t /f++ me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,++ derivatives and usage) and the operator views /me/ops — see docs/object-model.md#object-explorerobjects/ canonical object model: model (projections, holds), routes (/api/v2 + /o),backfill, reconcile, invariant, signing (presigned URLs), multipart,content-type, verify-job (scheduled copy verification), copy-report,@@@ -74,6 +76,13 @@ client `media` (`server/user-auth.js`, routes under `/auth`, host-only cookies);`openvibe-shared/legal` (profile `hosting`). This is separate from tenant auth below, which is howother OpenVibe services call the API.++**Your media (`/me`).** A signed-in person sees the objects whose `owner_subject` is their Network++subject, in every tenant: uploads in progress, copies, derivatives, visibility, lifecycle and their own++usage per tenant and namespace (`GET /api/v2/me/objects`, `/objects/:id`, `/usage`). Read-only: changes++stay with the apps. Network staff with `staff.site.view` get the operator views at `/me/ops` (failed++jobs, missing media, backfill, tiering, usage recompute with `staff.site.configure`). Details in++[docs/object-model.md](docs/object-model.md#object-explorer).++## Tenancy & authTenant = the `:app` path segment. Rows in the `apps` table define tenants:@@@ -285,6 +294,8 @@ directories are shared across apps) and responses carry a `note` saying so.| 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** |++| GET | `/admin/storage/ops` | this app's operator report (as `/me/ops` shows it to Network staff): failed jobs, missing media, backfill, tiering, the namespaces' usage snapshot. Not for calls acting for a user (403) |++| POST | `/admin/storage/ops/recompute` | refresh this app's namespaces' usage snapshot from the rows |### Webhooks (outbound)@@@ -368,7 +379,7 @@ never the headers (`test/trust-proxy.test.js`).| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. || `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. || `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |--| `GET /robots.txt` | crawler policy (`openvibe-shared/seo.robotsTxt`: AI and search crawlers named, `/api/`, `/auth/`, `/internal/`, `/o/`, `/metrics`, `/live/` disallowed) and the sitemap |++| `GET /robots.txt` | crawler policy (`openvibe-shared/seo.robotsTxt`: AI and search crawlers named, `/api/`, `/auth/`, `/internal/`, `/o/`, `/metrics`, `/live/`, `/me` disallowed) and the sitemap || `GET /sitemap.xml` | the media index and the watch pages Media is the canonical home of: public, playable VODs and clips of apps other than Live (Live lists its own `/vod` and `/clip` pages), never AI clips, private, unlisted, sandbox, recording or failed items; thumbnails as image entries. Every listed page renders `index, follow` || `GET /llms.txt` | a map of the site for language-model crawlers || `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |@@@ -398,6 +398,65 @@ every tenant gets its root row and every namespace an object names gets a row. O(`media_settings` `namespaces.reservations_seeded`), uploads already in progress get a reservation oftheir declared size for the full window from that open.++## Object explorer++++Roadmap WS-G task 12. Code: `server/me/` (`routes.js`, `explorer.js`, `ops.js`, `pages.js`, and `client.js`,++served as `/me/app.js`). Test: `test/me-explorer.test.js`.++++**Who.** The person signed in to openvibe.media (the Network access token in the `ov_token` cookie, or++a bearer token), identified by its `subject_id` (`usr_…`). The token must name `openvibe.media` in its++audience and be a person's: service and app principals and FedCM assertions are refused. A token++without a subject answers 401 `auth.no_subject` (signing in again issues one).++++**What.** The objects whose `owner_subject` is that subject, in every tenant, and nothing else: another++person's object is a 404 like a missing one, and no tenant's quota or namespace policy is shown (a person++owns no tenant). Keys, paths, metadata beyond the file name and title, job error text and a hold's kind,++reason, note and author are left out; a hold shows only as `held`.++++| method | path | notes |++|---|---|---|++| GET | `/api/v2/me/objects` | `?cursor&limit (≤100, 25)&status (a lifecycle, or all; default all but deleted)&kind&q (file name, title, type or id)&app` → `{ objects, next_cursor, limit }`, newest first. Each: `id`, `app_id`, `tenant { name, sandbox, project_id }`, `namespace`, `kind`, `filename`, `title`, `mime_type`, `size_bytes`, `visibility`, `lifecycle_status`, `readiness { metadata, bytes_verified, playable, reason }`, `held`, `derivatives` (live objects made from it: its variants and `derived_from` / `thumbnail_of` sources, not clips), `upload` (below), `failure`, `deleted { deleted_at, retention_until, purged }`, `public_url`, `created_at`, `updated_at` |++| GET | `/api/v2/me/objects/:id` | one of them, plus `content_hash`, `locations` (provider, class, state, checked), `derivative_list`, `source` (when that is the person's too) and the last 10 `jobs` (type, status, error code) |++| GET | `/api/v2/me/usage` | objects and bytes per tenant and namespace, by lifecycle: `objects` (not deleted), `stored_bytes` (ready and archived), `by_status` (uploading counts declared sizes; deleted are kept until the purge); `quotas: null` |++++`upload` (native objects still `uploading`): `method` (`multipart` or `single`), `waiting_for` (`parts`,++`assembly`, `bytes`, or `complete` when the bytes are stored and the app has not completed it),++`received_bytes` (parts received, the stored PUT, or a PUT still streaming into `OBJECTS_PATH/.tmp`),++`declared_bytes`, `percent`, `parts_received` / `parts_expected`, and `expires_at` (the quota++reservation: unfinished by then, the hourly sweep fails the upload).++++**Read-only.** Visibility, delete and restore stay with the apps: the object API takes them from a++tenant credential (app key or Network token) and answers a user JWT 403, so the explorer does not act++for a person.++++**Pages.** `GET /me` (and `/me/`) renders the usage table and the first page of objects in the OpenVibe++Frame, with filters as a GET form and "Next page" as a link, so it is complete without JavaScript;++`/me/app.js` (same-origin, no inline code) adds filtering and "Load more" without a reload and polls++uploads in progress every 4 s while the tab is visible. `GET /me/objects/:id` is one object. Signed++out, each is a sign-in prompt (`/auth/login?next=/me`). Every answer is `private, no-store`, `noindex`;++`/me` is disallowed in `robots.txt` and never in the sitemap. `/api/v2/me/…` is mounted ahead of the++tenant routes, so `me` cannot be a tenant id there. A restore drill signs nobody in (503).++++### Operator views++++For Network staff by the contracts staff map (ADR-022): `staff.site.view` reads, `staff.site.configure`++recomputes. The same report is in the admin routes for an app's own server (app key, scoped to it).++++| method | path | notes |++|---|---|---|++| GET | `/api/v2/me/ops`, `/me/ops` | `?app` narrows it to one tenant; `?limit` (≤200, 50) per list |++| POST | `/api/v2/me/ops/recompute`, `/me/ops/recompute` | refresh every namespace's usage snapshot (or one tenant's) from the rows. With the cookie only from this site's own pages (`Sec-Fetch-Site: same-origin`, else `Origin`); the page form redirects back (303) |++| GET / POST | `/api/v1/:app/admin/storage/ops`, `/ops/recompute` | the app key, not acting for a user |++++The report (`server/me/ops.js`) reads the database only: no provider, file or directory, so it stays++cheap and renders in a drill.++++- `jobs`: counts by status; failed jobs of the last 7 days by type and error code; the most recent failures with their error text.++- `missing`: ready objects with no good copy; copies recorded missing or corrupt; copies by provider and state; quarantined VODs; the last verification run.++- `backfill`: the last object backfill that changed something; rows with no object yet; objects whose owner subject is unresolved; whether upload reservations were seeded.++- `tiering`: providers and policy; VODs and clips per provider; objects per canonical copy (native or projected); VODs eligible to offload; R2 decisions of the last 24 h and recent refusals and failures; the sweep's state in this process.++- `namespaces`: every namespace with quota and usage snapshot.++## ReadinessCode: `server/objects/readiness.js`. Test: `test/readiness.test.js`.@@@ -28,6 +28,9 @@* 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++ * GET /ops this app's operator report (server/me/ops.js, as openvibe.media/me/ops shows++ * it to Network staff): failed jobs, missing media, backfill, tiering, namespaces++ * POST /ops/recompute refresh this app's namespaces' usage snapshot from the rows*/'use strict';@@@ -594,6 +597,42 @@ function _releaseHold(req, res) {router.post('/holds/:holdId/release', _releaseHold);router.delete('/holds/:holdId', _releaseHold);++// ═══════════════════════════════════════════════════════════════++// Operator report (WS-G task 12) — the same report as openvibe.media/me/ops, scoped to this app++// ═══════════════════════════════════════════════════════════════++++/** The operator views are the app's own (its server fronts its admins), never a call acting for one of its users. */++function _opsStaffOnly(req, res) {++ if (req.authType === 'app') return true;++ res.status(403).json({ error: 'The operator report is for the app itself: call with the app key, not on behalf of a user', code: 'media.ops.forbidden' });++ return false;++}++++// GET /ops?limit — failed jobs, missing media, backfill status, tiering diagnostics and the namespaces'++// usage snapshot of this app (server/me/ops.js; the sweep and the provider switches are service-wide).++router.get('/ops', (req, res) => {++ try {++ if (!_opsStaffOnly(req, res)) return;++ res.json(require('../me/ops').report({ appId: req.appId, limit: req.query.limit }));++ } catch (err) {++ console.error('[Admin] Ops report error:', err.message);++ res.status(500).json({ error: 'Failed to build the operator report' });++ }++});++++// POST /ops/recompute — refresh this app's namespaces' usage snapshot from the rows.++router.post('/ops/recompute', (req, res) => {++ try {++ if (!_opsStaffOnly(req, res)) return;++ const out = require('../me/ops').recompute({ appId: req.appId });++ console.log(`[Admin] Namespace usage recomputed (${req.appId}): ${out.namespaces} namespace(s)`);++ res.json(out);++ } catch (err) {++ console.error('[Admin] Usage recompute error:', err.message);++ res.status(500).json({ error: 'Failed to recompute usage' });++ }++});++// ── 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.@@@ -104,7 +104,8 @@ const userAuthConfig = {};// A restore drill signs nobody in: that would redeem codes at Network.if (drill.enabled) app.use('/auth', (req, res) => res.status(503).json({ error: 'This is a restore-drill instance (MEDIA_DRILL): no sign-in', code: 'media.drill_no_sign_in' }));--app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuth.createAuthClient(userAuthConfig)));++const userAuthClient = userAuth.createAuthClient(userAuthConfig);++app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuthClient));app.use(express.urlencoded({ extended: true, limit: '2mb' }));// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).@@@ -175,6 +176,11 @@ app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth(), (req, res) => {try { require('./views/service').ensureSchema(); } catch (e) { console.warn('[Views] schema:', e.message); }if (!drill.enabled) setInterval(() => { try { const n = require('./views/service').prune(30); if (n) console.log(`[Views] pruned ${n} stale visit row(s)`); } catch { /* */ } }, 12 * 3600 * 1000);++// The object explorer (WS-G task 12): a signed-in person's own objects and usage, and the operator views++// (server/me/routes.js). /api/v2/me sits ahead of /api/v2/:app/…, so "me" is never a tenant there.++const me = require('./me/routes').createMeRoutes({ auth: userAuthClient });++app.use('/api/v2/me', me.api);++app.use('/me', me.pages);app.use('/api/v1/:app/views', require('./views/routes'));app.use('/api/v1/:app/vods', require('./vod/routes'));app.use('/api/v1/:app/clips', require('./vod/clips-routes'));nnew file mode 100644@@@ -0,0 +1,247 @@++/*++ * OpenVibe.Media — /me/app.js: progressive enhancement for the object explorer (server/me/pages.js).++ * Runs in the browser. The pages work without it; with it:++ * - the filter form and "Next page" load from /api/v2/me/objects without a reload ("Load more" appends);++ * - uploads in progress are polled (every 4 s while the tab is visible, backing off on errors) and++ * their progress bars updated; a row whose upload finished is replaced with its new state.++ * Every string from the API goes into the page as text (textContent), never as markup.++ */++(function () {++ 'use strict';++++ var KIND = { vod: 'VOD', clip: 'Clip', file: 'File', thumbnail: 'Thumbnail', screenshot: 'Screenshot', avatar: 'Avatar', asset: 'Asset' };++ var STATUS = { uploading: 'Uploading', ready: 'Ready', failed: 'Failed', archived: 'Archived', deleted: 'Deleted' };++ var REASON = { no_object: 'no object', deleted: 'deleted', metadata_unreadable: 'metadata unreadable', failed: 'failed', archived: 'archived',++ recording: 'still recording', processing: 'still processing', verification_pending: 'copy not checked yet', no_verified_copy: 'no verified copy' };++ var POLL_MS = 4000;++++ function fmtBytes(n) {++ n = Number(n) || 0;++ if (n < 1024) return n + ' B';++ var units = ['KB', 'MB', 'GB', 'TB'], v = n, i = -1;++ do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);++ return (v >= 100 ? Math.round(v) : v.toFixed(1)) + ' ' + units[i];++ }++ function fmtWhen(iso) {++ if (!iso) return '';++ var d = new Date(iso);++ return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' ') + ' UTC';++ }++ function num(n) { return Number(n || 0).toLocaleString('en-US'); }++ function displayName(it) { return it.title || it.filename || ((KIND[it.kind] || it.kind) + ' ' + it.id.slice(-6)); }++++ /** el('a', { href: '/x', className: 'c' }, ['text', node]) */++ function el(tag, attrs, children) {++ var node = document.createElement(tag);++ Object.keys(attrs || {}).forEach(function (k) {++ var v = attrs[k];++ if (v == null || v === false) return;++ if (k === 'className') node.className = v;++ else if (k === 'text') node.textContent = v;++ else node.setAttribute(k, v === true ? '' : String(v));++ });++ (children || []).forEach(function (c) { if (c != null) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });++ return node;++ }++ function tag(cls, text) { return el('span', { className: 'tag ' + cls, text: text }); }++ function sub(text) { return el('span', { className: 'sub', text: text }); }++++ function uploadWords(u) {++ if (u.waiting_for === 'complete') return 'all bytes stored, waiting for the app to complete it';++ if (u.waiting_for === 'assembly') return 'assembling the parts';++ return (u.percent != null ? u.percent + '% · ' : '') + fmtBytes(u.received_bytes)++ + (u.declared_bytes ? ' of ' + fmtBytes(u.declared_bytes) : ' received')++ + (u.method === 'multipart' ? ' · ' + u.parts_received + ' of ' + u.parts_expected + ' parts' : '');++ }++++ function uploadBox(it) {++ var u = it.upload;++ var bar = el('progress', { 'aria-label': 'Upload of ' + displayName(it) });++ if (u.percent != null) { bar.max = 100; bar.value = u.percent; bar.textContent = u.percent + '%'; }++ var box = el('div', { className: 'up', 'data-upload': it.id }, [bar, el('span', { className: 'sub up-text', text: uploadWords(u) })]);++ if (u.expires_at) box.appendChild(sub('unfinished uploads fail after ' + fmtWhen(u.expires_at)));++ return box;++ }++++ function statusNodes(it) {++ var out = [el('span', { className: 'st', text: STATUS[it.lifecycle_status] || it.lifecycle_status })];++ if (it.upload) out.push(' ', uploadBox(it));++ if (it.failure) out.push(' ', sub(it.failure.replace(/_/g, ' ')));++ if (it.deleted) out.push(' ', sub(it.deleted.purged ? 'bytes purged' : it.deleted.retention_until ? 'kept until ' + fmtWhen(it.deleted.retention_until) : 'kept for the retention period'));++ if (it.held) out.push(' ', tag('warn', 'Held'));++ return out;++ }++++ function readinessNodes(it) {++ var r = it.readiness || {};++ var out = [r.metadata ? tag('ok', 'metadata') : tag('no', 'no metadata'), r.bytes_verified ? tag('ok', 'bytes verified') : tag('no', 'bytes not verified')];++ if (it.kind === 'vod' || it.kind === 'clip') out.push(r.playable ? tag('ok', 'playable') : tag('no', 'not playable' + (r.reason && REASON[r.reason] ? ': ' + REASON[r.reason] : '')));++ return out;++ }++++ function cell(label, children, cls) { return el('td', { 'data-label': label, className: cls }, [el('span', {}, children)]); }++++ function buildRow(it) {++ var name = displayName(it);++ var tenant = [it.tenant.name];++ if (it.tenant.sandbox) tenant.push(' ', el('span', { className: 'tag', text: 'sandbox' }));++ tenant.push(sub(it.namespace));++ var vis = [it.visibility];++ if (it.public_url) vis.push(' ', el('a', { href: it.public_url }, ['open', el('span', { className: 'sr', text: ' ' + name })]));++ var head = el('th', { scope: 'row', 'data-label': 'Name' }, [el('span', {}, [++ el('a', { href: '/me/objects/' + encodeURIComponent(it.id), text: name }),++ el('span', { className: 'id', text: it.id }),++ it.mime_type ? sub(it.mime_type) : null,++ ])]);++ var tr = el('tr', { 'data-id': it.id, 'data-status': it.lifecycle_status, 'data-uploading': it.upload ? '1' : null }, [++ head,++ el('td', { 'data-label': 'Kind', text: KIND[it.kind] || it.kind }),++ cell('Tenant', tenant),++ el('td', { 'data-label': 'Size', className: 'n', text: fmtBytes(it.size_bytes) }),++ cell('Visibility', vis),++ cell('Status', statusNodes(it)),++ cell('Readiness', readinessNodes(it)),++ el('td', { 'data-label': 'Derivatives', className: 'n', text: num(it.derivatives) }),++ cell('Created', [fmtWhen(it.created_at)]),++ ]);++ return tr;++ }++++ function fetchJson(url) {++ return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {++ return res.json().catch(function () { return {}; }).then(function (body) {++ if (!res.ok) { var err = new Error(body.detail || body.error || ('HTTP ' + res.status)); err.status = res.status; throw err; }++ return body;++ });++ });++ }++++ var live = document.getElementById('me-status');++ function announce(text) { if (live) live.textContent = text; }++++ // ── Upload progress (the list, or one object's page) ─────++ var pollTimer = null, pollDelay = POLL_MS;++ var table = document.getElementById('me-objects');++++ function uploadingIds() {++ var nodes = document.querySelectorAll(table ? 'tr[data-uploading]' : '[data-upload]');++ return Array.prototype.map.call(nodes, function (n) { return n.getAttribute(table ? 'data-id' : 'data-upload'); });++ }++++ function schedulePoll() {++ if (pollTimer || !uploadingIds().length) return;++ pollTimer = setTimeout(poll, pollDelay);++ }++++ function onVisible() { if (!document.hidden) { document.removeEventListener('visibilitychange', onVisible); schedulePoll(); } }++++ function updateRow(tr, it) {++ if (it.upload && it.lifecycle_status === 'uploading') {++ var box = tr.querySelector('[data-upload]');++ if (box) { box.parentNode.replaceChild(uploadBox(it), box); return; }++ }++ var hadFocus = tr.contains(document.activeElement);++ var fresh = buildRow(it);++ tr.parentNode.replaceChild(fresh, tr);++ if (hadFocus) fresh.querySelector('a').focus();++ if (!it.upload) announce(displayName(it) + ': ' + (STATUS[it.lifecycle_status] || it.lifecycle_status) + '.');++ }++++ function poll() {++ pollTimer = null;++ if (document.hidden) { document.addEventListener('visibilitychange', onVisible); return; }++ var ids = uploadingIds();++ if (!ids.length) return;++ if (!table) {++ // One object's page: follow it; when it stops uploading, show its new state.++ fetchJson('/api/v2/me/objects/' + encodeURIComponent(ids[0])).then(function (it) {++ if (it.lifecycle_status !== 'uploading') { window.location.reload(); return; }++ var box = document.querySelector('[data-upload]');++ if (box && it.upload) box.parentNode.replaceChild(uploadBox(it), box);++ pollDelay = POLL_MS; schedulePoll();++ }).catch(failed);++ return;++ }++ fetchJson('/api/v2/me/objects?status=uploading&limit=100').then(function (data) {++ var byId = {};++ (data.objects || []).forEach(function (o) { byId[o.id] = o; });++ var pending = ids.map(function (id) {++ var tr = table.querySelector('tr[data-id="' + id + '"]');++ if (!tr) return null;++ if (byId[id]) { updateRow(tr, byId[id]); return null; }++ return fetchJson('/api/v2/me/objects/' + encodeURIComponent(id)).then(function (it) { updateRow(tr, it); })++ .catch(function () { tr.removeAttribute('data-uploading'); });++ });++ return Promise.all(pending);++ }).then(function () { pollDelay = POLL_MS; schedulePoll(); }).catch(failed);++ }++++ function failed(err) {++ if (err && err.status === 401) { announce('You are signed out: upload progress stopped. Reload the page after signing in.'); return; }++ pollDelay = Math.min(pollDelay * 2, 60000);++ schedulePoll();++ }++++ // ── Filters and pages (/me) ──────────────────────────────++ var form = document.getElementById('me-filters');++ var next = document.getElementById('me-next');++ var empty = document.getElementById('me-empty');++++ function filterQuery() {++ var p = new URLSearchParams();++ new FormData(form).forEach(function (v, k) { if (String(v).trim()) p.set(k, String(v).trim()); });++ return p;++ }++++ function load(append) {++ var p = filterQuery();++ var cursor = append && next ? next.getAttribute('data-cursor') : null;++ if (cursor) p.set('cursor', cursor);++ announce('Loading…');++ return fetchJson('/api/v2/me/objects?' + p.toString()).then(function (data) {++ var body = table.tBodies[0];++ if (!append) while (body.firstChild) body.removeChild(body.firstChild);++ var first = null;++ (data.objects || []).forEach(function (it) { var tr = buildRow(it); if (!first) first = tr; body.appendChild(tr); });++ var shown = body.rows.length;++ table.hidden = !shown;++ if (empty) { empty.hidden = !!shown; empty.textContent = p.toString().replace(/(^|&)cursor=[^&]*/, '') ? 'No objects match these filters.' : 'No objects yet.'; }++ if (next) {++ var q = new URLSearchParams(p.toString()); q.delete('cursor');++ if (data.next_cursor) {++ q.set('cursor', data.next_cursor);++ next.href = '/me?' + q.toString();++ next.setAttribute('data-cursor', data.next_cursor);++ next.textContent = 'Load more';++ next.hidden = false;++ } else {++ next.hidden = true;++ next.removeAttribute('data-cursor');++ }++ }++ if (!append) {++ var clean = filterQuery().toString();++ try { history.replaceState(null, '', '/me' + (clean ? '?' + clean : '')); } catch (e) { /* not allowed here */ }++ }++ announce(num(shown) + ' object' + (shown === 1 ? '' : 's') + ' shown' + (data.next_cursor ? ', more available' : '') + '.');++ if (append && first) first.querySelector('a').focus();++ schedulePoll();++ }).catch(function (err) {++ announce(err.status === 401 ? 'You are signed out. Reload the page after signing in.' : 'Could not load your objects: ' + err.message);++ });++ }++++ if (form && table) {++ form.addEventListener('submit', function (e) { e.preventDefault(); load(false); });++ if (next) {++ var m = /[?&]cursor=([^&]+)/.exec(next.getAttribute('href') || '');++ if (m && !next.hidden) { next.setAttribute('data-cursor', decodeURIComponent(m[1])); next.textContent = 'Load more'; }++ next.addEventListener('click', function (e) {++ if (!next.getAttribute('data-cursor')) return;++ e.preventDefault();++ load(true);++ });++ }++ }++ schedulePoll();++})();nnew file mode 100644@@@ -0,0 +1,296 @@++/**++ * OpenVibe.Media — the object explorer's reads for one person (roadmap WS-G task 12;++ * docs/object-model.md#object-explorer).++ *++ * Everything here is scoped to one Network subject (usr_…): the objects whose owner_subject is that++ * subject, in every tenant. Nobody else's object is ever read into an answer, and no tenant's quota++ * or namespace policy is shown (a person owns no tenant; the tenants' quotas are their operators').++ *++ * list(subject, filters) a page of the person's objects, newest first (cursor = the last id)++ * detail(subject, id) one of them with its copies, derivatives and recent jobs, or null++ * usage(subject) the person's objects and bytes per tenant and namespace, by lifecycle++ * parseFilters(query) ?cursor&limit&status&kind&q&app → { filters } | { error }++ *++ * Read-only: nothing here writes, and none of it runs at load.++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const db = require('../db/database');++const config = require('../config');++const drill = require('../drill');++const model = require('../objects/model');++const readiness = require('../objects/readiness');++++const DEFAULT_LIMIT = 25;++const MAX_LIMIT = 100;++const ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const APP_RE = /^[A-Za-z0-9_.-]{1,80}$/;++const STATUS_FILTERS = [...model.LIFECYCLES, 'all'];++/** Relations whose `from` object was made out of the `to` object (a clip is its own work, not a derivative). */++const DERIVED_RELATIONS = ['derived_from', 'thumbnail_of'];++++/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) or ISO → ISO 8601, or null. */++function iso(v) {++ if (!v) return null;++ const s = String(v);++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s) && !s.includes('T') ? `${s.replace(' ', 'T')}Z` : s);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++const text = (v, n = 300) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, n) : null);++++/** ?cursor&limit&status&kind&q&app → { filters } or { error } (a 400). */++function parseFilters(query = {}) {++ const q = query || {};++ const one = (v) => (Array.isArray(v) ? v[0] : v);++ const f = { limit: DEFAULT_LIMIT, cursor: null, status: null, kind: null, q: null, app: null };++ const limit = one(q.limit);++ if (limit != null && limit !== '') {++ const n = Number(limit);++ if (!Number.isInteger(n) || n < 1) return { error: 'limit must be a positive integer' };++ f.limit = Math.min(n, MAX_LIMIT);++ }++ const cursor = one(q.cursor);++ if (cursor != null && cursor !== '') {++ if (!ID_RE.test(String(cursor))) return { error: 'Bad cursor' };++ f.cursor = String(cursor);++ }++ const status = one(q.status);++ if (status != null && status !== '') {++ if (!STATUS_FILTERS.includes(String(status))) return { error: `status must be one of ${STATUS_FILTERS.join(', ')}` };++ f.status = String(status);++ }++ const kind = one(q.kind);++ if (kind != null && kind !== '') {++ if (!model.KINDS.includes(String(kind))) return { error: `kind must be one of ${model.KINDS.join(', ')}` };++ f.kind = String(kind);++ }++ const search = one(q.q);++ if (search != null && String(search).trim() !== '') f.q = String(search).trim().slice(0, 100);++ const app = one(q.app);++ if (app != null && app !== '') {++ if (!APP_RE.test(String(app))) return { error: 'Bad app' };++ f.app = String(app);++ }++ return { filters: f };++}++++/** The tenant as the person sees it: id, name, whether it is a developer project's sandbox. */++function tenantOf(appId, cache = new Map()) {++ if (!cache.has(appId)) {++ const app = db.getApp(appId);++ cache.set(appId, {++ app_id: appId,++ name: (app && app.name) || appId,++ project_id: (app && app.project_id) || null,++ sandbox: !!(app && app.env === 'sandbox'),++ });++ }++ return cache.get(appId);++}++++/** Non-deleted objects made out of each of `ids`: Map<id, count>. */++function derivativeCounts(ids) {++ const out = new Map();++ if (!ids.length) return out;++ const ph = ids.map(() => '?').join(', ');++ const rels = DERIVED_RELATIONS.map(() => '?').join(', ');++ const rows = db.all(`SELECT x.src AS src, COUNT(DISTINCT x.d) AS n FROM (++ SELECT v.object_id AS src, v.derived_object_id AS d FROM media_variants v WHERE v.object_id IN (${ph})++ UNION++ SELECT r.to_object_id, r.from_object_id FROM media_relationships r WHERE r.to_object_id IN (${ph}) AND r.relation IN (${rels})++ ) x JOIN media_objects d ON d.id = x.d AND d.lifecycle_status != 'deleted' GROUP BY x.src`, [...ids, ...ids, ...DERIVED_RELATIONS]);++ for (const r of rows) out.set(r.src, r.n);++ return out;++}++++/** Bytes of a single-part PUT still streaming in (OBJECTS_PATH/.tmp/<id>-<rand>), or null. A drill reads no storage. */++function inFlightBytes(ctx, id) {++ if (drill.enabled) return null;++ const dir = path.join(config.objects.path, '.tmp');++ if (!ctx.tmp) {++ ctx.tmp = [];++ try { ctx.tmp = fs.readdirSync(dir); } catch { /* no upload has streamed yet */ }++ }++ const name = ctx.tmp.find(n => n.startsWith(`${id}-`));++ if (!name) return null;++ try { return fs.statSync(path.join(dir, name)).size; } catch { return null; }++}++++/**++ * How far a native upload has got: a multipart session's parts received, the bytes a PUT stored (the++ * upload then waits for complete), or a PUT still streaming. Bytes received vs the declared size.++ */++function uploadProgress(obj, locs, ctx) {++ const declared = Number(obj.size_bytes) || 0;++ const session = db.get("SELECT * FROM media_uploads WHERE object_id = ? AND status IN ('active', 'completing') ORDER BY created_at DESC LIMIT 1", [obj.id]);++ const reservation = db.get('SELECT expires_at FROM media_quota_reservations WHERE object_id = ?', [obj.id]);++ let out;++ if (session) {++ const parts = db.get('SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_upload_parts WHERE upload_id = ?', [session.id]);++ out = {++ method: 'multipart', waiting_for: session.status === 'completing' ? 'assembly' : 'parts',++ received_bytes: parts.b, parts_received: parts.n, parts_expected: session.parts_expected,++ session_expires_at: iso(session.expires_at),++ };++ } else {++ const stored = locs.find(l => l.provider === 'local' && l.state === 'present');++ if (stored) {++ out = { method: 'single', waiting_for: 'complete', received_bytes: Number(stored.size_bytes) || 0 };++ } else {++ const streaming = inFlightBytes(ctx, obj.id);++ out = { method: 'single', waiting_for: 'bytes', received_bytes: streaming || 0, streaming: streaming != null };++ }++ }++ out.declared_bytes = declared || null;++ out.percent = declared ? Math.min(100, Math.floor((out.received_bytes * 100) / declared)) : null;++ out.expires_at = reservation ? iso(reservation.expires_at) : null; // unfinished by then: the upload fails++ return out;++}++++/** One object as its owner sees it: no keys, paths, staff notes or other people's data. */++function item(obj, ctx, derivatives = 0) {++ const md = model.parseJson(obj.metadata, {}) || {};++ const locs = model.listLocations(obj.id);++ const r = readiness.compute(obj, locs);++ const tenant = tenantOf(obj.app_id, ctx.tenants);++ const ready = obj.lifecycle_status === 'ready';++ return {++ id: obj.id,++ app_id: obj.app_id,++ tenant,++ namespace: obj.namespace,++ kind: obj.kind,++ filename: text(md.filename, 200),++ title: text(md.title),++ mime_type: obj.mime_type || null,++ size_bytes: Number(obj.size_bytes) || 0,++ visibility: obj.visibility,++ lifecycle_status: obj.lifecycle_status,++ readiness: { metadata: r.metadata, bytes_verified: r.bytes_verified, playable: r.playable, reason: r.reason },++ // Only whether a retention hold keeps it; its kind, reason, note and who placed it are staff's.++ held: model.isHeld(obj.id),++ derivatives,++ upload: obj.lifecycle_status === 'uploading' && !obj.legacy_ref ? uploadProgress(obj, locs, ctx) : null,++ failure: obj.lifecycle_status === 'failed' ? text(md.failure, 60) : null,++ deleted: obj.lifecycle_status === 'deleted'++ ? { deleted_at: iso(obj.deleted_at), retention_until: iso(md.retention_until), purged: !!md.purged_at } : null,++ public_url: obj.visibility !== 'private' && ready && !tenant.sandbox ? (model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,++ managed_by: obj.legacy_ref ? 'v1' : 'v2',++ created_at: iso(obj.created_at),++ updated_at: iso(obj.updated_at),++ };++}++++/** A page of the subject's objects, newest first: { objects, next_cursor, limit }. */++function list(subject, filters = {}) {++ const f = { limit: DEFAULT_LIMIT, ...filters };++ const conds = ['o.owner_subject = @subject'];++ const p = { subject };++ if (f.cursor) { conds.push('o.id < @cursor'); p.cursor = f.cursor; }++ if (f.status === 'all') { /* everything, deleted included */ } else if (f.status) { conds.push('o.lifecycle_status = @status'); p.status = f.status; } else conds.push("o.lifecycle_status != 'deleted'");++ if (f.kind) { conds.push('o.kind = @kind'); p.kind = f.kind; }++ if (f.app) { conds.push('o.app_id = @app'); p.app = f.app; }++ if (f.q) {++ const field = (k) => `instr(lower(COALESCE(CASE WHEN json_valid(o.metadata) THEN json_extract(o.metadata, '$.${k}') END, '')), @ql) > 0`;++ conds.push(`(o.id = @q OR ${field('filename')} OR ${field('title')} OR instr(lower(COALESCE(o.mime_type, '')), @ql) > 0)`);++ p.q = f.q; p.ql = f.q.toLowerCase();++ }++ const limit = Math.min(Math.max(Number(f.limit) || DEFAULT_LIMIT, 1), MAX_LIMIT);++ const rows = db.all(`SELECT o.* FROM media_objects o WHERE ${conds.join(' AND ')} ORDER BY o.id DESC LIMIT ${limit + 1}`, p);++ const page = rows.slice(0, limit);++ const counts = derivativeCounts(page.map(o => o.id));++ const ctx = { tenants: new Map() };++ return {++ objects: page.map(o => item(o, ctx, counts.get(o.id) || 0)),++ next_cursor: rows.length > limit ? page[page.length - 1].id : null,++ limit,++ };++}++++/** One of the subject's objects (med_ id or legacy ref) with copies, derivatives and jobs; null when it is not theirs. */++function detail(subject, idOrRef) {++ const key = String(idOrRef || '');++ const obj = key.startsWith('legacy:') ? model.getObjectByLegacyRef(key) : (ID_RE.test(key) ? model.getObject(key) : null);++ if (!obj || !subject || obj.owner_subject !== subject) return null;++ const ctx = { tenants: new Map() };++ const derived = new Map();++ for (const v of db.all('SELECT variant_name, derived_object_id FROM media_variants WHERE object_id = ? ORDER BY id', [obj.id])) {++ derived.set(v.derived_object_id, v.variant_name);++ }++ for (const r of db.all(`SELECT relation, from_object_id FROM media_relationships WHERE to_object_id = ? AND relation IN (${DERIVED_RELATIONS.map(() => '?').join(', ')}) ORDER BY id`,++ [obj.id, ...DERIVED_RELATIONS])) {++ if (!derived.has(r.from_object_id)) derived.set(r.from_object_id, r.relation === 'thumbnail_of' ? 'thumbnail' : 'derived');++ }++ const derivatives = [];++ for (const [id, name] of derived) {++ const d = model.getObject(id);++ if (!d) continue;++ derivatives.push({ id: d.id, name, kind: d.kind, visibility: d.visibility, lifecycle_status: d.lifecycle_status, size_bytes: Number(d.size_bytes) || 0, mine: d.owner_subject === subject, created_at: iso(d.created_at) });++ }++ // What it was made from, when that is the person's too (a clip of someone else's stream names no one's VOD).++ const src = db.get(`SELECT r.relation, r.to_object_id FROM media_relationships r JOIN media_objects s ON s.id = r.to_object_id++ WHERE r.from_object_id = ? AND r.relation IN ('derived_from', 'thumbnail_of', 'clip_of') AND s.owner_subject = ? ORDER BY r.id LIMIT 1`, [obj.id, subject]);++ const jobs = db.all(`SELECT id, job_type, status, error_code, attempts, max_attempts, created_at, finished_at FROM media_jobs++ WHERE object_id = ? ORDER BY id DESC LIMIT 10`, [obj.id]).map(j => ({++ id: j.id, type: j.job_type, status: j.status, error_code: j.error_code || null, attempts: j.attempts, max_attempts: j.max_attempts,++ created_at: iso(j.created_at), finished_at: iso(j.finished_at),++ }));++ return {++ ...item(obj, ctx, derivatives.filter(d => d.lifecycle_status !== 'deleted').length),++ content_hash: obj.content_hash || null,++ locations: model.listLocations(obj.id).map(l => ({++ provider: l.provider, storage_class: l.storage_class, state: l.state, size_bytes: l.size_bytes,++ verified_at: iso(l.verified_at), canonical: l.provider === obj.canonical_provider,++ })),++ derivative_list: derivatives,++ source: src ? { id: src.to_object_id, relation: src.relation } : null,++ jobs,++ };++}++++/** Counts per lifecycle status → { objects (not deleted), stored_bytes (ready + archived), by_status }. */++function group(rows) {++ const by = {};++ let objects = 0, stored = 0;++ for (const r of rows) {++ const s = by[r.status] || (by[r.status] = { objects: 0, bytes: 0 });++ s.objects += r.n; s.bytes += r.b;++ if (r.status !== 'deleted') objects += r.n;++ if (r.status === 'ready' || r.status === 'archived') stored += r.b;++ }++ return { objects, stored_bytes: stored, by_status: by };++}++++/**++ * The subject's usage: their own objects and bytes per tenant and namespace (never a tenant's totals or++ * quota). Uploading bytes are the declared sizes; deleted ones are kept until the retention purge.++ */++function usage(subject) {++ const rows = db.all(`SELECT app_id, namespace, lifecycle_status AS status, COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b++ FROM media_objects WHERE owner_subject = ? GROUP BY app_id, namespace, lifecycle_status ORDER BY app_id, namespace`, [subject]);++ const tenants = new Map();++ const cache = new Map();++ for (const r of rows) {++ if (!tenants.has(r.app_id)) tenants.set(r.app_id, { rows: [], namespaces: new Map() });++ const t = tenants.get(r.app_id);++ t.rows.push(r);++ if (!t.namespaces.has(r.namespace)) t.namespaces.set(r.namespace, []);++ t.namespaces.get(r.namespace).push(r);++ }++ return {++ subject,++ totals: group(rows),++ tenants: [...tenants].map(([appId, t]) => ({++ ...tenantOf(appId, cache),++ ...group(t.rows),++ namespaces: [...t.namespaces].map(([namespace, nsRows]) => ({ namespace, ...group(nsRows) })),++ })),++ quotas: null,++ note: 'Your own objects only. Quotas are set per app and namespace by their operators and are not shown here.',++ };++}++++module.exports = { list, detail, usage, parseFilters, iso, DEFAULT_LIMIT, MAX_LIMIT, STATUS_FILTERS, ID_RE };nnew file mode 100644@@@ -0,0 +1,220 @@++/**++ * OpenVibe.Media — operator views over the object platform (roadmap WS-G task 12;++ * docs/object-model.md#operator-views).++ *++ * report({ appId, limit }) failed jobs, missing media, backfill status, tiering diagnostics and the++ * namespaces' usage snapshot; appId narrows everything that has a tenant++ * (the tiering sweep and the provider switches are service-wide)++ * recompute({ appId }) refresh the namespaces' usage snapshot from the rows (namespaces.reconcile),++ * the one write here++ *++ * Read from the database only: no provider is called, no file is opened and no directory is walked, so++ * a page load stays cheap and a restore drill (MEDIA_DRILL) can render it. Nothing runs at load.++ * Callers: GET /api/v2/me/ops and /me/ops (a Network token with staff.site.view; the recompute needs++ * staff.site.configure), GET /api/v1/:app/admin/storage/ops (the app key, scoped to that app).++ */++'use strict';++++const db = require('../db/database');++const namespaces = require('../objects/namespaces');++const copyReport = require('../objects/copy-report');++++const iso = require('./explorer').iso;++const clip = (s, n) => (s == null ? null : String(s).slice(0, n));++++/** `AND <col> = ?` when scoped to one tenant. */++function scoped(appId, col = 'app_id') {++ return appId ? { sql: ` AND ${col} = ?`, params: [appId] } : { sql: '', params: [] };++}++++function tableExists(name) {++ return !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?", [name]);++}++++// ── Jobs ─────────────────────────────────────────────────────++++function jobs(appId, limit) {++ const s = scoped(appId);++ const byStatus = {};++ for (const r of db.all(`SELECT status, COUNT(*) AS n FROM media_jobs WHERE 1 = 1${s.sql} GROUP BY status`, s.params)) byStatus[r.status] = r.n;++ const total = byStatus.failed || 0;++ const week = db.all(`SELECT job_type, COALESCE(error_code, '') AS error_code, COUNT(*) AS n FROM media_jobs++ WHERE status = 'failed' AND COALESCE(finished_at, updated_at) >= datetime('now', '-7 days')${s.sql}++ GROUP BY job_type, error_code ORDER BY n DESC, job_type`, s.params)++ .map(r => ({ type: r.job_type, error_code: r.error_code || null, count: r.n }));++ const recent = db.all(`SELECT id, app_id, object_id, job_type, error_code, error, attempts, max_attempts, created_by, created_at, finished_at, updated_at++ FROM media_jobs WHERE status = 'failed'${s.sql} ORDER BY COALESCE(finished_at, updated_at) DESC, id DESC LIMIT ?`, [...s.params, limit])++ .map(j => ({++ id: j.id, app_id: j.app_id, object_id: j.object_id || null, type: j.job_type, error_code: j.error_code || null,++ error: clip(j.error, 500), attempts: j.attempts, max_attempts: j.max_attempts, created_by: j.created_by || null,++ created_at: iso(j.created_at), finished_at: iso(j.finished_at || j.updated_at),++ }));++ return { by_status: byStatus, failed: { total, last_7_days: week, recent } };++}++++// ── Missing media ────────────────────────────────────────────++++function missing(appId, limit) {++ const s = scoped(appId, 'o.app_id');++ const q = { get: (sql, p) => db.get(sql, p), all: (sql, p) => db.all(sql, p) };++ const noGood = copyReport.countNoGoodCopy(q, { appId });++ const hasVerifications = tableExists('media_verifications');++ const noGoodList = db.all(`SELECT o.id, o.app_id, o.kind, o.legacy_ref, o.size_bytes${hasVerifications ? ', v.status AS verify_status, v.verified_at' : ''}++ FROM media_objects o${hasVerifications ? ' LEFT JOIN media_verifications v ON v.object_id = o.id' : ''}++ WHERE ${copyReport.NO_GOOD_COPY_WHERE}${s.sql} ORDER BY o.app_id, o.id LIMIT ?`, [...s.params, limit])++ .map(o => ({ id: o.id, app_id: o.app_id, kind: o.kind, legacy_ref: o.legacy_ref || null, size_bytes: o.size_bytes,++ last_verification: hasVerifications && o.verify_status ? { status: o.verify_status, at: iso(o.verified_at) } : null }));++ const byState = db.all(`SELECT l.provider, l.state, COUNT(*) AS n FROM media_locations l JOIN media_objects o ON o.id = l.object_id++ WHERE o.lifecycle_status != 'deleted'${s.sql} GROUP BY l.provider, l.state ORDER BY l.provider, l.state`, s.params)++ .map(r => ({ provider: r.provider, state: r.state, count: r.n }));++ const bad = db.all(`SELECT l.object_id, l.provider, l.state, l.verified_at, o.app_id, o.kind, o.legacy_ref, o.lifecycle_status++ FROM media_locations l JOIN media_objects o ON o.id = l.object_id++ WHERE l.state IN ('missing', 'corrupt') AND o.lifecycle_status != 'deleted'${s.sql}++ ORDER BY COALESCE(l.verified_at, '') DESC, l.id DESC LIMIT ?`, [...s.params, limit])++ .map(r => ({ object_id: r.object_id, app_id: r.app_id, kind: r.kind, legacy_ref: r.legacy_ref || null, lifecycle_status: r.lifecycle_status,++ provider: r.provider, state: r.state, verified_at: iso(r.verified_at) }));++ const vs = scoped(appId);++ const quarantined = {};++ for (const r of db.all(`SELECT health_status, COUNT(*) AS n FROM vods WHERE health_status IN ('missing_file', 'zero_byte', 'corrupt', 'needs_review')${vs.sql}++ GROUP BY health_status`, vs.params)) quarantined[r.health_status] = r.n;++ const verification = { last_run: null, statuses: {} };++ if (tableExists('media_verify_runs')) {++ const r = db.get('SELECT * FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1');++ if (r) {++ verification.last_run = { id: r.id, started_at: iso(r.started_at), finished_at: iso(r.finished_at), objects_checked: r.objects_checked,++ good: r.good, no_good_copy: r.no_good_copy, unverifiable: r.unverifiable, reuploaded: r.reuploaded, reupload_failed: r.reupload_failed,++ no_good_copy_total: r.no_good_copy_total, error: clip(r.error, 300) };++ }++ }++ if (hasVerifications) {++ for (const r of db.all(`SELECT v.status, COUNT(*) AS n FROM media_verifications v JOIN media_objects o ON o.id = v.object_id WHERE 1 = 1${s.sql} GROUP BY v.status`, s.params)) {++ verification.statuses[r.status] = r.n;++ }++ }++ return {++ no_good_copy: { total: noGood, objects: noGoodList },++ locations: { by_provider_state: byState, missing_or_corrupt: bad },++ vods_quarantined: quarantined,++ verification,++ note: 'From what the database records (scheduled verification, tier moves, reconciliation). A full walk of storage is the storage.orphans.scan report; drift between rows and objects is scripts/object-drift-report.js.',++ };++}++++// ── Backfill ─────────────────────────────────────────────────++++function backfill(appId) {++ const s = scoped(appId);++ const last = require('../objects/backfill').lastReport();++ const unprojected = {++ vods: db.get(`SELECT COUNT(*) AS n FROM vods WHERE object_id IS NULL AND COALESCE(clips_only, 0) = 0${s.sql}`, s.params).n,++ clips: db.get(`SELECT COUNT(*) AS n FROM clips WHERE object_id IS NULL${s.sql}`, s.params).n,++ files: db.get(`SELECT COUNT(*) AS n FROM files WHERE object_id IS NULL${s.sql}`, s.params).n,++ screenshots: db.get(`SELECT COUNT(*) AS n FROM pastes WHERE object_id IS NULL AND type = 'screenshot' AND COALESCE(screenshot_path, '') != ''${s.sql}`, s.params).n,++ };++ const owner = db.all(`SELECT app_id, COUNT(*) AS n FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL++ AND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);++ return {++ objects: last ? {++ finished_at: iso(last.finished_at) || null, dry_run: !!last.dry_run, only_missing: !!last.only_missing,++ totals: last.totals || null, locations: last.locations || null,++ errors: Array.isArray(last.errors) ? last.errors.length : 0,++ skipped: Array.isArray(last.skipped) ? last.skipped.length : 0,++ } : null,++ unprojected,++ owner_subject: { missing: owner.reduce((a, r) => a + r.n, 0), by_app: owner.map(r => ({ app_id: r.app_id, count: r.n })) },++ namespace_reservations_seeded: !!db.get("SELECT 1 AS x FROM media_settings WHERE key = 'namespaces.reservations_seeded'"),++ note: 'objects: the last boot or scripted backfill that changed something (media_settings objects.backfill.last_report). unprojected: rows with no object yet. owner_subject: objects the owner-subject job has not resolved.',++ };++}++++// ── Tiering ──────────────────────────────────────────────────++++function tiering(appId, limit) {++ const vodStorage = require('../vod/vod-storage');++ const s = scoped(appId);++ const providers = (table, bytes) => {++ const out = {};++ for (const r of db.all(`SELECT COALESCE(storage_provider, 'local') AS p, COUNT(*) AS n${bytes ? ', COALESCE(SUM(file_size), 0) AS b' : ''}++ FROM ${table} WHERE 1 = 1${s.sql} GROUP BY p`, s.params)) out[r.p] = bytes ? { count: r.n, bytes: r.b } : { count: r.n };++ return out;++ };++ const objects = db.all(`SELECT COALESCE(canonical_provider, 'none') AS p, CASE WHEN legacy_ref IS NULL THEN 'native' ELSE 'projected' END AS origin,++ COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b++ FROM media_objects WHERE lifecycle_status != 'deleted'${s.sql} GROUP BY p, origin ORDER BY origin, p`, s.params)++ .map(r => ({ canonical_provider: r.p, origin: r.origin, count: r.n, bytes: r.b }));++ const settings = vodStorage.getSettings();++ const pendingOffload = db.get(`SELECT COUNT(*) AS c FROM vods WHERE ${vodStorage.OFFLOADABLE_WHERE}++ AND created_at <= datetime('now', ?) AND COALESCE(view_count, 0) <= ?++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))${s.sql}`,++ [`-${Number(settings.minAgeDays) || 0} days`, Number(settings.maxViewsForCold) || 0, `-${Number(settings.minLastAccessDays) || 0} days`, ...s.params]).c;++ const decisions = { promote: { done: 0, already: 0, refused: 0, failed: 0 }, demote: { done: 0, already: 0, refused: 0, failed: 0 } };++ for (const r of db.all(`SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions++ WHERE decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')${s.sql} GROUP BY action, outcome`, s.params)) {++ if (decisions[r.action]) decisions[r.action][r.outcome] = r.n;++ }++ const problems = db.all(`SELECT id, decided_at, app_id, vod_id, object_id, action, from_provider, to_provider, outcome, trigger, reason, error++ FROM media_tier_decisions WHERE outcome IN ('refused', 'failed')${s.sql} ORDER BY id DESC LIMIT ?`, [...s.params, Math.min(limit, 20)])++ .map(r => ({ ...r, reason: clip(r.reason, 300), error: clip(r.error, 300) }));++ const sweep = vodStorage.sweepInfo();++ return {++ providers: { b2: vodStorage.providerConfigured('b2'), r2: vodStorage.providerConfigured('r2') },++ policy: { enabled: !!settings.enabled, r2_enabled: !!settings.r2Enabled, min_age_days: settings.minAgeDays, max_views_for_cold: settings.maxViewsForCold, min_last_access_days: settings.minLastAccessDays },++ vods: providers('vods', true),++ clips: providers('clips', false),++ objects,++ pending_offload: pendingOffload,++ decisions_24h: decisions,++ recent_problems: problems,++ sweep: {++ running: sweep.running, started_at: sweep.startedAt, last_run_at: sweep.lastRunAt, next_run_at: sweep.nextRunAt,++ stalled: sweep.stalled, stalled_passes: sweep.stalledPasses,++ last_result: sweep.lastResult ? {++ migrated: sweep.lastResult.migrated ?? null, promoted: sweep.lastResult.promoted ?? null, demoted: sweep.lastResult.demoted ?? null,++ skipped_held: sweep.lastResult.skippedHeld ?? null, quarantined: sweep.lastResult.quarantined ?? null,++ errors: Array.isArray(sweep.lastResult.errors) ? sweep.lastResult.errors.length : 0, error: clip(sweep.lastResult.error, 300),++ timestamp: sweep.lastResult.timestamp || null,++ } : null,++ },++ note: 'Native v2 objects stay on local storage until roadmap WS-G task 11 (tiering for native objects). The sweep and the provider switches are service-wide; counts follow the scope.',++ };++}++++// ── Namespaces ───────────────────────────────────────────────++++function namespaceSnapshot(appId) {++ const s = scoped(appId);++ const apps = new Map();++ return db.all(`SELECT * FROM media_namespaces WHERE 1 = 1${s.sql} ORDER BY app_id, namespace`, s.params).map((row) => {++ if (!apps.has(row.app_id)) apps.set(row.app_id, db.getApp(row.app_id) || { app_id: row.app_id });++ return { app_id: row.app_id, ...namespaces.publicShape(apps.get(row.app_id), row) };++ });++}++++/** The whole operator report (see the header). `limit` bounds each list (1-200, default 50). */++function report({ appId = null, limit = 50 } = {}) {++ const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);++ return {++ generated_at: new Date().toISOString(),++ scope: appId || 'all',++ tenants: db.all('SELECT app_id FROM apps ORDER BY app_id').map(r => r.app_id),++ jobs: jobs(appId, n),++ missing: missing(appId, n),++ backfill: backfill(appId),++ tiering: tiering(appId, n),++ namespaces: namespaceSnapshot(appId),++ };++}++++/** Refresh the usage snapshot of every namespace (or one tenant's) from the rows: { namespaces, recomputed_at }. */++function recompute({ appId = null } = {}) {++ let count = 0;++ if (appId) {++ const app = db.getApp(appId) || { app_id: appId };++ for (const row of namespaces.listForTenant(appId)) { namespaces.reconcile(app, row.namespace); count++; }++ } else {++ count = namespaces.reconcileAll();++ }++ return { namespaces: count, scope: appId || 'all', recomputed_at: new Date().toISOString() };++}++++module.exports = { report, recompute };nnew file mode 100644@@@ -0,0 +1,429 @@++/**++ * OpenVibe.Media — the object explorer's pages (roadmap WS-G task 12): /me, /me/objects/:id, /me/ops.++ *++ * Server-rendered in the OpenVibe Frame (public/page-frame.js), complete without JavaScript: the first++ * page of objects, the usage table, filters as a GET form, pages as links, and the operator recompute++ * as a POST form. /me/app.js (client.js) only enhances /me: filters and "load more" without a reload,++ * and upload progress polled while something is uploading. Every page is noindex and private.++ * Strings from the database are escaped (esc) and never reach a script.++ */++'use strict';++++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const pageFrame = require('../public/page-frame');++const model = require('../objects/model');++++const { esc, SITE_NAME } = pageFrame;++++// The enhancement script, read once: served as /me/app.js?v=<its hash> (routes.js).++const CLIENT_PATH = path.join(__dirname, 'client.js');++const CLIENT_SOURCE = fs.readFileSync(CLIENT_PATH, 'utf8');++const CLIENT_VERSION = crypto.createHash('sha256').update(CLIENT_SOURCE).digest('hex').slice(0, 12);++++const KIND_LABEL = { vod: 'VOD', clip: 'Clip', file: 'File', thumbnail: 'Thumbnail', screenshot: 'Screenshot', avatar: 'Avatar', asset: 'Asset' };++const STATUS_LABEL = { uploading: 'Uploading', ready: 'Ready', failed: 'Failed', archived: 'Archived', deleted: 'Deleted' };++const REASON_TEXT = {++ no_object: 'no object', deleted: 'deleted', metadata_unreadable: 'metadata unreadable', failed: 'failed', archived: 'archived',++ recording: 'still recording', processing: 'still processing', verification_pending: 'copy not checked yet', no_verified_copy: 'no verified copy',++};++++function fmtBytes(n) {++ n = Number(n) || 0;++ if (n < 1024) return `${n} B`;++ const units = ['KB', 'MB', 'GB', 'TB'];++ let v = n, i = -1;++ do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);++ return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;++}++function fmtWhen(isoStr) {++ if (!isoStr) return '';++ const d = new Date(isoStr);++ return Number.isNaN(d.getTime()) ? '' : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;++}++const num = (n) => Number(n || 0).toLocaleString('en-US');++const displayName = (it) => it.title || it.filename || `${KIND_LABEL[it.kind] || it.kind} ${it.id.slice(-6)}`;++++/** A table that becomes one card per row (each cell labelled from data-label) on narrow screens. */++const stackRules = (t) => `++ ${t} thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }++ ${t}, ${t} tbody, ${t} tr, ${t} td, ${t} th { display: block; width: 100%; }++ ${t} tr { border: 1px solid var(--line); border-radius: 10px; padding: .35rem .6rem; margin: 0 0 .6rem; background: var(--panel); }++ ${t} td, ${t} th { border: 0; padding: .25rem 0; display: flex; gap: .8rem; justify-content: space-between; text-align: right; }++ ${t} td::before, ${t} th[data-label]::before { content: attr(data-label); color: var(--muted); font-weight: 400; text-align: left; flex: none; }++ ${t} td > *, ${t} th > * { min-width: 0; }`;++++const CSS = `++ main { max-width: 1240px; }++ h2 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }++ h3 { font-size: .98rem; margin: 1.1rem 0 .4rem; }++ .sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }++ .hint { color: var(--muted); font-size: .88rem; margin: 0 0 .6rem; }++ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }++ button.btn { border: 0; cursor: pointer; font: inherit; font-weight: 600; font-size: .9rem; }++ /* The accent darkened a little: white on the default blue (#3b82f6) is 3.7:1, under WCAG AA for this size. */++ .btn:not(.ghost) { background: var(--acc); background: color-mix(in srgb, var(--acc) 82%, #000); }++ .grid { width: 100%; border-collapse: collapse; font-size: .88rem; margin: 0 0 .8rem; }++ .grid th, .grid td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: break-word; }++ .grid tbody th, .grid .id, .grid .sub { overflow-wrap: anywhere; }++ .grid tbody th { font-weight: 400; }++ .grid tbody th a { font-weight: 600; }++ .grid thead th { color: var(--muted); font-weight: 600; font-size: .8rem; }++ .grid td.n, .grid th.n { text-align: right; white-space: nowrap; }++ .grid tr.total td, .grid tr.total th { font-weight: 600; }++ .id { font: 12px/1.4 ui-monospace, Menlo, monospace; color: var(--muted); display: block; }++ .sub { color: var(--muted); font-size: .8rem; display: block; }++ .tag { display: inline-block; font-size: .74rem; line-height: 1.5; padding: 0 .45rem; border-radius: 999px; border: 1px solid var(--line); margin: 0 .25rem .25rem 0; white-space: nowrap; }++ .tag.ok { border-color: color-mix(in srgb, #22c55e 55%, var(--line)); }++ .tag.no { border-style: dashed; color: var(--muted); }++ .tag.warn { border-color: color-mix(in srgb, #f59e0b 60%, var(--line)); }++ .up { display: grid; gap: .2rem; min-width: 9rem; }++ .up progress { width: 100%; height: .6rem; accent-color: var(--acc); }++ .filters { display: flex; flex-wrap: wrap; gap: .6rem .8rem; align-items: flex-end; margin: 0 0 .8rem; }++ .filters div { display: grid; gap: .2rem; }++ .filters label { font-size: .8rem; color: var(--muted); }++ .filters input, .filters select { font: inherit; font-size: .9rem; padding: .4rem .5rem; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--text); max-width: 100%; }++ .filters input { width: 16rem; }++ .filters .go { display: flex; gap: .5rem; flex-direction: row; }++ .pager { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .4rem 0 1rem; }++ .empty { color: var(--muted); }++ dl.facts { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1rem; margin: 0 0 1rem; font-size: .9rem; }++ dl.facts dt { color: var(--muted); }++ dl.facts dd { margin: 0; overflow-wrap: anywhere; }++ .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: .6rem 1rem; }++ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .9rem; }++ .card b { display: block; font-size: 1.2rem; }++ .card span { color: var(--muted); font-size: .82rem; }++ pre.err { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; font: 12px/1.4 ui-monospace, Menlo, monospace; }++ @media (max-width: 760px) {++${stackRules('.grid.stack')}++ .filters input { width: 100%; }++ .filters div { flex: 1 1 100%; }++ }++ @media (min-width: 761px) and (max-width: 1060px) {++${stackRules('.grid.stack.objects')}++ }`;++++function doc({ title, description, body, script = false }) {++ return pageFrame.page({++ seo: { title: `${title} — ${SITE_NAME}`, description, robots: 'noindex, nofollow', twitterCard: 'summary' },++ css: CSS,++ body: body + (script ? `\n<script src="/me/app.js?v=${CLIENT_VERSION}" defer></script>` : ''),++ footer: { variant: 'compact' },++ });++}++++/** Query string for /me with these filters (+ a cursor). */++function query(filters = {}, extra = {}) {++ const p = new URLSearchParams();++ for (const k of ['q', 'kind', 'status', 'app']) if (filters[k]) p.set(k, filters[k]);++ if (filters.limit && filters.limit !== 25) p.set('limit', String(filters.limit));++ for (const [k, v] of Object.entries(extra)) if (v) p.set(k, v);++ const s = p.toString();++ return s ? `?${s}` : '';++}++++// ── Signed out / not allowed ─────────────────────────────────++++function renderSignIn({ next = '/me', heading = 'Your media', reason = null } = {}) {++ const body = `++ <h1>${esc(heading)}</h1>++ <p class="note">${reason ? `${esc(reason)} ` : ''}Sign in with your OpenVibe account to see the media you own: uploads in progress, copies, derivatives, visibility and usage.</p>++ <div class="actions"><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a></div>`;++ return doc({ title: heading, description: 'Sign in to see the media you own on OpenVibe.Media.', body });++}++++function renderForbidden({ person, heading = 'Media operations', need }) {++ const body = `++ <h1>${esc(heading)}</h1>++ <p class="note">Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong>. This page is for OpenVibe operators (the staff capability <code>${esc(need)}</code>).</p>++ <div class="actions"><a class="btn ghost" href="/me">Your media</a></div>`;++ return doc({ title: heading, description: 'Operator views of OpenVibe.Media.', body });++}++++function renderDrill(heading = 'Your media') {++ return doc({ title: heading, description: 'Restore-drill instance.', body: `<h1>${esc(heading)}</h1><p class="note">This is a restore-drill instance (MEDIA_DRILL): nobody signs in here.</p>` });++}++++// ── Explorer (/me) ───────────────────────────────────────────++++function statusCell(it) {++ const out = [`<span class="st">${esc(STATUS_LABEL[it.lifecycle_status] || it.lifecycle_status)}</span>`];++ const u = it.upload;++ if (u) {++ const label = `Upload of ${displayName(it)}`;++ const bar = u.percent != null++ ? `<progress max="100" value="${u.percent}" aria-label="${esc(label)}">${u.percent}%</progress>`++ : `<progress aria-label="${esc(label)}"></progress>`;++ const words = u.waiting_for === 'complete' ? 'all bytes stored, waiting for the app to complete it'++ : u.waiting_for === 'assembly' ? 'assembling the parts'++ : `${u.percent != null ? `${u.percent}% · ` : ''}${fmtBytes(u.received_bytes)}${u.declared_bytes ? ` of ${fmtBytes(u.declared_bytes)}` : ' received'}`++ + (u.method === 'multipart' ? ` · ${u.parts_received} of ${u.parts_expected} parts` : '');++ out.push(`<div class="up" data-upload="${esc(it.id)}">${bar}<span class="sub up-text">${esc(words)}</span>${u.expires_at ? `<span class="sub">unfinished uploads fail after ${esc(fmtWhen(u.expires_at))}</span>` : ''}</div>`);++ }++ if (it.failure) out.push(`<span class="sub">${esc(it.failure.replace(/_/g, ' '))}</span>`);++ if (it.deleted) out.push(`<span class="sub">${it.deleted.purged ? 'bytes purged' : it.deleted.retention_until ? `kept until ${esc(fmtWhen(it.deleted.retention_until))}` : 'kept for the retention period'}</span>`);++ if (it.held) out.push('<span class="tag warn">Held</span>');++ return out.join(' ');++}++++function readinessCell(it) {++ const r = it.readiness || {};++ const tags = [++ r.metadata ? '<span class="tag ok">metadata</span>' : '<span class="tag no">no metadata</span>',++ r.bytes_verified ? '<span class="tag ok">bytes verified</span>' : '<span class="tag no">bytes not verified</span>',++ ];++ if (it.kind === 'vod' || it.kind === 'clip') {++ tags.push(r.playable ? '<span class="tag ok">playable</span>' : `<span class="tag no">not playable${r.reason && REASON_TEXT[r.reason] ? `: ${esc(REASON_TEXT[r.reason])}` : ''}</span>`);++ }++ return tags.join('');++}++++function objectRow(it) {++ const name = displayName(it);++ return `<tr data-id="${esc(it.id)}" data-status="${esc(it.lifecycle_status)}"${it.upload ? ' data-uploading="1"' : ''}>++ <th scope="row" data-label="Name"><span><a href="/me/objects/${esc(it.id)}">${esc(name)}</a><span class="id">${esc(it.id)}</span>${it.mime_type ? `<span class="sub">${esc(it.mime_type)}</span>` : ''}</span></th>++ <td data-label="Kind">${esc(KIND_LABEL[it.kind] || it.kind)}</td>++ <td data-label="Tenant"><span>${esc(it.tenant.name)}${it.tenant.sandbox ? ' <span class="tag">sandbox</span>' : ''}<span class="sub">${esc(it.namespace)}</span></span></td>++ <td data-label="Size" class="n">${esc(fmtBytes(it.size_bytes))}</td>++ <td data-label="Visibility"><span>${esc(it.visibility)}${it.public_url ? ` <a href="${esc(it.public_url)}">open<span class="sr"> ${esc(name)}</span></a>` : ''}</span></td>++ <td data-label="Status"><span>${statusCell(it)}</span></td>++ <td data-label="Readiness"><span>${readinessCell(it)}</span></td>++ <td data-label="Derivatives" class="n">${num(it.derivatives)}</td>++ <td data-label="Created"><span>${esc(fmtWhen(it.created_at))}</span></td>++ </tr>`;++}++++function usageTable(usage) {++ if (!usage.tenants.length) return '<p class="empty">You own no media objects yet.</p>';++ const byStatus = (g, s) => g.by_status[s] || { objects: 0, bytes: 0 };++ const cells = (g) => `<td data-label="Objects" class="n">${num(g.objects)}</td>++ <td data-label="Stored" class="n">${esc(fmtBytes(g.stored_bytes))}</td>++ <td data-label="Uploading" class="n">${num(byStatus(g, 'uploading').objects)}${byStatus(g, 'uploading').objects ? ` · ${esc(fmtBytes(byStatus(g, 'uploading').bytes))}` : ''}</td>++ <td data-label="Failed" class="n">${num(byStatus(g, 'failed').objects)}</td>++ <td data-label="Deleted" class="n">${num(byStatus(g, 'deleted').objects)}${byStatus(g, 'deleted').objects ? ` · ${esc(fmtBytes(byStatus(g, 'deleted').bytes))}` : ''}</td>`;++ const rows = [];++ for (const t of usage.tenants) {++ for (const ns of t.namespaces) {++ rows.push(`<tr><th scope="row" data-label="Namespace"><span>${esc(t.name)}${t.sandbox ? ' <span class="tag">sandbox</span>' : ''}<span class="sub">${esc(ns.namespace)}</span></span></th>${cells(ns)}</tr>`);++ }++ if (t.namespaces.length > 1) rows.push(`<tr class="total"><th scope="row" data-label="Tenant">${esc(t.name)}, all namespaces</th>${cells(t)}</tr>`);++ }++ if (usage.tenants.length > 1) rows.push(`<tr class="total"><th scope="row" data-label="Total">All tenants</th>${cells(usage.totals)}</tr>`);++ return `<table class="grid stack usage">++ <caption class="sr">Your objects and bytes per tenant and namespace</caption>++ <thead><tr><th scope="col">Tenant and namespace</th><th scope="col" class="n">Objects</th><th scope="col" class="n">Stored</th><th scope="col" class="n">Uploading</th><th scope="col" class="n">Failed</th><th scope="col" class="n">Deleted, kept until purge</th></tr></thead>++ <tbody>${rows.join('\n')}</tbody>++ </table>`;++}++++function filtersForm(filters, tenants) {++ const opt = (v, label, cur) => `<option value="${esc(v)}"${String(cur || '') === String(v) ? ' selected' : ''}>${esc(label)}</option>`;++ return `<form class="filters" id="me-filters" method="get" action="/me" role="search" aria-label="Filter your objects">++ <div><label for="f-q">Search</label><input id="f-q" name="q" type="search" maxlength="100" value="${esc(filters.q || '')}" placeholder="Name, title, type or med_ id"></div>++ <div><label for="f-kind">Kind</label><select id="f-kind" name="kind">${opt('', 'Any kind', filters.kind)}${model.KINDS.map(k => opt(k, KIND_LABEL[k] || k, filters.kind)).join('')}</select></div>++ <div><label for="f-status">Status</label><select id="f-status" name="status">${opt('', 'Not deleted', filters.status)}${model.LIFECYCLES.map(s => opt(s, STATUS_LABEL[s] || s, filters.status)).join('')}${opt('all', 'All, deleted included', filters.status)}</select></div>++ <div><label for="f-app">Tenant</label><select id="f-app" name="app">${opt('', 'All tenants', filters.app)}${tenants.map(t => opt(t.app_id, t.name, filters.app)).join('')}</select></div>++ <div class="go"><button type="submit" class="btn">Apply</button><a class="btn ghost" href="/me">Clear</a></div>++ </form>`;++}++++function objectsTable(items) {++ return `<table class="grid stack objects" id="me-objects"${items.length ? '' : ' hidden'}>++ <caption class="sr">Your objects, newest first</caption>++ <thead><tr><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Tenant and namespace</th><th scope="col" class="n">Size</th><th scope="col">Visibility</th><th scope="col">Status</th><th scope="col">Readiness</th><th scope="col" class="n">Derivatives</th><th scope="col">Created</th></tr></thead>++ <tbody>${items.map(objectRow).join('\n')}</tbody>++ </table>`;++}++++function renderExplorer({ person, list, usage, filters, canOps = false }) {++ const who = person.username ? `@${person.username}` : person.subject;++ const tenants = usage.tenants.map(t => ({ app_id: t.app_id, name: t.name }));++ if (filters.app && !tenants.some(t => t.app_id === filters.app)) tenants.push({ app_id: filters.app, name: filters.app });++ const firstHref = `/me${query(filters)}`;++ const nextHref = list.next_cursor ? `/me${query(filters, { cursor: list.next_cursor })}` : null;++ const filtered = !!(filters.q || filters.kind || filters.status || filters.app);++ const body = `++ <h1>Your media</h1>++ <p class="meta"><span>Signed in as <strong>${esc(who)}</strong></span><span aria-hidden="true">·</span><span>objects you own, in every app that stores media here</span>${canOps ? '<span aria-hidden="true">·</span><a href="/me/ops">Operator views</a>' : ''}</p>++ <p class="note" id="me-readonly"><strong>Read-only.</strong> Change an object's visibility, delete it or restore it in the app that stored it (its tenant below): Media's object API takes those changes from the app, not from your sign-in.</p>++ <section aria-labelledby="usage-h">++ <h2 id="usage-h">Usage</h2>++ <p class="hint">${esc(usage.note)} Uploading counts the declared sizes; deleted objects are kept for the retention period before their bytes are purged.</p>++ ${usageTable(usage)}++ </section>++ <section aria-labelledby="objects-h">++ <h2 id="objects-h">Objects</h2>++ ${filtersForm(filters, tenants)}++ <p id="me-status" class="hint" role="status" aria-live="polite">${list.objects.length ? `${num(list.objects.length)} object${list.objects.length === 1 ? '' : 's'} shown, newest first.` : ''}</p>++ ${objectsTable(list.objects)}++ <p class="empty" id="me-empty"${list.objects.length ? ' hidden' : ''}>${filtered ? 'No objects match these filters.' : 'No objects yet.'}</p>++ <nav class="pager" aria-label="Pages of objects">++ ${filters.cursor ? `<a class="btn ghost" href="${esc(firstHref)}">Newest</a>` : ''}++ <a class="btn ghost" id="me-next" rel="next" href="${esc(nextHref || firstHref)}"${nextHref ? '' : ' hidden'}>Next page</a>++ </nav>++ </section>`;++ return doc({ title: 'Your media', description: 'The media you own on OpenVibe.Media: uploads, copies, derivatives, visibility and usage.', body, script: true });++}++++// ── Detail (/me/objects/:id) ─────────────────────────────────++++function table(caption, head, rows, { stack = true, numeric = [] } = {}) {++ if (!rows.length) return '';++ const th = head.map((h, i) => `<th scope="col"${numeric.includes(i) ? ' class="n"' : ''}>${esc(h)}</th>`).join('');++ const tr = rows.map(r => `<tr>${r.map((c, i) => `<td data-label="${esc(head[i])}"${numeric.includes(i) ? ' class="n"' : ''}>${c}</td>`).join('')}</tr>`).join('\n');++ return `<table class="grid${stack ? ' stack' : ''}"><caption class="sr">${esc(caption)}</caption><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;++}++++function renderDetail({ person, obj }) {++ const name = displayName(obj);++ const facts = [++ ['Id', `<code>${esc(obj.id)}</code>`],++ ['Kind', esc(KIND_LABEL[obj.kind] || obj.kind)],++ ['Tenant', `${esc(obj.tenant.name)} <span class="sub">${esc(obj.app_id)}${obj.tenant.sandbox ? ', sandbox' : ''}</span>`],++ ['Namespace', esc(obj.namespace)],++ ['File name', obj.filename ? esc(obj.filename) : '<span class="sub">none</span>'],++ ['Type', esc(obj.mime_type || 'unknown')],++ ['Size', `${esc(fmtBytes(obj.size_bytes))} <span class="sub">${num(obj.size_bytes)} bytes</span>`],++ ['Visibility', `${esc(obj.visibility)}${obj.public_url ? ` · <a href="${esc(obj.public_url)}">open</a>` : ''}`],++ ['Status', statusCell(obj)],++ ['Readiness', readinessCell(obj)],++ ['SHA-256', obj.content_hash ? `<code class="id">${esc(obj.content_hash)}</code>` : '<span class="sub">not computed yet</span>'],++ ['Written through', obj.managed_by === 'v1' ? 'the app\'s v1 API (a VOD, clip, file or screenshot row)' : 'the object API (v2)'],++ ['Created', esc(fmtWhen(obj.created_at))],++ ['Updated', esc(fmtWhen(obj.updated_at))],++ ];++ if (obj.source) facts.push(['Made from', `<a href="/me/objects/${esc(obj.source.id)}">${esc(obj.source.id)}</a> <span class="sub">${esc(obj.source.relation.replace(/_/g, ' '))}</span>`]);++ const body = `++ <p><a href="/me">← Your media</a></p>++ <h1>${esc(name)}</h1>++ <p class="meta"><span>Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong></span></p>++ <dl class="facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>++ <section aria-labelledby="copies-h">++ <h2 id="copies-h">Copies</h2>++ ${table('Where the bytes are', ['Provider', 'Class', 'State', 'Size', 'Checked'], obj.locations.map(l => [++ `${esc(l.provider)}${l.canonical ? ' <span class="tag">canonical</span>' : ''}`, esc(l.storage_class || ''), esc(l.state), esc(fmtBytes(l.size_bytes)), esc(fmtWhen(l.verified_at) || 'not yet'),++ ]), { numeric: [3] }) || '<p class="empty">No stored copy.</p>'}++ </section>++ <section aria-labelledby="derived-h">++ <h2 id="derived-h">Derivatives</h2>++ ${table('Objects made from this one', ['Object', 'Made as', 'Kind', 'Status', 'Size'], obj.derivative_list.map(d => [++ d.mine ? `<a href="/me/objects/${esc(d.id)}">${esc(d.id)}</a>` : `<code class="id">${esc(d.id)}</code>`, esc(d.name), esc(KIND_LABEL[d.kind] || d.kind), esc(STATUS_LABEL[d.lifecycle_status] || d.lifecycle_status), esc(fmtBytes(d.size_bytes)),++ ]), { numeric: [4] }) || '<p class="empty">None.</p>'}++ </section>++ <section aria-labelledby="jobs-h">++ <h2 id="jobs-h">Recent jobs</h2>++ ${table('Jobs on this object, newest first', ['Job', 'Status', 'Error', 'Attempts', 'Created', 'Finished'], obj.jobs.map(j => [++ esc(j.type), esc(j.status), esc(j.error_code || ''), `${num(j.attempts)} of ${num(j.max_attempts)}`, esc(fmtWhen(j.created_at)), esc(fmtWhen(j.finished_at)),++ ])) || '<p class="empty">None.</p>'}++ </section>++ <p class="note">Read-only: visibility, deletion and restore are changed in the app that stored this object.</p>`;++ return doc({ title: name, description: 'One of your media objects on OpenVibe.Media.', body, script: !!obj.upload });++}++++// ── Operator views (/me/ops) ─────────────────────────────────++++function renderOps({ person, report, canRecompute, recomputed = null }) {++ const r = report;++ const opt = (v, label) => `<option value="${esc(v)}"${(r.scope === 'all' ? '' : r.scope) === v ? ' selected' : ''}>${esc(label)}</option>`;++ const scopeQs = r.scope === 'all' ? '' : `?app=${encodeURIComponent(r.scope)}`;++ const card = (value, label) => `<div class="card"><b>${value}</b><span>${esc(label)}</span></div>`;++ const kv = (obj) => Object.entries(obj || {}).map(([k, v]) => `${esc(k.replace(/_/g, ' '))} ${num(v)}`).join(' · ') || 'none';++ const t = r.tiering;++ const bf = r.backfill;++ const m = r.missing;++ const body = `++ <h1>Media operations</h1>++ <p class="meta"><span>Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong></span><span aria-hidden="true">·</span><span>generated ${esc(fmtWhen(r.generated_at))}</span></p>++ <form class="filters" method="get" action="/me/ops" aria-label="Scope">++ <div><label for="ops-app">Tenant</label><select id="ops-app" name="app">${opt('', 'All tenants')}${r.tenants.map(a => opt(a, a)).join('')}</select></div>++ <div class="go"><button type="submit" class="btn">Show</button></div>++ </form>++ ${recomputed ? `<p class="note" role="status">Usage recomputed for ${num(recomputed)} namespace${recomputed === '1' ? '' : 's'}.</p>` : ''}++ <div class="cols">++ ${card(num(r.jobs.failed.total), 'failed jobs')}++ ${card(num(m.no_good_copy.total), 'ready objects with no good copy')}++ ${card(num(m.locations.missing_or_corrupt.length), 'missing or corrupt copies listed')}++ ${card(num(Object.values(bf.unprojected).reduce((a, b) => a + b, 0)), 'rows without an object')}++ ${card(num(t.pending_offload), 'VODs eligible to offload')}++ </div>++++ <section aria-labelledby="jobs-h">++ <h2 id="jobs-h">Failed jobs</h2>++ <p class="hint">Jobs by status: ${kv(r.jobs.by_status)}.</p>++ <h3>Last 7 days by type and error</h3>++ ${table('Failed jobs in the last 7 days', ['Type', 'Error code', 'Jobs'], r.jobs.failed.last_7_days.map(x => [esc(x.type), esc(x.error_code || 'none'), num(x.count)]), { numeric: [2] }) || '<p class="empty">None.</p>'}++ <h3>Most recent</h3>++ ${table('Most recent failed jobs', ['Finished', 'Tenant', 'Job', 'Error', 'Attempts', 'Object'], r.jobs.failed.recent.map(j => [++ esc(fmtWhen(j.finished_at)), esc(j.app_id), `${esc(j.type)}<span class="id">${esc(j.id)}</span>`,++ `${j.error_code ? `<code>${esc(j.error_code)}</code>` : ''}${j.error ? `<pre class="err">${esc(j.error)}</pre>` : ''}`,++ `${num(j.attempts)} of ${num(j.max_attempts)}`, j.object_id ? `<code class="id">${esc(j.object_id)}</code>` : '',++ ])) || '<p class="empty">None.</p>'}++ </section>++++ <section aria-labelledby="usage-h">++ <h2 id="usage-h">Usage</h2>++ <p class="hint">The namespaces' usage snapshot (refreshed at complete, delete and restore, when read, and hourly). Checks always count from the rows.</p>++ ${canRecompute ? `<form method="post" action="/me/ops/recompute${scopeQs}"><button type="submit" class="btn">Recompute usage${r.scope === 'all' ? '' : ` of ${esc(r.scope)}`} now</button></form>` : '<p class="hint">Recomputing needs the staff capability <code>staff.site.configure</code>.</p>'}++ ${table('Namespaces', ['Tenant', 'Namespace', 'Quota', 'Used', 'Reserved', 'Snapshot'], r.namespaces.map(n => [++ esc(n.app_id), esc(n.namespace),++ `${n.quota.bytes ? esc(fmtBytes(n.quota.bytes)) : 'no byte limit'}${n.quota.objects ? ` · ${num(n.quota.objects)} objects` : ''}`,++ `${esc(fmtBytes(n.usage.used_bytes))} · ${num(n.usage.used_objects)}`, `${esc(fmtBytes(n.usage.reserved_bytes))} · ${num(n.usage.reserved_objects)}`,++ esc(fmtWhen(n.usage.reconciled_at) || 'never'),++ ])) || '<p class="empty">No namespaces.</p>'}++ </section>++++ <section aria-labelledby="missing-h">++ <h2 id="missing-h">Missing media</h2>++ <p class="hint">${esc(m.note)}</p>++ <p>Quarantined VODs: ${kv(m.vods_quarantined)}. Verification: ${kv(m.verification.statuses)}${m.verification.last_run ? `; last run ${esc(fmtWhen(m.verification.last_run.finished_at))}, ${num(m.verification.last_run.objects_checked)} checked, ${num(m.verification.last_run.no_good_copy)} with no good copy` : '; no run yet'}.</p>++ <h3>No good copy</h3>++ ${table('Ready objects with no good copy', ['Object', 'Tenant', 'Kind', 'Size', 'Last verification'], m.no_good_copy.objects.map(o => [++ `<code class="id">${esc(o.id)}</code>${o.legacy_ref ? `<span class="sub">${esc(o.legacy_ref)}</span>` : ''}`, esc(o.app_id), esc(o.kind), esc(fmtBytes(o.size_bytes)),++ o.last_verification ? `${esc(o.last_verification.status)} ${esc(fmtWhen(o.last_verification.at))}` : 'never',++ ]), { numeric: [3] }) || '<p class="empty">None.</p>'}++ <h3>Missing or corrupt copies</h3>++ ${table('Copies recorded missing or corrupt', ['Object', 'Tenant', 'Kind', 'Provider', 'State', 'Checked'], m.locations.missing_or_corrupt.map(l => [++ `<code class="id">${esc(l.object_id)}</code>${l.legacy_ref ? `<span class="sub">${esc(l.legacy_ref)}</span>` : ''}`, esc(l.app_id), esc(l.kind), esc(l.provider), esc(l.state), esc(fmtWhen(l.verified_at)),++ ])) || '<p class="empty">None.</p>'}++ <h3>Copies by provider and state</h3>++ ${table('Copies by provider and state', ['Provider', 'State', 'Copies'], m.locations.by_provider_state.map(x => [esc(x.provider), esc(x.state), num(x.count)]), { numeric: [2] }) || '<p class="empty">None.</p>'}++ </section>++++ <section aria-labelledby="backfill-h">++ <h2 id="backfill-h">Backfill</h2>++ <p class="hint">${esc(bf.note)}</p>++ <dl class="facts">++ <dt>Last object backfill</dt><dd>${bf.objects ? `${esc(fmtWhen(bf.objects.finished_at))}${bf.objects.dry_run ? ' (dry run)' : ''}: ${bf.objects.totals ? kv(bf.objects.totals) : 'no totals'}; ${num(bf.objects.errors)} errors, ${num(bf.objects.skipped)} skipped` : 'none recorded'}</dd>++ <dt>Rows without an object</dt><dd>${kv(bf.unprojected)}</dd>++ <dt>Owner subject unresolved</dt><dd>${num(bf.owner_subject.missing)}${bf.owner_subject.by_app.length ? ` (${bf.owner_subject.by_app.map(a => `${esc(a.app_id)} ${num(a.count)}`).join(', ')})` : ''}</dd>++ <dt>Upload reservations seeded</dt><dd>${bf.namespace_reservations_seeded ? 'yes' : 'no'}</dd>++ </dl>++ </section>++++ <section aria-labelledby="tiering-h">++ <h2 id="tiering-h">Tiering</h2>++ <p class="hint">${esc(t.note)}</p>++ <dl class="facts">++ <dt>Providers</dt><dd>B2 ${t.providers.b2 ? 'configured' : 'not configured'} · R2 ${t.providers.r2 ? 'configured' : 'not configured'}</dd>++ <dt>Policy</dt><dd>offload ${t.policy.enabled ? 'on' : 'off'} (age ${esc(t.policy.min_age_days)} d, at most ${esc(t.policy.max_views_for_cold)} views, idle ${esc(t.policy.min_last_access_days)} d) · R2 ${t.policy.r2_enabled ? 'on' : 'off'}</dd>++ <dt>Sweep</dt><dd>${t.sweep.running ? 'running' : 'idle'}${t.sweep.stalled ? ` · <strong>stalled</strong> (${num(t.sweep.stalled_passes)} passes)` : ''}; last ${esc(fmtWhen(t.sweep.last_run_at) || 'not in this process')}${t.sweep.next_run_at ? `; next ${esc(fmtWhen(t.sweep.next_run_at))}` : ''}${t.sweep.last_result ? `; moved ${num(t.sweep.last_result.migrated)} to B2, ${num(t.sweep.last_result.promoted)} to R2, ${num(t.sweep.last_result.demoted)} back, ${num(t.sweep.last_result.errors)} errors` : ''}</dd>++ <dt>Eligible to offload</dt><dd>${num(t.pending_offload)} VODs</dd>++ <dt>R2 decisions, 24 h</dt><dd>promote: ${kv(t.decisions_24h.promote)} · demote: ${kv(t.decisions_24h.demote)}</dd>++ </dl>++ ${table('VODs by provider', ['Provider', 'VODs', 'Bytes'], Object.entries(t.vods).map(([p, v]) => [esc(p), num(v.count), esc(fmtBytes(v.bytes))]), { numeric: [1, 2] })}++ ${table('Objects by canonical copy', ['Origin', 'Canonical copy', 'Objects', 'Bytes'], t.objects.map(o => [esc(o.origin), esc(o.canonical_provider), num(o.count), esc(fmtBytes(o.bytes))]), { numeric: [2, 3] })}++ <h3>Recent refused or failed moves</h3>++ ${table('Recent refused or failed tier moves', ['When', 'Tenant', 'VOD', 'Move', 'Outcome', 'Why'], t.recent_problems.map(d => [++ esc(fmtWhen(d.decided_at)), esc(d.app_id || ''), esc(d.vod_id), `${esc(d.action)} ${esc(d.from_provider || '')} → ${esc(d.to_provider || '')}`, esc(d.outcome), esc(d.error || d.reason || ''),++ ])) || '<p class="empty">None.</p>'}++ </section>++ <p class="hint">JSON: <a href="/api/v2/me/ops${scopeQs}">/api/v2/me/ops</a>.</p>`;++ return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill and tiering.', body });++}++++module.exports = {++ renderSignIn, renderForbidden, renderDrill, renderExplorer, renderDetail, renderOps,++ fmtBytes, fmtWhen, displayName, CLIENT_SOURCE, CLIENT_VERSION,++};nnew file mode 100644@@@ -0,0 +1,248 @@++/**++ * OpenVibe.Media — the object explorer (roadmap WS-G task 12; docs/object-model.md#object-explorer).++ *++ * The person signed in to openvibe.media (the Network access token: the ov_token cookie, or a bearer++ * token) sees the media objects whose owner_subject is their Network subject (usr_…), in every tenant:++ *++ * GET /api/v2/me/objects ?cursor&limit (≤100)&status (a lifecycle, or all)&kind&q&app → { objects, next_cursor, limit }++ * GET /api/v2/me/objects/:id one of them: copies, derivatives, recent jobs (404 when it is not theirs)++ * GET /api/v2/me/usage their objects and bytes per tenant and namespace (no tenant's quota)++ * GET /me, /me/objects/:id the same as pages (complete without JavaScript; /me/app.js enhances /me)++ *++ * Read-only: visibility, delete and restore stay with the apps. The object API takes them only from a++ * tenant credential (tenantAuth answers a user JWT 403), so the explorer does not act for a person.++ *++ * Operator views, for Network staff (contracts staff map, ADR-022): staff.site.view reads,++ * staff.site.configure recomputes (POST, same-origin only when the credential is the cookie):++ *++ * GET /api/v2/me/ops[?app] failed jobs, missing media, backfill, tiering, namespaces (me/ops.js)++ * POST /api/v2/me/ops/recompute[?app] refresh the namespaces' usage snapshot++ * GET /me/ops, POST /me/ops/recompute the page and its form++ *++ * The app-key twin of the operator report is GET/POST /api/v1/:app/admin/storage/ops (admin/routes.js).++ * Tokens that are not a person's access token for this site are refused: service and app principals,++ * FedCM assertions, and tokens whose audience does not include openvibe.media. Every answer is private,++ * no-store and noindex. A restore drill (MEDIA_DRILL) signs nobody in: the API answers 503.++ */++'use strict';++++const express = require('express');++const { http, ids, staff } = require('openvibe-contracts');++const config = require('../config');++const drill = require('../drill');++const { extractToken } = require('../user-auth');++const explorer = require('./explorer');++const pages = require('./pages');++++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const SITE_ORIGIN = (() => { try { return new URL(config.publicUrl).origin; } catch { return null; } })();++++function problem(res, status, code, detail) {++ return http.sendProblem(res, status, code, { detail });++}++++function privateHeaders(_req, res, next) {++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Robots-Tag', 'noindex, nofollow');++ res.set('X-Content-Type-Options', 'nosniff');++ next();++}++++/**++ * The person a verified token names, or { error }: a Network access token for this site (audience++ * openvibe.media), a person (not a service or app principal, not a FedCM assertion), with a subject.++ */++function personFromClaims(claims) {++ if (!claims) return { error: 'invalid' };++ if (PRINCIPAL_SUB.test(String(claims.sub || '')) || claims.actor_type === 'service' || claims.actor_type === 'app') return { error: 'invalid' };++ if (claims.typ === 'fedcm') return { error: 'invalid' };++ const aud = Array.isArray(claims.aud) ? claims.aud : (claims.aud ? [claims.aud] : []);++ if (!aud.includes('openvibe.media')) return { error: 'invalid' };++ if (!ids.isSubjectId('user', claims.subject_id)) return { error: 'no_subject' };++ return {++ person: {++ subject: claims.subject_id,++ username: typeof claims.username === 'string' ? claims.username : null,++ display_name: typeof claims.display_name === 'string' ? claims.display_name : null,++ claims,++ },++ };++}++++/** req.person (or req.personError: none | invalid | no_subject | drill) and req.viaCookie. Never blocks. */++function identify(auth) {++ return async (req, _res, next) => {++ req.person = null;++ if (drill.enabled) { req.personError = 'drill'; return next(); }++ const token = extractToken(req);++ if (!token) { req.personError = 'none'; return next(); }++ req.viaCookie = !(req.headers.authorization && req.headers.authorization.startsWith('Bearer '));++ let claims = null;++ try { claims = await auth.verify(token); } catch { claims = null; }++ const r = personFromClaims(claims);++ if (r.person) req.person = r.person; else req.personError = r.error;++ next();++ };++}++++/** A cookie-borne write must come from this site's own pages (the cookie is SameSite=Lax as well). */++function sameOrigin(req) {++ if (!req.viaCookie) return true;++ const site = String(req.headers['sec-fetch-site'] || '');++ if (site) return site === 'same-origin';++ const origin = String(req.headers.origin || '');++ return !!origin && origin === SITE_ORIGIN;++}++++const can = (person, capability) => {++ try { return !!person && staff.can(person.claims, capability); } catch { return false; }++};++++/** 401/503 problems for an API caller who is not a signed-in person. */++function needPerson(req, res) {++ if (req.person) return true;++ if (req.personError === 'drill') problem(res, 503, 'media.drill_no_sign_in', 'This is a restore-drill instance (MEDIA_DRILL): no sign-in');++ else if (req.personError === 'no_subject') problem(res, 401, 'auth.no_subject', 'Your sign-in carries no network subject: sign in again');++ else if (req.personError === 'invalid') problem(res, 401, 'auth.invalid', 'Invalid or expired token');++ else problem(res, 401, 'auth.required', 'Sign in to see your media');++ return false;++}++++function opsScope(req) {++ const app = req.query.app;++ if (app == null || app === '') return { appId: null };++ return /^[A-Za-z0-9_.-]{1,80}$/.test(String(app)) ? { appId: String(app) } : { error: 'Bad app' };++}++++/**++ * { api, pages }: `api` is mounted at /api/v2/me (ahead of /api/v2/:app/…, so "me" is never a tenant++ * there), `pages` at /me. `auth` is user-auth.createAuthClient's (verify(token) → claims | null).++ */++function createMeRoutes({ auth }) {++ const api = express.Router();++ api.use(privateHeaders, identify(auth));++++ api.get('/objects', (req, res) => {++ if (!needPerson(req, res)) return;++ const f = explorer.parseFilters(req.query);++ if (f.error) return problem(res, 400, 'media.me.invalid', f.error);++ try {++ res.json(explorer.list(req.person.subject, f.filters));++ } catch (err) {++ console.error('[Me] list error:', err.message);++ problem(res, 500, 'media.me.list_failed', 'Failed to list your objects');++ }++ });++++ api.get('/objects/:id', (req, res) => {++ if (!needPerson(req, res)) return;++ const obj = explorer.detail(req.person.subject, req.params.id);++ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object of yours');++ res.json(obj);++ });++++ api.get('/usage', (req, res) => {++ if (!needPerson(req, res)) return;++ try {++ res.json(explorer.usage(req.person.subject));++ } catch (err) {++ console.error('[Me] usage error:', err.message);++ problem(res, 500, 'media.me.usage_failed', 'Failed to count your usage');++ }++ });++++ api.get('/ops', (req, res) => {++ if (!needPerson(req, res)) return;++ if (!can(req.person, 'staff.site.view')) return problem(res, 403, 'capability.denied', 'staff.site.view not granted');++ const s = opsScope(req);++ if (s.error) return problem(res, 400, 'media.me.invalid', s.error);++ try {++ res.json(require('./ops').report({ appId: s.appId, limit: req.query.limit }));++ } catch (err) {++ console.error('[Me] ops report error:', err.message);++ problem(res, 500, 'media.ops.report_failed', 'Failed to build the operator report');++ }++ });++++ api.post('/ops/recompute', (req, res) => {++ if (!needPerson(req, res)) return;++ if (!can(req.person, 'staff.site.configure')) return problem(res, 403, 'capability.denied', 'staff.site.configure not granted');++ if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');++ const s = opsScope(req);++ if (s.error) return problem(res, 400, 'media.me.invalid', s.error);++ const out = require('./ops').recompute({ appId: s.appId });++ console.log(`[Me] Namespace usage recomputed by ${req.person.subject} (${out.scope}): ${out.namespaces} namespace(s)`);++ res.json(out);++ });++++ api.use((req, res) => problem(res, 404, 'media.not_found', 'No such route'));++++ // ── Pages ────────────────────────────────────────────────++ const site = express.Router();++ site.use(privateHeaders);++++ // The enhancement script (client.js). Versioned by content: a matching ?v= is cached for a year.++ site.get('/app.js', (req, res) => {++ res.set('Cache-Control', req.query.v === pages.CLIENT_VERSION ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ res.set('X-Content-Type-Options', 'nosniff');++ res.type('application/javascript').send(pages.CLIENT_SOURCE);++ });++++ site.use(identify(auth));++ const html = (res, status, body) => res.status(status).type('html').send(body);++++ /** The page for someone who is not (validly) signed in: the sign-in prompt, or the drill notice. */++ function signedOut(req, res, heading, next) {++ if (req.personError === 'drill') return html(res, 503, pages.renderDrill(heading));++ const reason = req.personError === 'no_subject' ? 'Your sign-in carries no network subject yet: sign in again.'++ : req.personError === 'invalid' ? 'Your sign-in has expired.' : null;++ return html(res, 200, pages.renderSignIn({ next, heading, reason }));++ }++++ site.get('/', (req, res) => {++ if (!req.person) return signedOut(req, res, 'Your media', '/me');++ const f = explorer.parseFilters(req.query);++ const filters = f.error ? explorer.parseFilters({}).filters : f.filters;++ try {++ html(res, f.error ? 400 : 200, pages.renderExplorer({++ person: req.person, filters, list: explorer.list(req.person.subject, filters), usage: explorer.usage(req.person.subject),++ canOps: can(req.person, 'staff.site.view'),++ }));++ } catch (err) {++ console.error('[Me] page error:', err.message);++ html(res, 500, pages.renderSignIn({ heading: 'Your media', reason: 'Something went wrong loading your media.' }));++ }++ });++++ site.get('/objects/:id', (req, res, next) => {++ if (!req.person) return signedOut(req, res, 'Your media', `/me/objects/${encodeURIComponent(req.params.id)}`);++ const obj = explorer.detail(req.person.subject, req.params.id);++ if (!obj) return next(); // the site's own not-found, exactly as for an id that does not exist++ html(res, 200, pages.renderDetail({ person: req.person, obj }));++ });++++ site.get('/ops', (req, res) => {++ if (!req.person) return signedOut(req, res, 'Media operations', '/me/ops');++ if (!can(req.person, 'staff.site.view')) return html(res, 403, pages.renderForbidden({ person: req.person, need: 'staff.site.view' }));++ const s = opsScope(req);++ html(res, s.error ? 400 : 200, pages.renderOps({++ person: req.person, report: require('./ops').report({ appId: s.error ? null : s.appId }), canRecompute: can(req.person, 'staff.site.configure'),++ recomputed: /^\d+$/.test(String(req.query.recomputed || '')) ? String(req.query.recomputed) : null,++ }));++ });++++ site.post('/ops/recompute', (req, res) => {++ if (!req.person) return signedOut(req, res, 'Media operations', '/me/ops');++ if (!can(req.person, 'staff.site.configure')) return html(res, 403, pages.renderForbidden({ person: req.person, need: 'staff.site.configure' }));++ if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');++ const s = opsScope(req);++ const out = require('./ops').recompute({ appId: s.error ? null : s.appId });++ console.log(`[Me] Namespace usage recomputed by ${req.person.subject} (${out.scope}): ${out.namespaces} namespace(s)`);++ const qs = new URLSearchParams({ ...(s.appId ? { app: s.appId } : {}), recomputed: String(out.namespaces) });++ res.redirect(303, `/me/ops?${qs}`);++ });++++ return { api, pages: site };++}++++module.exports = { createMeRoutes, personFromClaims, sameOrigin };@@@ -68,8 +68,9 @@ function sitemapEntries() {function robotsTxt() {return seo.robotsTxt({sitemaps: [`${config.publicUrl}/sitemap.xml`],-- // APIs, sign-in, signed object bytes, operator and dev-data endpoints are not pages.-- disallow: ['/api/', '/auth/', '/internal/', '/o/', '/metrics', '/release-metrics', '/live/'],++ // APIs, sign-in, signed object bytes, operator and dev-data endpoints are not pages; /me is a++ // signed-in person's own media (noindex as well).++ disallow: ['/api/', '/auth/', '/internal/', '/o/', '/metrics', '/release-metrics', '/live/', '/me'],});}@@@ -138,6 +138,8 @@ function baseCss() {function frameScripts({ history, footer } = {}) {const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, logoutUrl: '/auth/logout?next={path}', sessionUrl: '/auth/me' };if (history) navOpts.history = history;++ // The account menu's section for this site: the signed-in person's own media (server/me/, WS-G task 12).++ navOpts.menu = { before: [{ id: 'media-mine', label: 'Your media', href: '/me', icon: 'fa-photo-film' }] };const footOpts = {service: 'media',variant: (footer && footer.variant) || 'compact',@@@ -1333,6 +1333,23 @@ async function probeProvider(name) {}}++/**++ * The sweep's state alone, without the local directory walk getStatus() makes (the operator views,++ * server/me/ops.js, read it on every page load).++ */++function sweepInfo() {++ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);++ return {++ running: sweepRunning,++ startedAt: iso(sweepState.startedAt),++ lastRunAt: iso(sweepState.lastRunAt),++ nextRunAt: iso(sweepState.nextRunAt),++ lastResult: sweepState.lastResult,++ stalled: sweepState.stalled,++ stalledPasses: sweepState.stalledPasses,++ };++}++function getStatus() {const settings = getSettings();const localDisk = diskUsage(config.vod.path);@@@ -1432,6 +1449,7 @@ module.exports = {start,stop,getStatus,++ sweepInfo,bucketStatus,probeProvider,getBucketUsage,@@@ -280,6 +280,12 @@ const safe = { DB_PATH, HOST: '127.0.0.1', PORT: '14100' };}const login = await request('GET', '/auth/login');assert.strictEqual(login.status, 503);++ // The object explorer signs nobody in either, and verifies no token (that would ask Network for its key).++ const mine = await request('GET', '/api/v2/me/objects', { headers: { Cookie: 'ov_token=a.b.c' } });++ assert.deepStrictEqual([mine.status, mine.json && mine.json.code], [503, 'media.drill_no_sign_in']);++ const mePage = await request('GET', '/me', { headers: { Cookie: 'ov_token=a.b.c' } });++ assert.ok(mePage.status === 503 && mePage.text.includes('restore-drill'), `/me: ${mePage.status}`);++ assert.deepStrictEqual(drill.blocked, [], 'nothing tried to leave');assert.deepStrictEqual(counts(), countsBefore);});await check('webhooks and the Events relay stay off even when called', async () => {nnew file mode 100644@@@ -0,0 +1,382 @@++'use strict';++// The object explorer (roadmap WS-G task 12; server/me/): a signed-in person's own objects across++// tenants (/api/v2/me/objects, /usage, /me pages) and the operator views (/api/v2/me/ops, /me/ops,++// /api/v1/:app/admin/storage/ops).++// - owner scoping: only objects whose owner_subject is the caller's subject, never another person's++// (list, filters, detail, page), and only a person's access token for this site identifies them;++// - signed out: the API answers 401, the page is a sign-in prompt;++// - cursor pagination, filters, upload progress (multipart parts, stored bytes, a PUT streaming),++// derivatives, holds (only whether), usage per tenant and namespace with no quota;++// - the page renders without JavaScript (rows, pager, usage), escapes what it shows, is noindex,++// is left out of the sitemap and disallowed in robots.txt;++// - operator views for staff.site.view only; the recompute for staff.site.configure, same-origin++// only; the app-key twin in the admin routes (never acting for a user).++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-me-'));++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.MEDIA_DRILL;++++const { ids } = require('openvibe-contracts');++const db = require('../server/db/database');++require('../server/views/service').ensureSchema();++const model = require('../server/objects/model');++const namespaces = require('../server/objects/namespaces');++const { createMeRoutes, personFromClaims } = require('../server/me/routes');++const express = require('express');++++db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });++db.upsertApp({ app_id: 'tools', name: 'OpenVibe.Tools', api_key: 'tools-key' });++for (const a of ['live', 'tools']) db.ensureRootNamespace(db.getApp(a));++namespaces.ensure(db.getApp('live'), 'live.avatars');++++const ANA = `usr_${ids.ulid()}`;++const BEN = `usr_${ids.ulid()}`;++const ALEX = `usr_${ids.ulid()}`;++const base = Date.now() - 3600e3;++let tick = 0;++const make = (o) => model.createObject({ app_id: 'live', kind: 'file', visibility: 'private', lifecycle_status: 'ready', mime_type: 'text/plain', size_bytes: 100, createdMs: base + (tick++) * 1000, ...o });++const present = (id, bytes, key = path.join(process.env.OBJECTS_PATH, id)) => model.upsertLocation(id, { provider: 'local', key, state: 'present', size_bytes: bytes, verified: true });++++// ── Fixtures: Ana's objects ──++const anaReady = [];++for (let i = 0; i < 30; i++) {++ const id = make({ owner_subject: ANA, metadata: { filename: `note-${i}.txt` }, size_bytes: 1000 + i });++ present(id, 1000 + i);++ anaReady.push(id);++}++const tricky = make({ owner_subject: ANA, visibility: 'public', metadata: { filename: '<script>alert(1)</script>.txt' }, size_bytes: 7 });++present(tricky, 7);++const avatar = make({ owner_subject: ANA, namespace: 'live.avatars', kind: 'avatar', mime_type: 'image/png', metadata: { filename: 'me.png' }, size_bytes: 500 });++present(avatar, 500);++const inTools = make({ app_id: 'tools', owner_subject: ANA, metadata: { filename: 'result.json' }, mime_type: 'application/json', size_bytes: 2048 });++present(inTools, 2048);++// Uploading, multipart: 2 of 4 parts of 1 MiB received.++const multi = make({ owner_subject: ANA, lifecycle_status: 'uploading', mime_type: 'video/mp4', kind: 'vod', metadata: { filename: 'big.mp4' }, size_bytes: 4 * 1048576 });++namespaces.reserve(model.getObject(multi), 4 * 1048576);++db.run("INSERT INTO media_uploads (id, object_id, app_id, part_size, total_size, parts_expected, status, expires_at) VALUES ('mup_TEST', ?, 'live', 1048576, ?, 4, 'active', datetime('now', '+1 day'))", [multi, 4 * 1048576]);++for (const n of [1, 3]) db.run("INSERT INTO media_upload_parts (upload_id, part_number, size_bytes, sha256) VALUES ('mup_TEST', ?, 1048576, 'x')", [n]);++// Uploading, single part, bytes stored and waiting for complete.++const stored = make({ owner_subject: ANA, lifecycle_status: 'uploading', metadata: { filename: 'stored.txt' }, size_bytes: 300 });++present(stored, 300);++// Uploading, a PUT still streaming (a temp file under OBJECTS_PATH/.tmp).++const streaming = make({ owner_subject: ANA, lifecycle_status: 'uploading', metadata: { filename: 'streaming.txt' }, size_bytes: 1000 });++fs.mkdirSync(path.join(process.env.OBJECTS_PATH, '.tmp'), { recursive: true });++fs.writeFileSync(path.join(process.env.OBJECTS_PATH, '.tmp', `${streaming}-abcd1234`), Buffer.alloc(250));++const deleted = make({ owner_subject: ANA, lifecycle_status: 'deleted', metadata: { filename: 'gone.txt', retention_until: new Date(Date.now() + 864e5).toISOString() }, size_bytes: 40 });++const failed = make({ owner_subject: ANA, lifecycle_status: 'failed', metadata: { filename: 'expired.txt', failure: 'upload_expired' }, size_bytes: 60 });++// A derivative (waveform variant) of one of Ana's, and a held one.++const wave = make({ owner_subject: ANA, kind: 'asset', mime_type: 'image/png', metadata: { derived_from: anaReady[0] }, size_bytes: 10 });++present(wave, 10);++model.setRelationship(wave, 'derived_from', anaReady[0], {});++model.setVariant(anaReady[0], 'waveform', wave, 'object.waveform@1');++model.placeHold({ object_id: anaReady[1], kind: 'evidence', reason: 'SECRET-HOLD-REASON', created_by: 'staff:x', note: 'SECRET-NOTE' });++// ── Ben's objects (must never show up for Ana) ──++const benIds = [];++for (let i = 0; i < 3; i++) { const id = make({ owner_subject: BEN, metadata: { filename: `ben-${i}.txt` }, size_bytes: 999999 }); present(id, 999999); benIds.push(id); }++const benUploading = make({ owner_subject: BEN, lifecycle_status: 'uploading', size_bytes: 5 });++// An object with no subject at all.++make({ owner_subject: null, owner_user_id: 5, metadata: { filename: 'nobody.txt' } });++// A failed job, for the operator views.++db.run(`INSERT INTO media_jobs (id, app_id, object_id, job_type, status, error, error_code, attempts, max_attempts, finished_at)++ VALUES ('mjob_TESTFAILED', 'live', ?, 'object.sprite', 'failed', 'ffmpeg exited 1', 'no_video', 1, 3, CURRENT_TIMESTAMP)`, [anaReady[2]]);++// A missing copy and a no-good-copy object.++const lost = make({ owner_subject: BEN, size_bytes: 1 });++model.upsertLocation(lost, { provider: 'b2', key: 'k/lost', state: 'missing', size_bytes: 1, verified: true });++++// ── Tokens: what auth.verify (the JWKS-backed verifier) would answer ──++const AUD = ['openvibe.live', 'openvibe.media', 'openvibe.network'];++const CLAIMS = {++ ana: { sub: 7, subject_id: ANA, username: 'ana', role: 'user', aud: AUD },++ ben: { sub: 8, subject_id: BEN, username: 'ben', role: 'user', aud: AUD },++ admin: { sub: 1, subject_id: ALEX, username: 'alex', role: 'admin', aud: AUD },++ mod: { sub: 2, subject_id: `usr_${ids.ulid()}`, username: 'mo', role: 'global_mod', aud: AUD },++ liveOnly: { sub: 7, subject_id: ANA, username: 'ana', aud: ['openvibe.live'] },++ service: { sub: 'svc:community', subject_id: ANA, aud: AUD, actor_type: 'service' },++ fedcm: { sub: 7, subject_id: ANA, aud: AUD, typ: 'fedcm' },++ noSubject: { sub: 7, username: 'ana', aud: AUD },++};++const auth = { verify: async (token) => CLAIMS[token] || null };++++const app = express();++app.use((req, _res, next) => { // as server/index.js parses cookies++ req.cookies = {};++ for (const part of String(req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i > 0) req.cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); }++ next();++});++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++const me = createMeRoutes({ auth });++app.use('/api/v2/me', me.api);++app.use('/me', me.pages);++app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));++app.use('/', require('../server/public/crawl'));++app.use(require('../server/not-found').notFound);++++(async () => {++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const call = (method, p, { token = null, cookie = null, headers = {}, body = null } = {}) => new Promise((resolve, reject) => {++ const data = body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body));++ const h = { ...headers };++ if (token) h.authorization = `Bearer ${token}`;++ if (cookie) h.cookie = `ov_token=${cookie}`;++ if (data) { h['content-type'] = h['content-type'] || 'application/json'; h['content-length'] = Buffer.byteLength(data); }++ const rq = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers: h }, (res) => {++ let b = ''; res.on('data', (c) => { b += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(b); } catch { /* html */ } resolve({ status: res.statusCode, headers: res.headers, text: b, json }); });++ });++ rq.on('error', reject);++ rq.end(data);++ });++ const all = async (token, qs = '') => {++ const seen = [];++ let cursor = null, pages = 0;++ do {++ const r = await call('GET', `/api/v2/me/objects?limit=7${qs}${cursor ? `&cursor=${cursor}` : ''}`, { token });++ assert.strictEqual(r.status, 200, r.text);++ seen.push(...r.json.objects);++ cursor = r.json.next_cursor;++ assert.ok(++pages < 50);++ } while (cursor);++ return seen;++ };++ try {++ // ── Signed out ──++ let r = await call('GET', '/api/v2/me/objects');++ assert.deepStrictEqual([r.status, r.json.code], [401, 'auth.required']);++ assert.strictEqual(r.headers['cache-control'], 'private, no-store');++ assert.strictEqual((await call('GET', '/api/v2/me/usage')).status, 401);++ assert.strictEqual((await call('GET', `/api/v2/me/objects/${anaReady[0]}`)).status, 401);++ r = await call('GET', '/me');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('href="/auth/login?next=%2Fme"'), 'the sign-in prompt comes back to /me');++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));++ assert.ok(r.headers['x-robots-tag'].includes('noindex') && r.headers['cache-control'] === 'private, no-store');++ assert.ok(!r.text.includes(anaReady[0]) && !r.text.includes('<table'), 'nothing of anyone is shown');++ assert.ok(r.text.includes('navbar.js') && r.text.includes('"sessionUrl":"/auth/me"'), 'in the OpenVibe Frame');++ console.log('✅ signed out: the API answers 401, /me is a sign-in prompt in the Frame');++++ // ── Only a person's access token for this site identifies them ──++ assert.strictEqual((await call('GET', '/api/v2/me/objects', { token: 'forged' })).json.code, 'auth.invalid');++ for (const t of ['liveOnly', 'service', 'fedcm']) {++ r = await call('GET', '/api/v2/me/objects', { token: t });++ assert.deepStrictEqual([r.status, r.json.code], [401, 'auth.invalid'], t);++ }++ assert.strictEqual((await call('GET', '/api/v2/me/objects', { token: 'noSubject' })).json.code, 'auth.no_subject');++ assert.ok(personFromClaims(CLAIMS.ana).person && !personFromClaims(null).person);++ r = await call('GET', '/me', { cookie: 'noSubject' });++ assert.ok(r.text.includes('no network subject') && r.text.includes('/auth/login'), 'a sign-in without a subject is asked to sign in again');++ console.log('✅ identity: audience openvibe.media, a person (no principals, no FedCM assertions) with a usr_ subject');++++ // ── Owner scoping and pagination ──++ const anaAll = await all('ana', '&status=all');++ const anaIds = anaAll.map(o => o.id);++ assert.strictEqual(new Set(anaIds).size, anaIds.length, 'no object twice across pages');++ assert.deepStrictEqual([...anaIds].sort().reverse(), anaIds, 'newest first');++ const expected = db.all('SELECT id FROM media_objects WHERE owner_subject = ? ORDER BY id DESC', [ANA]).map(x => x.id);++ assert.deepStrictEqual(anaIds, expected, 'every one of her objects, in every tenant and status');++ for (const b of [...benIds, benUploading, lost]) assert.ok(!anaIds.includes(b), 'never another person\'s object');++ const benAll = await all('ben', '&status=all');++ assert.deepStrictEqual(benAll.map(o => o.id).sort(), [...benIds, benUploading, lost].sort());++ r = await call('GET', `/api/v2/me/objects/${benIds[0]}`, { token: 'ana' });++ assert.deepStrictEqual([r.status, r.json.code], [404, 'media.object.not_found'], 'another person\'s object is not found');++ assert.strictEqual((await call('GET', `/me/objects/${benIds[0]}`, { cookie: 'ana' })).status, 404, 'nor its page');++ r = await call('GET', '/api/v2/me/objects?limit=10', { token: 'ana' });++ assert.strictEqual(r.json.objects.length, 10);++ assert.strictEqual(r.json.next_cursor, r.json.objects[9].id);++ const page2 = await call('GET', `/api/v2/me/objects?limit=10&cursor=${r.json.next_cursor}`, { token: 'ana' });++ assert.ok(page2.json.objects.every(o => o.id < r.json.next_cursor));++ assert.strictEqual((await call('GET', '/api/v2/me/objects?cursor=nope', { token: 'ana' })).status, 400);++ assert.strictEqual((await call('GET', '/api/v2/me/objects?limit=1000', { token: 'ana' })).json.limit, 100);++ assert.strictEqual((await call('GET', '/api/v2/me/objects?status=gone', { token: 'ana' })).status, 400);++ const byDefault = await all('ana');++ assert.ok(!byDefault.some(o => o.lifecycle_status === 'deleted') && byDefault.length === anaIds.length - 1, 'deleted objects only on request');++ console.log(`✅ scoping + pagination: ${anaIds.length} of Ana's objects over pages of 7, none of Ben's; another person's id is a 404`);++++ // ── Filters ──++ r = await call('GET', '/api/v2/me/objects?status=uploading', { token: 'ana' });++ assert.deepStrictEqual(r.json.objects.map(o => o.id).sort(), [multi, stored, streaming].sort());++ r = await call('GET', '/api/v2/me/objects?app=tools', { token: 'ana' });++ assert.deepStrictEqual(r.json.objects.map(o => o.id), [inTools]);++ assert.deepStrictEqual([r.json.objects[0].tenant.name, r.json.objects[0].app_id], ['OpenVibe.Tools', 'tools']);++ r = await call('GET', '/api/v2/me/objects?kind=avatar', { token: 'ana' });++ assert.deepStrictEqual(r.json.objects.map(o => [o.id, o.namespace]), [[avatar, 'live.avatars']]);++ r = await call('GET', '/api/v2/me/objects?q=NOTE-2', { token: 'ana' });++ assert.deepStrictEqual(r.json.objects.map(o => o.filename).sort(), ['note-2.txt', 'note-20.txt', 'note-21.txt', 'note-22.txt', 'note-23.txt', 'note-24.txt', 'note-25.txt', 'note-26.txt', 'note-27.txt', 'note-28.txt', 'note-29.txt']);++ r = await call('GET', `/api/v2/me/objects?q=${benIds[0]}`, { token: 'ana' });++ assert.deepStrictEqual(r.json.objects, [], 'searching for someone else\'s id finds nothing');++ r = await call('GET', '/api/v2/me/objects?q=ben-', { token: 'ana' });++ assert.deepStrictEqual(r.json.objects, []);++ console.log('✅ filters: status, tenant, kind, search (name, title, type, id), all within her own objects');++++ // ── Upload progress, derivatives, holds ──++ const byId = Object.fromEntries(anaAll.map(o => [o.id, o]));++ assert.deepStrictEqual(++ (({ method, waiting_for, received_bytes, parts_received, parts_expected, declared_bytes, percent }) => ({ method, waiting_for, received_bytes, parts_received, parts_expected, declared_bytes, percent }))(byId[multi].upload),++ { method: 'multipart', waiting_for: 'parts', received_bytes: 2 * 1048576, parts_received: 2, parts_expected: 4, declared_bytes: 4 * 1048576, percent: 50 });++ assert.ok(byId[multi].upload.expires_at && byId[multi].upload.session_expires_at, 'when the upload fails if unfinished');++ assert.deepStrictEqual([byId[stored].upload.waiting_for, byId[stored].upload.received_bytes, byId[stored].upload.percent], ['complete', 300, 100]);++ assert.deepStrictEqual([byId[streaming].upload.streaming, byId[streaming].upload.received_bytes, byId[streaming].upload.percent], [true, 250, 25]);++ assert.strictEqual(byId[anaReady[0]].upload, null);++ assert.strictEqual(byId[anaReady[0]].derivatives, 1, 'the waveform counts as a derivative');++ assert.strictEqual(byId[anaReady[3]].derivatives, 0);++ assert.strictEqual(byId[anaReady[1]].held, true);++ assert.strictEqual(byId[failed].failure, 'upload_expired');++ assert.ok(byId[deleted].deleted.retention_until && byId[deleted].deleted.purged === false);++ assert.strictEqual(byId[tricky].public_url, `https://media.test/o/${tricky}`, 'a public ready object links to its bytes');++ assert.strictEqual(byId[anaReady[0]].public_url, null, 'a private one does not');++ assert.deepStrictEqual(byId[anaReady[0]].readiness, { metadata: true, bytes_verified: true, playable: false, reason: 'not_playback_kind' });++ const listText = JSON.stringify(anaAll);++ for (const secret of ['SECRET-HOLD-REASON', 'SECRET-NOTE', 'staff:x', 'evidence', process.env.OBJECTS_PATH, 'canonical_key']) {++ assert.ok(!listText.includes(secret), `the owner's view never carries ${secret}`);++ }++ r = await call('GET', `/api/v2/me/objects/${anaReady[0]}`, { token: 'ana' });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json.derivative_list.map(d => [d.id, d.name, d.kind, d.mine]), [[wave, 'waveform', 'asset', true]]);++ assert.deepStrictEqual(r.json.locations.map(l => [l.provider, l.state, l.canonical]), [['local', 'present', false]]);++ assert.deepStrictEqual(r.json.jobs, []);++ r = await call('GET', `/api/v2/me/objects/${wave}`, { token: 'ana' });++ assert.deepStrictEqual(r.json.source, { id: anaReady[0], relation: 'derived_from' });++ r = await call('GET', `/api/v2/me/objects/${anaReady[2]}`, { token: 'ana' });++ assert.deepStrictEqual(r.json.jobs.map(j => [j.id, j.type, j.status, j.error_code]), [['mjob_TESTFAILED', 'object.sprite', 'failed', 'no_video']]);++ assert.ok(!JSON.stringify(r.json).includes('ffmpeg exited'), 'job error text stays with operators');++ console.log('✅ uploads (multipart parts, stored bytes, a PUT streaming), derivatives, holds (only whether), no keys or staff notes');++++ // ── Usage ──++ r = await call('GET', '/api/v2/me/usage', { token: 'ana' });++ assert.strictEqual(r.status, 200);++ const u = r.json;++ const sum = (list) => list.reduce((a, id) => a + Number(model.getObject(id).size_bytes), 0);++ const readyLive = [...anaReady, tricky, wave];++ assert.strictEqual(u.totals.stored_bytes, sum([...readyLive, avatar, inTools]));++ assert.strictEqual(u.totals.objects, anaIds.length - 1, 'deleted objects are not counted as objects');++ assert.deepStrictEqual(u.totals.by_status.uploading, { objects: 3, bytes: 4 * 1048576 + 300 + 1000 });++ assert.deepStrictEqual(u.totals.by_status.deleted, { objects: 1, bytes: 40 });++ assert.deepStrictEqual(u.tenants.map(t => t.app_id), ['live', 'tools']);++ const live = u.tenants[0];++ assert.deepStrictEqual(live.namespaces.map(n => n.namespace), ['live', 'live.avatars']);++ assert.strictEqual(live.namespaces[0].stored_bytes, sum(readyLive));++ assert.strictEqual(live.namespaces[1].stored_bytes, 500);++ assert.strictEqual(u.tenants[1].stored_bytes, 2048);++ assert.strictEqual(u.quotas, null);++ assert.ok(!/quota_bytes|quota_objects|used_bytes/.test(JSON.stringify(u)), 'no tenant quota or tenant totals');++ const ub = (await call('GET', '/api/v2/me/usage', { token: 'ben' })).json;++ assert.strictEqual(ub.totals.stored_bytes, 3 * 999999 + 1, 'Ben\'s usage is his own');++ console.log('✅ usage: her objects and bytes per tenant and namespace, by status; no quotas');++++ // ── The page, without JavaScript ──++ r = await call('GET', '/me', { cookie: 'ana' });++ assert.strictEqual(r.status, 200);++ const rows = r.text.match(/<tr data-id="med_/g) || [];++ assert.strictEqual(rows.length, 25, 'the first page of objects is in the HTML');++ assert.ok(r.text.includes('Signed in as <strong>@ana</strong>'));++ assert.ok(r.text.includes('<script>alert(1)</script>.txt') && !r.text.includes('<script>alert(1)'), 'names are escaped');++ const next = /id="me-next" rel="next" href="([^"]+)"/.exec(r.text);++ assert.ok(next && /cursor=med_/.test(next[1]) && !/me-next[^>]*hidden/.test(r.text.slice(r.text.indexOf('id="me-next"') - 40, r.text.indexOf('id="me-next"') + 200)), 'a next-page link');++ assert.ok(/<script src="\/me\/app\.js\?v=[0-9a-f]{12}" defer><\/script>/.test(r.text), 'the enhancement is a same-origin script');++ assert.ok(r.text.includes('Read-only.') && r.text.includes('class="grid stack usage"') && r.text.includes('live.avatars'));++ assert.ok(r.text.includes('<progress max="100" value="50"'), 'upload progress without JavaScript');++ for (const b of benIds) assert.ok(!r.text.includes(b));++ assert.ok(/<label for="f-q">/.test(r.text) && /<caption class="sr">/.test(r.text), 'labelled filters, captioned tables');++ assert.ok(!/\bfree\b/i.test(r.text), 'no cost claims in served HTML');++ assert.ok(r.text.includes('"menu":{"before":[{"id":"media-mine","label":"Your media","href":"/me"'), 'the account menu links /me');++ assert.ok(!r.text.includes('href="/me/ops"'), 'no operator link for a user');++ assert.ok((await call('GET', '/me', { cookie: 'admin' })).text.includes('href="/me/ops"'), 'staff get one');++ r = await call('GET', next[1].replace(/&/g, '&'), { cookie: 'ana' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual((r.text.match(/<tr data-id="med_/g) || []).length, anaIds.length - 1 - 25, 'the second page');++ assert.ok(r.text.includes('>Newest</a>'));++ r = await call('GET', '/me/?kind=avatar&app=live', { cookie: 'ana' });++ assert.strictEqual((r.text.match(/<tr data-id="med_/g) || []).length, 1, '/me/ and filters as a GET form');++ assert.ok(r.text.includes('<option value="avatar" selected>'));++ r = await call('GET', `/me/objects/${anaReady[0]}`, { cookie: 'ana' });++ assert.ok(r.status === 200 && r.text.includes('waveform') && r.text.includes('Copies') && r.text.includes('noindex'));++ r = await call('GET', `/me/objects/${multi}`, { cookie: 'ana' });++ assert.ok(r.text.includes('data-upload=') && r.text.includes('/me/app.js'), 'an upload in progress is polled on its page');++ r = await call('GET', '/me/app.js?v=x');++ assert.ok(r.status === 200 && /javascript/.test(r.headers['content-type']) && r.text.includes('/api/v2/me/objects'));++ assert.ok(!/innerHTML/.test(r.text), 'the script builds nodes, never markup');++ console.log('✅ /me without JavaScript: first page, pager, filters, usage, progress; escaped; detail pages');++++ // ── Crawlers ──++ r = await call('GET', '/sitemap.xml');++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);++ assert.ok(r.status === 200 && locs.length && locs.every(l => !/^https:\/\/media\.test\/me([/?]|$)/.test(l)), 'the sitemap never lists /me');++ r = await call('GET', '/robots.txt');++ assert.ok(/^Disallow: \/me$/m.test(r.text), 'robots.txt keeps crawlers out of /me');++ console.log('✅ /me is not in the sitemap and is disallowed in robots.txt');++++ // ── Operator views ──++ r = await call('GET', '/api/v2/me/ops', { token: 'ana' });++ assert.deepStrictEqual([r.status, r.json.code], [403, 'capability.denied']);++ assert.strictEqual((await call('GET', '/api/v2/me/ops', { token: 'mod' })).status, 403, 'a global_mod does not hold staff.site.view');++ assert.strictEqual((await call('GET', '/api/v2/me/ops')).status, 401);++ r = await call('GET', '/me/ops', { cookie: 'ana' });++ assert.ok(r.status === 403 && r.text.includes('staff.site.view') && !r.text.includes('mjob_TESTFAILED'));++ r = await call('GET', '/api/v2/me/ops', { token: 'admin' });++ assert.strictEqual(r.status, 200, r.text);++ const rep = r.json;++ assert.strictEqual(rep.scope, 'all');++ assert.deepStrictEqual(rep.jobs.failed.recent.map(j => [j.id, j.error_code, j.error]), [['mjob_TESTFAILED', 'no_video', 'ffmpeg exited 1']]);++ assert.deepStrictEqual(rep.jobs.failed.last_7_days, [{ type: 'object.sprite', error_code: 'no_video', count: 1 }]);++ assert.ok(rep.missing.no_good_copy.objects.some(o => o.id === lost), 'a ready object whose only copy is missing');++ assert.ok(rep.missing.locations.missing_or_corrupt.some(l => l.object_id === lost && l.provider === 'b2'));++ assert.ok(rep.backfill.unprojected && rep.backfill.owner_subject.missing >= 1);++ assert.ok(rep.tiering.objects.some(o => o.origin === 'native') && rep.tiering.sweep && 'pending_offload' in rep.tiering);++ assert.ok(rep.namespaces.some(n => n.namespace === 'live.avatars'));++ r = await call('GET', '/api/v2/me/ops?app=tools', { token: 'admin' });++ assert.deepStrictEqual([r.json.scope, r.json.jobs.failed.total, r.json.namespaces.map(n => n.namespace)], ['tools', 0, ['tools']]);++ r = await call('GET', '/me/ops', { cookie: 'admin' });++ assert.ok(r.status === 200 && r.text.includes('mjob_TESTFAILED') && r.text.includes('action="/me/ops/recompute"') && r.text.includes('noindex'));++ console.log('✅ operator views: staff.site.view only (not users, not global mods); failed jobs, missing media, backfill, tiering, namespaces');++++ // ── Recompute: staff.site.configure, same-origin ──++ db.run("UPDATE media_namespaces SET used_bytes = 0, used_objects = 0, reconciled_at = NULL WHERE namespace = 'live'");++ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin' })).status, 403, 'a cookie-borne write needs this site as its origin');++ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { origin: 'https://evil.test' } })).status, 403);++ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { 'sec-fetch-site': 'cross-site', origin: 'https://media.test' } })).status, 403);++ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { token: 'ana' })).status, 403, 'users cannot recompute');++ assert.strictEqual(db.get("SELECT used_bytes FROM media_namespaces WHERE namespace = 'live'").used_bytes, 0, 'nothing ran');++ r = await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { origin: 'https://media.test' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.json.namespaces, r.json.scope], [db.get('SELECT COUNT(*) AS n FROM media_namespaces').n, 'all']);++ const liveRow = db.get("SELECT * FROM media_namespaces WHERE namespace = 'live'");++ assert.strictEqual(liveRow.used_bytes, namespaces.usage(db.getApp('live'), 'live').used_bytes);++ assert.ok(liveRow.used_bytes > 0 && liveRow.reconciled_at, 'the snapshot is counted again from the rows');++ r = await call('POST', '/me/ops/recompute?app=tools', { cookie: 'admin', headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/x-www-form-urlencoded' }, body: 'x=1' });++ assert.deepStrictEqual([r.status, r.headers.location], [303, '/me/ops?app=tools&recomputed=1'], 'the form posts and comes back to the page');++ r = await call('POST', '/api/v2/me/ops/recompute', { token: 'admin' });++ assert.strictEqual(r.status, 200, 'a bearer token is not a cookie: no cross-site risk');++ console.log('✅ recompute: staff.site.configure, same-origin for the cookie, refreshes the snapshot; the page form redirects back');++++ // ── The app-key twin in the admin routes ──++ r = await call('GET', '/api/v1/live/admin/storage/ops', { token: 'live-key' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.scope, 'live');++ assert.ok(r.json.namespaces.every(n => n.app_id === 'live'));++ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/ops', { token: 'live-key', headers: { 'x-ov-user-id': '5' } })).status, 403, 'never acting for a user');++ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/ops', { token: 'ana' })).status, 401);++ r = await call('POST', '/api/v1/tools/admin/storage/ops/recompute', { token: 'tools-key' });++ assert.deepStrictEqual([r.status, r.json.scope, r.json.namespaces], [200, 'tools', 1]);++ console.log('✅ /api/v1/:app/admin/storage/ops: the same report for the app key, scoped to its tenant');++++ // ── Routes under /api/v2/me are the explorer's only ──++ r = await call('GET', '/api/v2/me/namespaces', { token: 'ana' });++ assert.deepStrictEqual([r.status, r.json.code], [404, 'media.not_found']);++ console.log('me-explorer: all checks passed');++ } finally {++ server.close();++ try { db.close(); } catch { /* */ }++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -41,6 +41,8 @@ server/
@@ -41,6 +41,8 @@ server/
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
public/routes.js public /v /c /p /t /f
public/routes.js public /v /c /p /t /f
+ me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,
+ derivatives and usage) and the operator views /me/ops — see docs/object-model.md#object-explorer
objects/ canonical object model: model (projections, holds), routes (/api/v2 + /o),
objects/ canonical object model: model (projections, holds), routes (/api/v2 + /o),
backfill, reconcile, invariant, signing (presigned URLs), multipart,
backfill, reconcile, invariant, signing (presigned URLs), multipart,
content-type, verify-job (scheduled copy verification), copy-report,
content-type, verify-job (scheduled copy verification), copy-report,
@@ -74,6 +76,13 @@ client `media` (`server/user-auth.js`, routes under `/auth`, host-only cookies);
@@ -74,6 +76,13 @@ client `media` (`server/user-auth.js`, routes under `/auth`, host-only cookies);
`openvibe-shared/legal` (profile `hosting`). This is separate from tenant auth below, which is how
`openvibe-shared/legal` (profile `hosting`). This is separate from tenant auth below, which is how
other OpenVibe services call the API.
other OpenVibe services call the API.
+**Your media (`/me`).** A signed-in person sees the objects whose `owner_subject` is their Network
+subject, in every tenant: uploads in progress, copies, derivatives, visibility, lifecycle and their own
+usage per tenant and namespace (`GET /api/v2/me/objects`, `/objects/:id`, `/usage`). Read-only: changes
+stay with the apps. Network staff with `staff.site.view` get the operator views at `/me/ops` (failed
+jobs, missing media, backfill, tiering, usage recompute with `staff.site.configure`). Details in
+[docs/object-model.md](docs/object-model.md#object-explorer).
+
## Tenancy & auth
## Tenancy & auth
Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
@@ -285,6 +294,8 @@ directories are shared across apps) and responses carry a `note` saying so.
@@ -285,6 +294,8 @@ directories are shared across apps) and responses carry a `note` saying so.
| 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` | `{ 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 |
| 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** |
| GET | `/admin/storage/buckets` | sanitized bucket status per provider: `{ configured, endpoint, bucket, region, healthy, reachable }` via a live HeadBucket probe — **credentials are never returned** |
+| GET | `/admin/storage/ops` | this app's operator report (as `/me/ops` shows it to Network staff): failed jobs, missing media, backfill, tiering, the namespaces' usage snapshot. Not for calls acting for a user (403) |
+| POST | `/admin/storage/ops/recompute` | refresh this app's namespaces' usage snapshot from the rows |
### Webhooks (outbound)
### Webhooks (outbound)
@@ -368,7 +379,7 @@ never the headers (`test/trust-proxy.test.js`).
@@ -368,7 +379,7 @@ never the headers (`test/trust-proxy.test.js`).
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
-| `GET /robots.txt` | crawler policy (`openvibe-shared/seo.robotsTxt`: AI and search crawlers named, `/api/`, `/auth/`, `/internal/`, `/o/`, `/metrics`, `/live/` disallowed) and the sitemap |
+| `GET /robots.txt` | crawler policy (`openvibe-shared/seo.robotsTxt`: AI and search crawlers named, `/api/`, `/auth/`, `/internal/`, `/o/`, `/metrics`, `/live/`, `/me` disallowed) and the sitemap |
| `GET /sitemap.xml` | the media index and the watch pages Media is the canonical home of: public, playable VODs and clips of apps other than Live (Live lists its own `/vod` and `/clip` pages), never AI clips, private, unlisted, sandbox, recording or failed items; thumbnails as image entries. Every listed page renders `index, follow` |
| `GET /sitemap.xml` | the media index and the watch pages Media is the canonical home of: public, playable VODs and clips of apps other than Live (Live lists its own `/vod` and `/clip` pages), never AI clips, private, unlisted, sandbox, recording or failed items; thumbnails as image entries. Every listed page renders `index, follow` |
| `GET /llms.txt` | a map of the site for language-model crawlers |
| `GET /llms.txt` | a map of the site for language-model crawlers |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
@@ -398,6 +398,65 @@ every tenant gets its root row and every namespace an object names gets a row. O
@@ -398,6 +398,65 @@ every tenant gets its root row and every namespace an object names gets a row. O
(`media_settings` `namespaces.reservations_seeded`), uploads already in progress get a reservation of
(`media_settings` `namespaces.reservations_seeded`), uploads already in progress get a reservation of
their declared size for the full window from that open.
their declared size for the full window from that open.
+## Object explorer
+
+Roadmap WS-G task 12. Code: `server/me/` (`routes.js`, `explorer.js`, `ops.js`, `pages.js`, and `client.js`,
+served as `/me/app.js`). Test: `test/me-explorer.test.js`.
+
+**Who.** The person signed in to openvibe.media (the Network access token in the `ov_token` cookie, or
+a bearer token), identified by its `subject_id` (`usr_…`). The token must name `openvibe.media` in its
+audience and be a person's: service and app principals and FedCM assertions are refused. A token
+without a subject answers 401 `auth.no_subject` (signing in again issues one).
+
+**What.** The objects whose `owner_subject` is that subject, in every tenant, and nothing else: another
+person's object is a 404 like a missing one, and no tenant's quota or namespace policy is shown (a person
+owns no tenant). Keys, paths, metadata beyond the file name and title, job error text and a hold's kind,
+reason, note and author are left out; a hold shows only as `held`.
+
+| method | path | notes |
+|---|---|---|
+| GET | `/api/v2/me/objects` | `?cursor&limit (≤100, 25)&status (a lifecycle, or all; default all but deleted)&kind&q (file name, title, type or id)&app` → `{ objects, next_cursor, limit }`, newest first. Each: `id`, `app_id`, `tenant { name, sandbox, project_id }`, `namespace`, `kind`, `filename`, `title`, `mime_type`, `size_bytes`, `visibility`, `lifecycle_status`, `readiness { metadata, bytes_verified, playable, reason }`, `held`, `derivatives` (live objects made from it: its variants and `derived_from` / `thumbnail_of` sources, not clips), `upload` (below), `failure`, `deleted { deleted_at, retention_until, purged }`, `public_url`, `created_at`, `updated_at` |
+| GET | `/api/v2/me/objects/:id` | one of them, plus `content_hash`, `locations` (provider, class, state, checked), `derivative_list`, `source` (when that is the person's too) and the last 10 `jobs` (type, status, error code) |
+| GET | `/api/v2/me/usage` | objects and bytes per tenant and namespace, by lifecycle: `objects` (not deleted), `stored_bytes` (ready and archived), `by_status` (uploading counts declared sizes; deleted are kept until the purge); `quotas: null` |
+
+`upload` (native objects still `uploading`): `method` (`multipart` or `single`), `waiting_for` (`parts`,
+`assembly`, `bytes`, or `complete` when the bytes are stored and the app has not completed it),
+`received_bytes` (parts received, the stored PUT, or a PUT still streaming into `OBJECTS_PATH/.tmp`),
+`declared_bytes`, `percent`, `parts_received` / `parts_expected`, and `expires_at` (the quota
+reservation: unfinished by then, the hourly sweep fails the upload).
+
+**Read-only.** Visibility, delete and restore stay with the apps: the object API takes them from a
+tenant credential (app key or Network token) and answers a user JWT 403, so the explorer does not act
+for a person.
+
+**Pages.** `GET /me` (and `/me/`) renders the usage table and the first page of objects in the OpenVibe
+Frame, with filters as a GET form and "Next page" as a link, so it is complete without JavaScript;
+`/me/app.js` (same-origin, no inline code) adds filtering and "Load more" without a reload and polls
+uploads in progress every 4 s while the tab is visible. `GET /me/objects/:id` is one object. Signed
+out, each is a sign-in prompt (`/auth/login?next=/me`). Every answer is `private, no-store`, `noindex`;
+`/me` is disallowed in `robots.txt` and never in the sitemap. `/api/v2/me/…` is mounted ahead of the
+tenant routes, so `me` cannot be a tenant id there. A restore drill signs nobody in (503).
+
+### Operator views
+
+For Network staff by the contracts staff map (ADR-022): `staff.site.view` reads, `staff.site.configure`
+recomputes. The same report is in the admin routes for an app's own server (app key, scoped to it).
+
+| method | path | notes |
+|---|---|---|
+| GET | `/api/v2/me/ops`, `/me/ops` | `?app` narrows it to one tenant; `?limit` (≤200, 50) per list |
+| POST | `/api/v2/me/ops/recompute`, `/me/ops/recompute` | refresh every namespace's usage snapshot (or one tenant's) from the rows. With the cookie only from this site's own pages (`Sec-Fetch-Site: same-origin`, else `Origin`); the page form redirects back (303) |
+| GET / POST | `/api/v1/:app/admin/storage/ops`, `/ops/recompute` | the app key, not acting for a user |
+
+The report (`server/me/ops.js`) reads the database only: no provider, file or directory, so it stays
+cheap and renders in a drill.
+
+- `jobs`: counts by status; failed jobs of the last 7 days by type and error code; the most recent failures with their error text.
+- `missing`: ready objects with no good copy; copies recorded missing or corrupt; copies by provider and state; quarantined VODs; the last verification run.
+- `backfill`: the last object backfill that changed something; rows with no object yet; objects whose owner subject is unresolved; whether upload reservations were seeded.
+- `tiering`: providers and policy; VODs and clips per provider; objects per canonical copy (native or projected); VODs eligible to offload; R2 decisions of the last 24 h and recent refusals and failures; the sweep's state in this process.
+- `namespaces`: every namespace with quota and usage snapshot.
+
## Readiness
## Readiness
Code: `server/objects/readiness.js`. Test: `test/readiness.test.js`.
Code: `server/objects/readiness.js`. Test: `test/readiness.test.js`.
@@ -28,6 +28,9 @@
@@ -28,6 +28,9 @@
* cut from it. The hold row (placed_by/at, released_by/at, note) is the record,
* 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
* and each change is logged as an [Admin] line
* POST /holds/:holdId/release { released_by } (also DELETE /holds/:holdId); 409 when already released
* POST /holds/:holdId/release { released_by } (also DELETE /holds/:holdId); 409 when already released
+ * GET /ops this app's operator report (server/me/ops.js, as openvibe.media/me/ops shows
+ * it to Network staff): failed jobs, missing media, backfill, tiering, namespaces
+ * POST /ops/recompute refresh this app's namespaces' usage snapshot from the rows
*/
*/
'use strict';
'use strict';
@@ -594,6 +597,42 @@ function _releaseHold(req, res) {
@@ -594,6 +597,42 @@ function _releaseHold(req, res) {
router.post('/holds/:holdId/release', _releaseHold);
router.post('/holds/:holdId/release', _releaseHold);
router.delete('/holds/:holdId', _releaseHold);
router.delete('/holds/:holdId', _releaseHold);
+// ═══════════════════════════════════════════════════════════════
+// Operator report (WS-G task 12) — the same report as openvibe.media/me/ops, scoped to this app
+// ═══════════════════════════════════════════════════════════════
+
+/** The operator views are the app's own (its server fronts its admins), never a call acting for one of its users. */
+function _opsStaffOnly(req, res) {
+ if (req.authType === 'app') return true;
+ res.status(403).json({ error: 'The operator report is for the app itself: call with the app key, not on behalf of a user', code: 'media.ops.forbidden' });
+ return false;
+}
+
+// GET /ops?limit — failed jobs, missing media, backfill status, tiering diagnostics and the namespaces'
+// usage snapshot of this app (server/me/ops.js; the sweep and the provider switches are service-wide).
+router.get('/ops', (req, res) => {
+ try {
+ if (!_opsStaffOnly(req, res)) return;
+ res.json(require('../me/ops').report({ appId: req.appId, limit: req.query.limit }));
+ } catch (err) {
+ console.error('[Admin] Ops report error:', err.message);
+ res.status(500).json({ error: 'Failed to build the operator report' });
+ }
+});
+
+// POST /ops/recompute — refresh this app's namespaces' usage snapshot from the rows.
+router.post('/ops/recompute', (req, res) => {
+ try {
+ if (!_opsStaffOnly(req, res)) return;
+ const out = require('../me/ops').recompute({ appId: req.appId });
+ console.log(`[Admin] Namespace usage recomputed (${req.appId}): ${out.namespaces} namespace(s)`);
+ res.json(out);
+ } catch (err) {
+ console.error('[Admin] Usage recompute error:', err.message);
+ res.status(500).json({ error: 'Failed to recompute usage' });
+ }
+});
+
// ── GET /buckets — bucket usage scan + cost estimate + reachability ──
// ── GET /buckets — bucket usage scan + cost estimate + reachability ──
// Full ListObjectsV2 walk per provider (10-min server cache; ?force=1 rescans).
// Full ListObjectsV2 walk per provider (10-min server cache; ?force=1 rescans).
// Response shape matches the legacy admin UI: { usage, costs, cachedAt } + buckets probe.
// Response shape matches the legacy admin UI: { usage, costs, cachedAt } + buckets probe.
@@ -104,7 +104,8 @@ const userAuthConfig = {
@@ -104,7 +104,8 @@ const userAuthConfig = {
};
};
// A restore drill signs nobody in: that would redeem codes at Network.
// A restore drill signs nobody in: that would redeem codes at Network.
if (drill.enabled) app.use('/auth', (req, res) => res.status(503).json({ error: 'This is a restore-drill instance (MEDIA_DRILL): no sign-in', code: 'media.drill_no_sign_in' }));
if (drill.enabled) app.use('/auth', (req, res) => res.status(503).json({ error: 'This is a restore-drill instance (MEDIA_DRILL): no sign-in', code: 'media.drill_no_sign_in' }));
-app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuth.createAuthClient(userAuthConfig)));
+const userAuthClient = userAuth.createAuthClient(userAuthConfig);
+app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuthClient));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
@@ -175,6 +176,11 @@ app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth(), (req, res) => {
@@ -175,6 +176,11 @@ app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth(), (req, res) => {
try { require('./views/service').ensureSchema(); } catch (e) { console.warn('[Views] schema:', e.message); }
try { require('./views/service').ensureSchema(); } catch (e) { console.warn('[Views] schema:', e.message); }
if (!drill.enabled) setInterval(() => { try { const n = require('./views/service').prune(30); if (n) console.log(`[Views] pruned ${n} stale visit row(s)`); } catch { /* */ } }, 12 * 3600 * 1000);
if (!drill.enabled) setInterval(() => { try { const n = require('./views/service').prune(30); if (n) console.log(`[Views] pruned ${n} stale visit row(s)`); } catch { /* */ } }, 12 * 3600 * 1000);
+// The object explorer (WS-G task 12): a signed-in person's own objects and usage, and the operator views
+// (server/me/routes.js). /api/v2/me sits ahead of /api/v2/:app/…, so "me" is never a tenant there.
+const me = require('./me/routes').createMeRoutes({ auth: userAuthClient });
+app.use('/api/v2/me', me.api);
+app.use('/me', me.pages);
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,247 @@
@@ -0,0 +1,247 @@
+/*
+ * OpenVibe.Media — /me/app.js: progressive enhancement for the object explorer (server/me/pages.js).
+ * Runs in the browser. The pages work without it; with it:
+ * - the filter form and "Next page" load from /api/v2/me/objects without a reload ("Load more" appends);
+ * - uploads in progress are polled (every 4 s while the tab is visible, backing off on errors) and
+ * their progress bars updated; a row whose upload finished is replaced with its new state.
+ * Every string from the API goes into the page as text (textContent), never as markup.
+ */
+(function () {
+ 'use strict';
+
+ var KIND = { vod: 'VOD', clip: 'Clip', file: 'File', thumbnail: 'Thumbnail', screenshot: 'Screenshot', avatar: 'Avatar', asset: 'Asset' };
+ var STATUS = { uploading: 'Uploading', ready: 'Ready', failed: 'Failed', archived: 'Archived', deleted: 'Deleted' };
+ var REASON = { no_object: 'no object', deleted: 'deleted', metadata_unreadable: 'metadata unreadable', failed: 'failed', archived: 'archived',
+ recording: 'still recording', processing: 'still processing', verification_pending: 'copy not checked yet', no_verified_copy: 'no verified copy' };
+ var POLL_MS = 4000;
+
+ function fmtBytes(n) {
+ n = Number(n) || 0;
+ if (n < 1024) return n + ' B';
+ var units = ['KB', 'MB', 'GB', 'TB'], v = n, i = -1;
+ do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);
+ return (v >= 100 ? Math.round(v) : v.toFixed(1)) + ' ' + units[i];
+ }
+ function fmtWhen(iso) {
+ if (!iso) return '';
+ var d = new Date(iso);
+ return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
+ }
+ function num(n) { return Number(n || 0).toLocaleString('en-US'); }
+ function displayName(it) { return it.title || it.filename || ((KIND[it.kind] || it.kind) + ' ' + it.id.slice(-6)); }
+
+ /** el('a', { href: '/x', className: 'c' }, ['text', node]) */
+ function el(tag, attrs, children) {
+ var node = document.createElement(tag);
+ Object.keys(attrs || {}).forEach(function (k) {
+ var v = attrs[k];
+ if (v == null || v === false) return;
+ if (k === 'className') node.className = v;
+ else if (k === 'text') node.textContent = v;
+ else node.setAttribute(k, v === true ? '' : String(v));
+ });
+ (children || []).forEach(function (c) { if (c != null) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
+ return node;
+ }
+ function tag(cls, text) { return el('span', { className: 'tag ' + cls, text: text }); }
+ function sub(text) { return el('span', { className: 'sub', text: text }); }
+
+ function uploadWords(u) {
+ if (u.waiting_for === 'complete') return 'all bytes stored, waiting for the app to complete it';
+ if (u.waiting_for === 'assembly') return 'assembling the parts';
+ return (u.percent != null ? u.percent + '% · ' : '') + fmtBytes(u.received_bytes)
+ + (u.declared_bytes ? ' of ' + fmtBytes(u.declared_bytes) : ' received')
+ + (u.method === 'multipart' ? ' · ' + u.parts_received + ' of ' + u.parts_expected + ' parts' : '');
+ }
+
+ function uploadBox(it) {
+ var u = it.upload;
+ var bar = el('progress', { 'aria-label': 'Upload of ' + displayName(it) });
+ if (u.percent != null) { bar.max = 100; bar.value = u.percent; bar.textContent = u.percent + '%'; }
+ var box = el('div', { className: 'up', 'data-upload': it.id }, [bar, el('span', { className: 'sub up-text', text: uploadWords(u) })]);
+ if (u.expires_at) box.appendChild(sub('unfinished uploads fail after ' + fmtWhen(u.expires_at)));
+ return box;
+ }
+
+ function statusNodes(it) {
+ var out = [el('span', { className: 'st', text: STATUS[it.lifecycle_status] || it.lifecycle_status })];
+ if (it.upload) out.push(' ', uploadBox(it));
+ if (it.failure) out.push(' ', sub(it.failure.replace(/_/g, ' ')));
+ if (it.deleted) out.push(' ', sub(it.deleted.purged ? 'bytes purged' : it.deleted.retention_until ? 'kept until ' + fmtWhen(it.deleted.retention_until) : 'kept for the retention period'));
+ if (it.held) out.push(' ', tag('warn', 'Held'));
+ return out;
+ }
+
+ function readinessNodes(it) {
+ var r = it.readiness || {};
+ var out = [r.metadata ? tag('ok', 'metadata') : tag('no', 'no metadata'), r.bytes_verified ? tag('ok', 'bytes verified') : tag('no', 'bytes not verified')];
+ if (it.kind === 'vod' || it.kind === 'clip') out.push(r.playable ? tag('ok', 'playable') : tag('no', 'not playable' + (r.reason && REASON[r.reason] ? ': ' + REASON[r.reason] : '')));
+ return out;
+ }
+
+ function cell(label, children, cls) { return el('td', { 'data-label': label, className: cls }, [el('span', {}, children)]); }
+
+ function buildRow(it) {
+ var name = displayName(it);
+ var tenant = [it.tenant.name];
+ if (it.tenant.sandbox) tenant.push(' ', el('span', { className: 'tag', text: 'sandbox' }));
+ tenant.push(sub(it.namespace));
+ var vis = [it.visibility];
+ if (it.public_url) vis.push(' ', el('a', { href: it.public_url }, ['open', el('span', { className: 'sr', text: ' ' + name })]));
+ var head = el('th', { scope: 'row', 'data-label': 'Name' }, [el('span', {}, [
+ el('a', { href: '/me/objects/' + encodeURIComponent(it.id), text: name }),
+ el('span', { className: 'id', text: it.id }),
+ it.mime_type ? sub(it.mime_type) : null,
+ ])]);
+ var tr = el('tr', { 'data-id': it.id, 'data-status': it.lifecycle_status, 'data-uploading': it.upload ? '1' : null }, [
+ head,
+ el('td', { 'data-label': 'Kind', text: KIND[it.kind] || it.kind }),
+ cell('Tenant', tenant),
+ el('td', { 'data-label': 'Size', className: 'n', text: fmtBytes(it.size_bytes) }),
+ cell('Visibility', vis),
+ cell('Status', statusNodes(it)),
+ cell('Readiness', readinessNodes(it)),
+ el('td', { 'data-label': 'Derivatives', className: 'n', text: num(it.derivatives) }),
+ cell('Created', [fmtWhen(it.created_at)]),
+ ]);
+ return tr;
+ }
+
+ function fetchJson(url) {
+ return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {
+ return res.json().catch(function () { return {}; }).then(function (body) {
+ if (!res.ok) { var err = new Error(body.detail || body.error || ('HTTP ' + res.status)); err.status = res.status; throw err; }
+ return body;
+ });
+ });
+ }
+
+ var live = document.getElementById('me-status');
+ function announce(text) { if (live) live.textContent = text; }
+
+ // ── Upload progress (the list, or one object's page) ─────
+ var pollTimer = null, pollDelay = POLL_MS;
+ var table = document.getElementById('me-objects');
+
+ function uploadingIds() {
+ var nodes = document.querySelectorAll(table ? 'tr[data-uploading]' : '[data-upload]');
+ return Array.prototype.map.call(nodes, function (n) { return n.getAttribute(table ? 'data-id' : 'data-upload'); });
+ }
+
+ function schedulePoll() {
+ if (pollTimer || !uploadingIds().length) return;
+ pollTimer = setTimeout(poll, pollDelay);
+ }
+
+ function onVisible() { if (!document.hidden) { document.removeEventListener('visibilitychange', onVisible); schedulePoll(); } }
+
+ function updateRow(tr, it) {
+ if (it.upload && it.lifecycle_status === 'uploading') {
+ var box = tr.querySelector('[data-upload]');
+ if (box) { box.parentNode.replaceChild(uploadBox(it), box); return; }
+ }
+ var hadFocus = tr.contains(document.activeElement);
+ var fresh = buildRow(it);
+ tr.parentNode.replaceChild(fresh, tr);
+ if (hadFocus) fresh.querySelector('a').focus();
+ if (!it.upload) announce(displayName(it) + ': ' + (STATUS[it.lifecycle_status] || it.lifecycle_status) + '.');
+ }
+
+ function poll() {
+ pollTimer = null;
+ if (document.hidden) { document.addEventListener('visibilitychange', onVisible); return; }
+ var ids = uploadingIds();
+ if (!ids.length) return;
+ if (!table) {
+ // One object's page: follow it; when it stops uploading, show its new state.
+ fetchJson('/api/v2/me/objects/' + encodeURIComponent(ids[0])).then(function (it) {
+ if (it.lifecycle_status !== 'uploading') { window.location.reload(); return; }
+ var box = document.querySelector('[data-upload]');
+ if (box && it.upload) box.parentNode.replaceChild(uploadBox(it), box);
+ pollDelay = POLL_MS; schedulePoll();
+ }).catch(failed);
+ return;
+ }
+ fetchJson('/api/v2/me/objects?status=uploading&limit=100').then(function (data) {
+ var byId = {};
+ (data.objects || []).forEach(function (o) { byId[o.id] = o; });
+ var pending = ids.map(function (id) {
+ var tr = table.querySelector('tr[data-id="' + id + '"]');
+ if (!tr) return null;
+ if (byId[id]) { updateRow(tr, byId[id]); return null; }
+ return fetchJson('/api/v2/me/objects/' + encodeURIComponent(id)).then(function (it) { updateRow(tr, it); })
+ .catch(function () { tr.removeAttribute('data-uploading'); });
+ });
+ return Promise.all(pending);
+ }).then(function () { pollDelay = POLL_MS; schedulePoll(); }).catch(failed);
+ }
+
+ function failed(err) {
+ if (err && err.status === 401) { announce('You are signed out: upload progress stopped. Reload the page after signing in.'); return; }
+ pollDelay = Math.min(pollDelay * 2, 60000);
+ schedulePoll();
+ }
+
+ // ── Filters and pages (/me) ──────────────────────────────
+ var form = document.getElementById('me-filters');
+ var next = document.getElementById('me-next');
+ var empty = document.getElementById('me-empty');
+
+ function filterQuery() {
+ var p = new URLSearchParams();
+ new FormData(form).forEach(function (v, k) { if (String(v).trim()) p.set(k, String(v).trim()); });
+ return p;
+ }
+
+ function load(append) {
+ var p = filterQuery();
+ var cursor = append && next ? next.getAttribute('data-cursor') : null;
+ if (cursor) p.set('cursor', cursor);
+ announce('Loading…');
+ return fetchJson('/api/v2/me/objects?' + p.toString()).then(function (data) {
+ var body = table.tBodies[0];
+ if (!append) while (body.firstChild) body.removeChild(body.firstChild);
+ var first = null;
+ (data.objects || []).forEach(function (it) { var tr = buildRow(it); if (!first) first = tr; body.appendChild(tr); });
+ var shown = body.rows.length;
+ table.hidden = !shown;
+ if (empty) { empty.hidden = !!shown; empty.textContent = p.toString().replace(/(^|&)cursor=[^&]*/, '') ? 'No objects match these filters.' : 'No objects yet.'; }
+ if (next) {
+ var q = new URLSearchParams(p.toString()); q.delete('cursor');
+ if (data.next_cursor) {
+ q.set('cursor', data.next_cursor);
+ next.href = '/me?' + q.toString();
+ next.setAttribute('data-cursor', data.next_cursor);
+ next.textContent = 'Load more';
+ next.hidden = false;
+ } else {
+ next.hidden = true;
+ next.removeAttribute('data-cursor');
+ }
+ }
+ if (!append) {
+ var clean = filterQuery().toString();
+ try { history.replaceState(null, '', '/me' + (clean ? '?' + clean : '')); } catch (e) { /* not allowed here */ }
+ }
+ announce(num(shown) + ' object' + (shown === 1 ? '' : 's') + ' shown' + (data.next_cursor ? ', more available' : '') + '.');
+ if (append && first) first.querySelector('a').focus();
+ schedulePoll();
+ }).catch(function (err) {
+ announce(err.status === 401 ? 'You are signed out. Reload the page after signing in.' : 'Could not load your objects: ' + err.message);
+ });
+ }
+
+ if (form && table) {
+ form.addEventListener('submit', function (e) { e.preventDefault(); load(false); });
+ if (next) {
+ var m = /[?&]cursor=([^&]+)/.exec(next.getAttribute('href') || '');
+ if (m && !next.hidden) { next.setAttribute('data-cursor', decodeURIComponent(m[1])); next.textContent = 'Load more'; }
+ next.addEventListener('click', function (e) {
+ if (!next.getAttribute('data-cursor')) return;
+ e.preventDefault();
+ load(true);
+ });
+ }
+ }
+ schedulePoll();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,296 @@
@@ -0,0 +1,296 @@
+/**
+ * OpenVibe.Media — the object explorer's reads for one person (roadmap WS-G task 12;
+ * docs/object-model.md#object-explorer).
+ *
+ * Everything here is scoped to one Network subject (usr_…): the objects whose owner_subject is that
+ * subject, in every tenant. Nobody else's object is ever read into an answer, and no tenant's quota
+ * or namespace policy is shown (a person owns no tenant; the tenants' quotas are their operators').
+ *
+ * list(subject, filters) a page of the person's objects, newest first (cursor = the last id)
+ * detail(subject, id) one of them with its copies, derivatives and recent jobs, or null
+ * usage(subject) the person's objects and bytes per tenant and namespace, by lifecycle
+ * parseFilters(query) ?cursor&limit&status&kind&q&app → { filters } | { error }
+ *
+ * Read-only: nothing here writes, and none of it runs at load.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const db = require('../db/database');
+const config = require('../config');
+const drill = require('../drill');
+const model = require('../objects/model');
+const readiness = require('../objects/readiness');
+
+const DEFAULT_LIMIT = 25;
+const MAX_LIMIT = 100;
+const ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const APP_RE = /^[A-Za-z0-9_.-]{1,80}$/;
+const STATUS_FILTERS = [...model.LIFECYCLES, 'all'];
+/** Relations whose `from` object was made out of the `to` object (a clip is its own work, not a derivative). */
+const DERIVED_RELATIONS = ['derived_from', 'thumbnail_of'];
+
+/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) or ISO → ISO 8601, or null. */
+function iso(v) {
+ if (!v) return null;
+ const s = String(v);
+ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s) && !s.includes('T') ? `${s.replace(' ', 'T')}Z` : s);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+const text = (v, n = 300) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, n) : null);
+
+/** ?cursor&limit&status&kind&q&app → { filters } or { error } (a 400). */
+function parseFilters(query = {}) {
+ const q = query || {};
+ const one = (v) => (Array.isArray(v) ? v[0] : v);
+ const f = { limit: DEFAULT_LIMIT, cursor: null, status: null, kind: null, q: null, app: null };
+ const limit = one(q.limit);
+ if (limit != null && limit !== '') {
+ const n = Number(limit);
+ if (!Number.isInteger(n) || n < 1) return { error: 'limit must be a positive integer' };
+ f.limit = Math.min(n, MAX_LIMIT);
+ }
+ const cursor = one(q.cursor);
+ if (cursor != null && cursor !== '') {
+ if (!ID_RE.test(String(cursor))) return { error: 'Bad cursor' };
+ f.cursor = String(cursor);
+ }
+ const status = one(q.status);
+ if (status != null && status !== '') {
+ if (!STATUS_FILTERS.includes(String(status))) return { error: `status must be one of ${STATUS_FILTERS.join(', ')}` };
+ f.status = String(status);
+ }
+ const kind = one(q.kind);
+ if (kind != null && kind !== '') {
+ if (!model.KINDS.includes(String(kind))) return { error: `kind must be one of ${model.KINDS.join(', ')}` };
+ f.kind = String(kind);
+ }
+ const search = one(q.q);
+ if (search != null && String(search).trim() !== '') f.q = String(search).trim().slice(0, 100);
+ const app = one(q.app);
+ if (app != null && app !== '') {
+ if (!APP_RE.test(String(app))) return { error: 'Bad app' };
+ f.app = String(app);
+ }
+ return { filters: f };
+}
+
+/** The tenant as the person sees it: id, name, whether it is a developer project's sandbox. */
+function tenantOf(appId, cache = new Map()) {
+ if (!cache.has(appId)) {
+ const app = db.getApp(appId);
+ cache.set(appId, {
+ app_id: appId,
+ name: (app && app.name) || appId,
+ project_id: (app && app.project_id) || null,
+ sandbox: !!(app && app.env === 'sandbox'),
+ });
+ }
+ return cache.get(appId);
+}
+
+/** Non-deleted objects made out of each of `ids`: Map<id, count>. */
+function derivativeCounts(ids) {
+ const out = new Map();
+ if (!ids.length) return out;
+ const ph = ids.map(() => '?').join(', ');
+ const rels = DERIVED_RELATIONS.map(() => '?').join(', ');
+ const rows = db.all(`SELECT x.src AS src, COUNT(DISTINCT x.d) AS n FROM (
+ SELECT v.object_id AS src, v.derived_object_id AS d FROM media_variants v WHERE v.object_id IN (${ph})
+ UNION
+ SELECT r.to_object_id, r.from_object_id FROM media_relationships r WHERE r.to_object_id IN (${ph}) AND r.relation IN (${rels})
+ ) x JOIN media_objects d ON d.id = x.d AND d.lifecycle_status != 'deleted' GROUP BY x.src`, [...ids, ...ids, ...DERIVED_RELATIONS]);
+ for (const r of rows) out.set(r.src, r.n);
+ return out;
+}
+
+/** Bytes of a single-part PUT still streaming in (OBJECTS_PATH/.tmp/<id>-<rand>), or null. A drill reads no storage. */
+function inFlightBytes(ctx, id) {
+ if (drill.enabled) return null;
+ const dir = path.join(config.objects.path, '.tmp');
+ if (!ctx.tmp) {
+ ctx.tmp = [];
+ try { ctx.tmp = fs.readdirSync(dir); } catch { /* no upload has streamed yet */ }
+ }
+ const name = ctx.tmp.find(n => n.startsWith(`${id}-`));
+ if (!name) return null;
+ try { return fs.statSync(path.join(dir, name)).size; } catch { return null; }
+}
+
+/**
+ * How far a native upload has got: a multipart session's parts received, the bytes a PUT stored (the
+ * upload then waits for complete), or a PUT still streaming. Bytes received vs the declared size.
+ */
+function uploadProgress(obj, locs, ctx) {
+ const declared = Number(obj.size_bytes) || 0;
+ const session = db.get("SELECT * FROM media_uploads WHERE object_id = ? AND status IN ('active', 'completing') ORDER BY created_at DESC LIMIT 1", [obj.id]);
+ const reservation = db.get('SELECT expires_at FROM media_quota_reservations WHERE object_id = ?', [obj.id]);
+ let out;
+ if (session) {
+ const parts = db.get('SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_upload_parts WHERE upload_id = ?', [session.id]);
+ out = {
+ method: 'multipart', waiting_for: session.status === 'completing' ? 'assembly' : 'parts',
+ received_bytes: parts.b, parts_received: parts.n, parts_expected: session.parts_expected,
+ session_expires_at: iso(session.expires_at),
+ };
+ } else {
+ const stored = locs.find(l => l.provider === 'local' && l.state === 'present');
+ if (stored) {
+ out = { method: 'single', waiting_for: 'complete', received_bytes: Number(stored.size_bytes) || 0 };
+ } else {
+ const streaming = inFlightBytes(ctx, obj.id);
+ out = { method: 'single', waiting_for: 'bytes', received_bytes: streaming || 0, streaming: streaming != null };
+ }
+ }
+ out.declared_bytes = declared || null;
+ out.percent = declared ? Math.min(100, Math.floor((out.received_bytes * 100) / declared)) : null;
+ out.expires_at = reservation ? iso(reservation.expires_at) : null; // unfinished by then: the upload fails
+ return out;
+}
+
+/** One object as its owner sees it: no keys, paths, staff notes or other people's data. */
+function item(obj, ctx, derivatives = 0) {
+ const md = model.parseJson(obj.metadata, {}) || {};
+ const locs = model.listLocations(obj.id);
+ const r = readiness.compute(obj, locs);
+ const tenant = tenantOf(obj.app_id, ctx.tenants);
+ const ready = obj.lifecycle_status === 'ready';
+ return {
+ id: obj.id,
+ app_id: obj.app_id,
+ tenant,
+ namespace: obj.namespace,
+ kind: obj.kind,
+ filename: text(md.filename, 200),
+ title: text(md.title),
+ mime_type: obj.mime_type || null,
+ size_bytes: Number(obj.size_bytes) || 0,
+ visibility: obj.visibility,
+ lifecycle_status: obj.lifecycle_status,
+ readiness: { metadata: r.metadata, bytes_verified: r.bytes_verified, playable: r.playable, reason: r.reason },
+ // Only whether a retention hold keeps it; its kind, reason, note and who placed it are staff's.
+ held: model.isHeld(obj.id),
+ derivatives,
+ upload: obj.lifecycle_status === 'uploading' && !obj.legacy_ref ? uploadProgress(obj, locs, ctx) : null,
+ failure: obj.lifecycle_status === 'failed' ? text(md.failure, 60) : null,
+ deleted: obj.lifecycle_status === 'deleted'
+ ? { deleted_at: iso(obj.deleted_at), retention_until: iso(md.retention_until), purged: !!md.purged_at } : null,
+ public_url: obj.visibility !== 'private' && ready && !tenant.sandbox ? (model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
+ managed_by: obj.legacy_ref ? 'v1' : 'v2',
+ created_at: iso(obj.created_at),
+ updated_at: iso(obj.updated_at),
+ };
+}
+
+/** A page of the subject's objects, newest first: { objects, next_cursor, limit }. */
+function list(subject, filters = {}) {
+ const f = { limit: DEFAULT_LIMIT, ...filters };
+ const conds = ['o.owner_subject = @subject'];
+ const p = { subject };
+ if (f.cursor) { conds.push('o.id < @cursor'); p.cursor = f.cursor; }
+ if (f.status === 'all') { /* everything, deleted included */ } else if (f.status) { conds.push('o.lifecycle_status = @status'); p.status = f.status; } else conds.push("o.lifecycle_status != 'deleted'");
+ if (f.kind) { conds.push('o.kind = @kind'); p.kind = f.kind; }
+ if (f.app) { conds.push('o.app_id = @app'); p.app = f.app; }
+ if (f.q) {
+ const field = (k) => `instr(lower(COALESCE(CASE WHEN json_valid(o.metadata) THEN json_extract(o.metadata, '$.${k}') END, '')), @ql) > 0`;
+ conds.push(`(o.id = @q OR ${field('filename')} OR ${field('title')} OR instr(lower(COALESCE(o.mime_type, '')), @ql) > 0)`);
+ p.q = f.q; p.ql = f.q.toLowerCase();
+ }
+ const limit = Math.min(Math.max(Number(f.limit) || DEFAULT_LIMIT, 1), MAX_LIMIT);
+ const rows = db.all(`SELECT o.* FROM media_objects o WHERE ${conds.join(' AND ')} ORDER BY o.id DESC LIMIT ${limit + 1}`, p);
+ const page = rows.slice(0, limit);
+ const counts = derivativeCounts(page.map(o => o.id));
+ const ctx = { tenants: new Map() };
+ return {
+ objects: page.map(o => item(o, ctx, counts.get(o.id) || 0)),
+ next_cursor: rows.length > limit ? page[page.length - 1].id : null,
+ limit,
+ };
+}
+
+/** One of the subject's objects (med_ id or legacy ref) with copies, derivatives and jobs; null when it is not theirs. */
+function detail(subject, idOrRef) {
+ const key = String(idOrRef || '');
+ const obj = key.startsWith('legacy:') ? model.getObjectByLegacyRef(key) : (ID_RE.test(key) ? model.getObject(key) : null);
+ if (!obj || !subject || obj.owner_subject !== subject) return null;
+ const ctx = { tenants: new Map() };
+ const derived = new Map();
+ for (const v of db.all('SELECT variant_name, derived_object_id FROM media_variants WHERE object_id = ? ORDER BY id', [obj.id])) {
+ derived.set(v.derived_object_id, v.variant_name);
+ }
+ for (const r of db.all(`SELECT relation, from_object_id FROM media_relationships WHERE to_object_id = ? AND relation IN (${DERIVED_RELATIONS.map(() => '?').join(', ')}) ORDER BY id`,
+ [obj.id, ...DERIVED_RELATIONS])) {
+ if (!derived.has(r.from_object_id)) derived.set(r.from_object_id, r.relation === 'thumbnail_of' ? 'thumbnail' : 'derived');
+ }
+ const derivatives = [];
+ for (const [id, name] of derived) {
+ const d = model.getObject(id);
+ if (!d) continue;
+ derivatives.push({ id: d.id, name, kind: d.kind, visibility: d.visibility, lifecycle_status: d.lifecycle_status, size_bytes: Number(d.size_bytes) || 0, mine: d.owner_subject === subject, created_at: iso(d.created_at) });
+ }
+ // What it was made from, when that is the person's too (a clip of someone else's stream names no one's VOD).
+ const src = db.get(`SELECT r.relation, r.to_object_id FROM media_relationships r JOIN media_objects s ON s.id = r.to_object_id
+ WHERE r.from_object_id = ? AND r.relation IN ('derived_from', 'thumbnail_of', 'clip_of') AND s.owner_subject = ? ORDER BY r.id LIMIT 1`, [obj.id, subject]);
+ const jobs = db.all(`SELECT id, job_type, status, error_code, attempts, max_attempts, created_at, finished_at FROM media_jobs
+ WHERE object_id = ? ORDER BY id DESC LIMIT 10`, [obj.id]).map(j => ({
+ id: j.id, type: j.job_type, status: j.status, error_code: j.error_code || null, attempts: j.attempts, max_attempts: j.max_attempts,
+ created_at: iso(j.created_at), finished_at: iso(j.finished_at),
+ }));
+ return {
+ ...item(obj, ctx, derivatives.filter(d => d.lifecycle_status !== 'deleted').length),
+ content_hash: obj.content_hash || null,
+ locations: model.listLocations(obj.id).map(l => ({
+ provider: l.provider, storage_class: l.storage_class, state: l.state, size_bytes: l.size_bytes,
+ verified_at: iso(l.verified_at), canonical: l.provider === obj.canonical_provider,
+ })),
+ derivative_list: derivatives,
+ source: src ? { id: src.to_object_id, relation: src.relation } : null,
+ jobs,
+ };
+}
+
+/** Counts per lifecycle status → { objects (not deleted), stored_bytes (ready + archived), by_status }. */
+function group(rows) {
+ const by = {};
+ let objects = 0, stored = 0;
+ for (const r of rows) {
+ const s = by[r.status] || (by[r.status] = { objects: 0, bytes: 0 });
+ s.objects += r.n; s.bytes += r.b;
+ if (r.status !== 'deleted') objects += r.n;
+ if (r.status === 'ready' || r.status === 'archived') stored += r.b;
+ }
+ return { objects, stored_bytes: stored, by_status: by };
+}
+
+/**
+ * The subject's usage: their own objects and bytes per tenant and namespace (never a tenant's totals or
+ * quota). Uploading bytes are the declared sizes; deleted ones are kept until the retention purge.
+ */
+function usage(subject) {
+ const rows = db.all(`SELECT app_id, namespace, lifecycle_status AS status, COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b
+ FROM media_objects WHERE owner_subject = ? GROUP BY app_id, namespace, lifecycle_status ORDER BY app_id, namespace`, [subject]);
+ const tenants = new Map();
+ const cache = new Map();
+ for (const r of rows) {
+ if (!tenants.has(r.app_id)) tenants.set(r.app_id, { rows: [], namespaces: new Map() });
+ const t = tenants.get(r.app_id);
+ t.rows.push(r);
+ if (!t.namespaces.has(r.namespace)) t.namespaces.set(r.namespace, []);
+ t.namespaces.get(r.namespace).push(r);
+ }
+ return {
+ subject,
+ totals: group(rows),
+ tenants: [...tenants].map(([appId, t]) => ({
+ ...tenantOf(appId, cache),
+ ...group(t.rows),
+ namespaces: [...t.namespaces].map(([namespace, nsRows]) => ({ namespace, ...group(nsRows) })),
+ })),
+ quotas: null,
+ note: 'Your own objects only. Quotas are set per app and namespace by their operators and are not shown here.',
+ };
+}
+
+module.exports = { list, detail, usage, parseFilters, iso, DEFAULT_LIMIT, MAX_LIMIT, STATUS_FILTERS, ID_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,220 @@
@@ -0,0 +1,220 @@
+/**
+ * OpenVibe.Media — operator views over the object platform (roadmap WS-G task 12;
+ * docs/object-model.md#operator-views).
+ *
+ * report({ appId, limit }) failed jobs, missing media, backfill status, tiering diagnostics and the
+ * namespaces' usage snapshot; appId narrows everything that has a tenant
+ * (the tiering sweep and the provider switches are service-wide)
+ * recompute({ appId }) refresh the namespaces' usage snapshot from the rows (namespaces.reconcile),
+ * the one write here
+ *
+ * Read from the database only: no provider is called, no file is opened and no directory is walked, so
+ * a page load stays cheap and a restore drill (MEDIA_DRILL) can render it. Nothing runs at load.
+ * Callers: GET /api/v2/me/ops and /me/ops (a Network token with staff.site.view; the recompute needs
+ * staff.site.configure), GET /api/v1/:app/admin/storage/ops (the app key, scoped to that app).
+ */
+'use strict';
+
+const db = require('../db/database');
+const namespaces = require('../objects/namespaces');
+const copyReport = require('../objects/copy-report');
+
+const iso = require('./explorer').iso;
+const clip = (s, n) => (s == null ? null : String(s).slice(0, n));
+
+/** `AND <col> = ?` when scoped to one tenant. */
+function scoped(appId, col = 'app_id') {
+ return appId ? { sql: ` AND ${col} = ?`, params: [appId] } : { sql: '', params: [] };
+}
+
+function tableExists(name) {
+ return !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?", [name]);
+}
+
+// ── Jobs ─────────────────────────────────────────────────────
+
+function jobs(appId, limit) {
+ const s = scoped(appId);
+ const byStatus = {};
+ for (const r of db.all(`SELECT status, COUNT(*) AS n FROM media_jobs WHERE 1 = 1${s.sql} GROUP BY status`, s.params)) byStatus[r.status] = r.n;
+ const total = byStatus.failed || 0;
+ const week = db.all(`SELECT job_type, COALESCE(error_code, '') AS error_code, COUNT(*) AS n FROM media_jobs
+ WHERE status = 'failed' AND COALESCE(finished_at, updated_at) >= datetime('now', '-7 days')${s.sql}
+ GROUP BY job_type, error_code ORDER BY n DESC, job_type`, s.params)
+ .map(r => ({ type: r.job_type, error_code: r.error_code || null, count: r.n }));
+ const recent = db.all(`SELECT id, app_id, object_id, job_type, error_code, error, attempts, max_attempts, created_by, created_at, finished_at, updated_at
+ FROM media_jobs WHERE status = 'failed'${s.sql} ORDER BY COALESCE(finished_at, updated_at) DESC, id DESC LIMIT ?`, [...s.params, limit])
+ .map(j => ({
+ id: j.id, app_id: j.app_id, object_id: j.object_id || null, type: j.job_type, error_code: j.error_code || null,
+ error: clip(j.error, 500), attempts: j.attempts, max_attempts: j.max_attempts, created_by: j.created_by || null,
+ created_at: iso(j.created_at), finished_at: iso(j.finished_at || j.updated_at),
+ }));
+ return { by_status: byStatus, failed: { total, last_7_days: week, recent } };
+}
+
+// ── Missing media ────────────────────────────────────────────
+
+function missing(appId, limit) {
+ const s = scoped(appId, 'o.app_id');
+ const q = { get: (sql, p) => db.get(sql, p), all: (sql, p) => db.all(sql, p) };
+ const noGood = copyReport.countNoGoodCopy(q, { appId });
+ const hasVerifications = tableExists('media_verifications');
+ const noGoodList = db.all(`SELECT o.id, o.app_id, o.kind, o.legacy_ref, o.size_bytes${hasVerifications ? ', v.status AS verify_status, v.verified_at' : ''}
+ FROM media_objects o${hasVerifications ? ' LEFT JOIN media_verifications v ON v.object_id = o.id' : ''}
+ WHERE ${copyReport.NO_GOOD_COPY_WHERE}${s.sql} ORDER BY o.app_id, o.id LIMIT ?`, [...s.params, limit])
+ .map(o => ({ id: o.id, app_id: o.app_id, kind: o.kind, legacy_ref: o.legacy_ref || null, size_bytes: o.size_bytes,
+ last_verification: hasVerifications && o.verify_status ? { status: o.verify_status, at: iso(o.verified_at) } : null }));
+ const byState = db.all(`SELECT l.provider, l.state, COUNT(*) AS n FROM media_locations l JOIN media_objects o ON o.id = l.object_id
+ WHERE o.lifecycle_status != 'deleted'${s.sql} GROUP BY l.provider, l.state ORDER BY l.provider, l.state`, s.params)
+ .map(r => ({ provider: r.provider, state: r.state, count: r.n }));
+ const bad = db.all(`SELECT l.object_id, l.provider, l.state, l.verified_at, o.app_id, o.kind, o.legacy_ref, o.lifecycle_status
+ FROM media_locations l JOIN media_objects o ON o.id = l.object_id
+ WHERE l.state IN ('missing', 'corrupt') AND o.lifecycle_status != 'deleted'${s.sql}
+ ORDER BY COALESCE(l.verified_at, '') DESC, l.id DESC LIMIT ?`, [...s.params, limit])
+ .map(r => ({ object_id: r.object_id, app_id: r.app_id, kind: r.kind, legacy_ref: r.legacy_ref || null, lifecycle_status: r.lifecycle_status,
+ provider: r.provider, state: r.state, verified_at: iso(r.verified_at) }));
+ const vs = scoped(appId);
+ const quarantined = {};
+ for (const r of db.all(`SELECT health_status, COUNT(*) AS n FROM vods WHERE health_status IN ('missing_file', 'zero_byte', 'corrupt', 'needs_review')${vs.sql}
+ GROUP BY health_status`, vs.params)) quarantined[r.health_status] = r.n;
+ const verification = { last_run: null, statuses: {} };
+ if (tableExists('media_verify_runs')) {
+ const r = db.get('SELECT * FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1');
+ if (r) {
+ verification.last_run = { id: r.id, started_at: iso(r.started_at), finished_at: iso(r.finished_at), objects_checked: r.objects_checked,
+ good: r.good, no_good_copy: r.no_good_copy, unverifiable: r.unverifiable, reuploaded: r.reuploaded, reupload_failed: r.reupload_failed,
+ no_good_copy_total: r.no_good_copy_total, error: clip(r.error, 300) };
+ }
+ }
+ if (hasVerifications) {
+ for (const r of db.all(`SELECT v.status, COUNT(*) AS n FROM media_verifications v JOIN media_objects o ON o.id = v.object_id WHERE 1 = 1${s.sql} GROUP BY v.status`, s.params)) {
+ verification.statuses[r.status] = r.n;
+ }
+ }
+ return {
+ no_good_copy: { total: noGood, objects: noGoodList },
+ locations: { by_provider_state: byState, missing_or_corrupt: bad },
+ vods_quarantined: quarantined,
+ verification,
+ note: 'From what the database records (scheduled verification, tier moves, reconciliation). A full walk of storage is the storage.orphans.scan report; drift between rows and objects is scripts/object-drift-report.js.',
+ };
+}
+
+// ── Backfill ─────────────────────────────────────────────────
+
+function backfill(appId) {
+ const s = scoped(appId);
+ const last = require('../objects/backfill').lastReport();
+ const unprojected = {
+ vods: db.get(`SELECT COUNT(*) AS n FROM vods WHERE object_id IS NULL AND COALESCE(clips_only, 0) = 0${s.sql}`, s.params).n,
+ clips: db.get(`SELECT COUNT(*) AS n FROM clips WHERE object_id IS NULL${s.sql}`, s.params).n,
+ files: db.get(`SELECT COUNT(*) AS n FROM files WHERE object_id IS NULL${s.sql}`, s.params).n,
+ screenshots: db.get(`SELECT COUNT(*) AS n FROM pastes WHERE object_id IS NULL AND type = 'screenshot' AND COALESCE(screenshot_path, '') != ''${s.sql}`, s.params).n,
+ };
+ const owner = db.all(`SELECT app_id, COUNT(*) AS n FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL
+ AND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);
+ return {
+ objects: last ? {
+ finished_at: iso(last.finished_at) || null, dry_run: !!last.dry_run, only_missing: !!last.only_missing,
+ totals: last.totals || null, locations: last.locations || null,
+ errors: Array.isArray(last.errors) ? last.errors.length : 0,
+ skipped: Array.isArray(last.skipped) ? last.skipped.length : 0,
+ } : null,
+ unprojected,
+ owner_subject: { missing: owner.reduce((a, r) => a + r.n, 0), by_app: owner.map(r => ({ app_id: r.app_id, count: r.n })) },
+ namespace_reservations_seeded: !!db.get("SELECT 1 AS x FROM media_settings WHERE key = 'namespaces.reservations_seeded'"),
+ note: 'objects: the last boot or scripted backfill that changed something (media_settings objects.backfill.last_report). unprojected: rows with no object yet. owner_subject: objects the owner-subject job has not resolved.',
+ };
+}
+
+// ── Tiering ──────────────────────────────────────────────────
+
+function tiering(appId, limit) {
+ const vodStorage = require('../vod/vod-storage');
+ const s = scoped(appId);
+ const providers = (table, bytes) => {
+ const out = {};
+ for (const r of db.all(`SELECT COALESCE(storage_provider, 'local') AS p, COUNT(*) AS n${bytes ? ', COALESCE(SUM(file_size), 0) AS b' : ''}
+ FROM ${table} WHERE 1 = 1${s.sql} GROUP BY p`, s.params)) out[r.p] = bytes ? { count: r.n, bytes: r.b } : { count: r.n };
+ return out;
+ };
+ const objects = db.all(`SELECT COALESCE(canonical_provider, 'none') AS p, CASE WHEN legacy_ref IS NULL THEN 'native' ELSE 'projected' END AS origin,
+ COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b
+ FROM media_objects WHERE lifecycle_status != 'deleted'${s.sql} GROUP BY p, origin ORDER BY origin, p`, s.params)
+ .map(r => ({ canonical_provider: r.p, origin: r.origin, count: r.n, bytes: r.b }));
+ const settings = vodStorage.getSettings();
+ const pendingOffload = db.get(`SELECT COUNT(*) AS c FROM vods WHERE ${vodStorage.OFFLOADABLE_WHERE}
+ AND created_at <= datetime('now', ?) AND COALESCE(view_count, 0) <= ?
+ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))${s.sql}`,
+ [`-${Number(settings.minAgeDays) || 0} days`, Number(settings.maxViewsForCold) || 0, `-${Number(settings.minLastAccessDays) || 0} days`, ...s.params]).c;
+ const decisions = { promote: { done: 0, already: 0, refused: 0, failed: 0 }, demote: { done: 0, already: 0, refused: 0, failed: 0 } };
+ for (const r of db.all(`SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions
+ WHERE decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')${s.sql} GROUP BY action, outcome`, s.params)) {
+ if (decisions[r.action]) decisions[r.action][r.outcome] = r.n;
+ }
+ const problems = db.all(`SELECT id, decided_at, app_id, vod_id, object_id, action, from_provider, to_provider, outcome, trigger, reason, error
+ FROM media_tier_decisions WHERE outcome IN ('refused', 'failed')${s.sql} ORDER BY id DESC LIMIT ?`, [...s.params, Math.min(limit, 20)])
+ .map(r => ({ ...r, reason: clip(r.reason, 300), error: clip(r.error, 300) }));
+ const sweep = vodStorage.sweepInfo();
+ return {
+ providers: { b2: vodStorage.providerConfigured('b2'), r2: vodStorage.providerConfigured('r2') },
+ policy: { enabled: !!settings.enabled, r2_enabled: !!settings.r2Enabled, min_age_days: settings.minAgeDays, max_views_for_cold: settings.maxViewsForCold, min_last_access_days: settings.minLastAccessDays },
+ vods: providers('vods', true),
+ clips: providers('clips', false),
+ objects,
+ pending_offload: pendingOffload,
+ decisions_24h: decisions,
+ recent_problems: problems,
+ sweep: {
+ running: sweep.running, started_at: sweep.startedAt, last_run_at: sweep.lastRunAt, next_run_at: sweep.nextRunAt,
+ stalled: sweep.stalled, stalled_passes: sweep.stalledPasses,
+ last_result: sweep.lastResult ? {
+ migrated: sweep.lastResult.migrated ?? null, promoted: sweep.lastResult.promoted ?? null, demoted: sweep.lastResult.demoted ?? null,
+ skipped_held: sweep.lastResult.skippedHeld ?? null, quarantined: sweep.lastResult.quarantined ?? null,
+ errors: Array.isArray(sweep.lastResult.errors) ? sweep.lastResult.errors.length : 0, error: clip(sweep.lastResult.error, 300),
+ timestamp: sweep.lastResult.timestamp || null,
+ } : null,
+ },
+ note: 'Native v2 objects stay on local storage until roadmap WS-G task 11 (tiering for native objects). The sweep and the provider switches are service-wide; counts follow the scope.',
+ };
+}
+
+// ── Namespaces ───────────────────────────────────────────────
+
+function namespaceSnapshot(appId) {
+ const s = scoped(appId);
+ const apps = new Map();
+ return db.all(`SELECT * FROM media_namespaces WHERE 1 = 1${s.sql} ORDER BY app_id, namespace`, s.params).map((row) => {
+ if (!apps.has(row.app_id)) apps.set(row.app_id, db.getApp(row.app_id) || { app_id: row.app_id });
+ return { app_id: row.app_id, ...namespaces.publicShape(apps.get(row.app_id), row) };
+ });
+}
+
+/** The whole operator report (see the header). `limit` bounds each list (1-200, default 50). */
+function report({ appId = null, limit = 50 } = {}) {
+ const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
+ return {
+ generated_at: new Date().toISOString(),
+ scope: appId || 'all',
+ tenants: db.all('SELECT app_id FROM apps ORDER BY app_id').map(r => r.app_id),
+ jobs: jobs(appId, n),
+ missing: missing(appId, n),
+ backfill: backfill(appId),
+ tiering: tiering(appId, n),
+ namespaces: namespaceSnapshot(appId),
+ };
+}
+
+/** Refresh the usage snapshot of every namespace (or one tenant's) from the rows: { namespaces, recomputed_at }. */
+function recompute({ appId = null } = {}) {
+ let count = 0;
+ if (appId) {
+ const app = db.getApp(appId) || { app_id: appId };
+ for (const row of namespaces.listForTenant(appId)) { namespaces.reconcile(app, row.namespace); count++; }
+ } else {
+ count = namespaces.reconcileAll();
+ }
+ return { namespaces: count, scope: appId || 'all', recomputed_at: new Date().toISOString() };
+}
+
+module.exports = { report, recompute };
new file mode 100644
new file mode 100644
@@ -0,0 +1,429 @@
@@ -0,0 +1,429 @@
+/**
+ * OpenVibe.Media — the object explorer's pages (roadmap WS-G task 12): /me, /me/objects/:id, /me/ops.
+ *
+ * Server-rendered in the OpenVibe Frame (public/page-frame.js), complete without JavaScript: the first
+ * page of objects, the usage table, filters as a GET form, pages as links, and the operator recompute
+ * as a POST form. /me/app.js (client.js) only enhances /me: filters and "load more" without a reload,
+ * and upload progress polled while something is uploading. Every page is noindex and private.
+ * Strings from the database are escaped (esc) and never reach a script.
+ */
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const pageFrame = require('../public/page-frame');
+const model = require('../objects/model');
+
+const { esc, SITE_NAME } = pageFrame;
+
+// The enhancement script, read once: served as /me/app.js?v=<its hash> (routes.js).
+const CLIENT_PATH = path.join(__dirname, 'client.js');
+const CLIENT_SOURCE = fs.readFileSync(CLIENT_PATH, 'utf8');
+const CLIENT_VERSION = crypto.createHash('sha256').update(CLIENT_SOURCE).digest('hex').slice(0, 12);
+
+const KIND_LABEL = { vod: 'VOD', clip: 'Clip', file: 'File', thumbnail: 'Thumbnail', screenshot: 'Screenshot', avatar: 'Avatar', asset: 'Asset' };
+const STATUS_LABEL = { uploading: 'Uploading', ready: 'Ready', failed: 'Failed', archived: 'Archived', deleted: 'Deleted' };
+const REASON_TEXT = {
+ no_object: 'no object', deleted: 'deleted', metadata_unreadable: 'metadata unreadable', failed: 'failed', archived: 'archived',
+ recording: 'still recording', processing: 'still processing', verification_pending: 'copy not checked yet', no_verified_copy: 'no verified copy',
+};
+
+function fmtBytes(n) {
+ n = Number(n) || 0;
+ if (n < 1024) return `${n} B`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let v = n, i = -1;
+ do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);
+ return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
+}
+function fmtWhen(isoStr) {
+ if (!isoStr) return '';
+ const d = new Date(isoStr);
+ return Number.isNaN(d.getTime()) ? '' : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
+}
+const num = (n) => Number(n || 0).toLocaleString('en-US');
+const displayName = (it) => it.title || it.filename || `${KIND_LABEL[it.kind] || it.kind} ${it.id.slice(-6)}`;
+
+/** A table that becomes one card per row (each cell labelled from data-label) on narrow screens. */
+const stackRules = (t) => `
+ ${t} thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+ ${t}, ${t} tbody, ${t} tr, ${t} td, ${t} th { display: block; width: 100%; }
+ ${t} tr { border: 1px solid var(--line); border-radius: 10px; padding: .35rem .6rem; margin: 0 0 .6rem; background: var(--panel); }
+ ${t} td, ${t} th { border: 0; padding: .25rem 0; display: flex; gap: .8rem; justify-content: space-between; text-align: right; }
+ ${t} td::before, ${t} th[data-label]::before { content: attr(data-label); color: var(--muted); font-weight: 400; text-align: left; flex: none; }
+ ${t} td > *, ${t} th > * { min-width: 0; }`;
+
+const CSS = `
+ main { max-width: 1240px; }
+ h2 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }
+ h3 { font-size: .98rem; margin: 1.1rem 0 .4rem; }
+ .sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+ .hint { color: var(--muted); font-size: .88rem; margin: 0 0 .6rem; }
+ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--link); outline-offset: 2px; }
+ button.btn { border: 0; cursor: pointer; font: inherit; font-weight: 600; font-size: .9rem; }
+ /* The accent darkened a little: white on the default blue (#3b82f6) is 3.7:1, under WCAG AA for this size. */
+ .btn:not(.ghost) { background: var(--acc); background: color-mix(in srgb, var(--acc) 82%, #000); }
+ .grid { width: 100%; border-collapse: collapse; font-size: .88rem; margin: 0 0 .8rem; }
+ .grid th, .grid td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: break-word; }
+ .grid tbody th, .grid .id, .grid .sub { overflow-wrap: anywhere; }
+ .grid tbody th { font-weight: 400; }
+ .grid tbody th a { font-weight: 600; }
+ .grid thead th { color: var(--muted); font-weight: 600; font-size: .8rem; }
+ .grid td.n, .grid th.n { text-align: right; white-space: nowrap; }
+ .grid tr.total td, .grid tr.total th { font-weight: 600; }
+ .id { font: 12px/1.4 ui-monospace, Menlo, monospace; color: var(--muted); display: block; }
+ .sub { color: var(--muted); font-size: .8rem; display: block; }
+ .tag { display: inline-block; font-size: .74rem; line-height: 1.5; padding: 0 .45rem; border-radius: 999px; border: 1px solid var(--line); margin: 0 .25rem .25rem 0; white-space: nowrap; }
+ .tag.ok { border-color: color-mix(in srgb, #22c55e 55%, var(--line)); }
+ .tag.no { border-style: dashed; color: var(--muted); }
+ .tag.warn { border-color: color-mix(in srgb, #f59e0b 60%, var(--line)); }
+ .up { display: grid; gap: .2rem; min-width: 9rem; }
+ .up progress { width: 100%; height: .6rem; accent-color: var(--acc); }
+ .filters { display: flex; flex-wrap: wrap; gap: .6rem .8rem; align-items: flex-end; margin: 0 0 .8rem; }
+ .filters div { display: grid; gap: .2rem; }
+ .filters label { font-size: .8rem; color: var(--muted); }
+ .filters input, .filters select { font: inherit; font-size: .9rem; padding: .4rem .5rem; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--text); max-width: 100%; }
+ .filters input { width: 16rem; }
+ .filters .go { display: flex; gap: .5rem; flex-direction: row; }
+ .pager { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .4rem 0 1rem; }
+ .empty { color: var(--muted); }
+ dl.facts { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1rem; margin: 0 0 1rem; font-size: .9rem; }
+ dl.facts dt { color: var(--muted); }
+ dl.facts dd { margin: 0; overflow-wrap: anywhere; }
+ .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: .6rem 1rem; }
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .9rem; }
+ .card b { display: block; font-size: 1.2rem; }
+ .card span { color: var(--muted); font-size: .82rem; }
+ pre.err { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; font: 12px/1.4 ui-monospace, Menlo, monospace; }
+ @media (max-width: 760px) {
+${stackRules('.grid.stack')}
+ .filters input { width: 100%; }
+ .filters div { flex: 1 1 100%; }
+ }
+ @media (min-width: 761px) and (max-width: 1060px) {
+${stackRules('.grid.stack.objects')}
+ }`;
+
+function doc({ title, description, body, script = false }) {
+ return pageFrame.page({
+ seo: { title: `${title} — ${SITE_NAME}`, description, robots: 'noindex, nofollow', twitterCard: 'summary' },
+ css: CSS,
+ body: body + (script ? `\n<script src="/me/app.js?v=${CLIENT_VERSION}" defer></script>` : ''),
+ footer: { variant: 'compact' },
+ });
+}
+
+/** Query string for /me with these filters (+ a cursor). */
+function query(filters = {}, extra = {}) {
+ const p = new URLSearchParams();
+ for (const k of ['q', 'kind', 'status', 'app']) if (filters[k]) p.set(k, filters[k]);
+ if (filters.limit && filters.limit !== 25) p.set('limit', String(filters.limit));
+ for (const [k, v] of Object.entries(extra)) if (v) p.set(k, v);
+ const s = p.toString();
+ return s ? `?${s}` : '';
+}
+
+// ── Signed out / not allowed ─────────────────────────────────
+
+function renderSignIn({ next = '/me', heading = 'Your media', reason = null } = {}) {
+ const body = `
+ <h1>${esc(heading)}</h1>
+ <p class="note">${reason ? `${esc(reason)} ` : ''}Sign in with your OpenVibe account to see the media you own: uploads in progress, copies, derivatives, visibility and usage.</p>
+ <div class="actions"><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a></div>`;
+ return doc({ title: heading, description: 'Sign in to see the media you own on OpenVibe.Media.', body });
+}
+
+function renderForbidden({ person, heading = 'Media operations', need }) {
+ const body = `
+ <h1>${esc(heading)}</h1>
+ <p class="note">Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong>. This page is for OpenVibe operators (the staff capability <code>${esc(need)}</code>).</p>
+ <div class="actions"><a class="btn ghost" href="/me">Your media</a></div>`;
+ return doc({ title: heading, description: 'Operator views of OpenVibe.Media.', body });
+}
+
+function renderDrill(heading = 'Your media') {
+ return doc({ title: heading, description: 'Restore-drill instance.', body: `<h1>${esc(heading)}</h1><p class="note">This is a restore-drill instance (MEDIA_DRILL): nobody signs in here.</p>` });
+}
+
+// ── Explorer (/me) ───────────────────────────────────────────
+
+function statusCell(it) {
+ const out = [`<span class="st">${esc(STATUS_LABEL[it.lifecycle_status] || it.lifecycle_status)}</span>`];
+ const u = it.upload;
+ if (u) {
+ const label = `Upload of ${displayName(it)}`;
+ const bar = u.percent != null
+ ? `<progress max="100" value="${u.percent}" aria-label="${esc(label)}">${u.percent}%</progress>`
+ : `<progress aria-label="${esc(label)}"></progress>`;
+ const words = u.waiting_for === 'complete' ? 'all bytes stored, waiting for the app to complete it'
+ : u.waiting_for === 'assembly' ? 'assembling the parts'
+ : `${u.percent != null ? `${u.percent}% · ` : ''}${fmtBytes(u.received_bytes)}${u.declared_bytes ? ` of ${fmtBytes(u.declared_bytes)}` : ' received'}`
+ + (u.method === 'multipart' ? ` · ${u.parts_received} of ${u.parts_expected} parts` : '');
+ out.push(`<div class="up" data-upload="${esc(it.id)}">${bar}<span class="sub up-text">${esc(words)}</span>${u.expires_at ? `<span class="sub">unfinished uploads fail after ${esc(fmtWhen(u.expires_at))}</span>` : ''}</div>`);
+ }
+ if (it.failure) out.push(`<span class="sub">${esc(it.failure.replace(/_/g, ' '))}</span>`);
+ if (it.deleted) out.push(`<span class="sub">${it.deleted.purged ? 'bytes purged' : it.deleted.retention_until ? `kept until ${esc(fmtWhen(it.deleted.retention_until))}` : 'kept for the retention period'}</span>`);
+ if (it.held) out.push('<span class="tag warn">Held</span>');
+ return out.join(' ');
+}
+
+function readinessCell(it) {
+ const r = it.readiness || {};
+ const tags = [
+ r.metadata ? '<span class="tag ok">metadata</span>' : '<span class="tag no">no metadata</span>',
+ r.bytes_verified ? '<span class="tag ok">bytes verified</span>' : '<span class="tag no">bytes not verified</span>',
+ ];
+ if (it.kind === 'vod' || it.kind === 'clip') {
+ tags.push(r.playable ? '<span class="tag ok">playable</span>' : `<span class="tag no">not playable${r.reason && REASON_TEXT[r.reason] ? `: ${esc(REASON_TEXT[r.reason])}` : ''}</span>`);
+ }
+ return tags.join('');
+}
+
+function objectRow(it) {
+ const name = displayName(it);
+ return `<tr data-id="${esc(it.id)}" data-status="${esc(it.lifecycle_status)}"${it.upload ? ' data-uploading="1"' : ''}>
+ <th scope="row" data-label="Name"><span><a href="/me/objects/${esc(it.id)}">${esc(name)}</a><span class="id">${esc(it.id)}</span>${it.mime_type ? `<span class="sub">${esc(it.mime_type)}</span>` : ''}</span></th>
+ <td data-label="Kind">${esc(KIND_LABEL[it.kind] || it.kind)}</td>
+ <td data-label="Tenant"><span>${esc(it.tenant.name)}${it.tenant.sandbox ? ' <span class="tag">sandbox</span>' : ''}<span class="sub">${esc(it.namespace)}</span></span></td>
+ <td data-label="Size" class="n">${esc(fmtBytes(it.size_bytes))}</td>
+ <td data-label="Visibility"><span>${esc(it.visibility)}${it.public_url ? ` <a href="${esc(it.public_url)}">open<span class="sr"> ${esc(name)}</span></a>` : ''}</span></td>
+ <td data-label="Status"><span>${statusCell(it)}</span></td>
+ <td data-label="Readiness"><span>${readinessCell(it)}</span></td>
+ <td data-label="Derivatives" class="n">${num(it.derivatives)}</td>
+ <td data-label="Created"><span>${esc(fmtWhen(it.created_at))}</span></td>
+ </tr>`;
+}
+
+function usageTable(usage) {
+ if (!usage.tenants.length) return '<p class="empty">You own no media objects yet.</p>';
+ const byStatus = (g, s) => g.by_status[s] || { objects: 0, bytes: 0 };
+ const cells = (g) => `<td data-label="Objects" class="n">${num(g.objects)}</td>
+ <td data-label="Stored" class="n">${esc(fmtBytes(g.stored_bytes))}</td>
+ <td data-label="Uploading" class="n">${num(byStatus(g, 'uploading').objects)}${byStatus(g, 'uploading').objects ? ` · ${esc(fmtBytes(byStatus(g, 'uploading').bytes))}` : ''}</td>
+ <td data-label="Failed" class="n">${num(byStatus(g, 'failed').objects)}</td>
+ <td data-label="Deleted" class="n">${num(byStatus(g, 'deleted').objects)}${byStatus(g, 'deleted').objects ? ` · ${esc(fmtBytes(byStatus(g, 'deleted').bytes))}` : ''}</td>`;
+ const rows = [];
+ for (const t of usage.tenants) {
+ for (const ns of t.namespaces) {
+ rows.push(`<tr><th scope="row" data-label="Namespace"><span>${esc(t.name)}${t.sandbox ? ' <span class="tag">sandbox</span>' : ''}<span class="sub">${esc(ns.namespace)}</span></span></th>${cells(ns)}</tr>`);
+ }
+ if (t.namespaces.length > 1) rows.push(`<tr class="total"><th scope="row" data-label="Tenant">${esc(t.name)}, all namespaces</th>${cells(t)}</tr>`);
+ }
+ if (usage.tenants.length > 1) rows.push(`<tr class="total"><th scope="row" data-label="Total">All tenants</th>${cells(usage.totals)}</tr>`);
+ return `<table class="grid stack usage">
+ <caption class="sr">Your objects and bytes per tenant and namespace</caption>
+ <thead><tr><th scope="col">Tenant and namespace</th><th scope="col" class="n">Objects</th><th scope="col" class="n">Stored</th><th scope="col" class="n">Uploading</th><th scope="col" class="n">Failed</th><th scope="col" class="n">Deleted, kept until purge</th></tr></thead>
+ <tbody>${rows.join('\n')}</tbody>
+ </table>`;
+}
+
+function filtersForm(filters, tenants) {
+ const opt = (v, label, cur) => `<option value="${esc(v)}"${String(cur || '') === String(v) ? ' selected' : ''}>${esc(label)}</option>`;
+ return `<form class="filters" id="me-filters" method="get" action="/me" role="search" aria-label="Filter your objects">
+ <div><label for="f-q">Search</label><input id="f-q" name="q" type="search" maxlength="100" value="${esc(filters.q || '')}" placeholder="Name, title, type or med_ id"></div>
+ <div><label for="f-kind">Kind</label><select id="f-kind" name="kind">${opt('', 'Any kind', filters.kind)}${model.KINDS.map(k => opt(k, KIND_LABEL[k] || k, filters.kind)).join('')}</select></div>
+ <div><label for="f-status">Status</label><select id="f-status" name="status">${opt('', 'Not deleted', filters.status)}${model.LIFECYCLES.map(s => opt(s, STATUS_LABEL[s] || s, filters.status)).join('')}${opt('all', 'All, deleted included', filters.status)}</select></div>
+ <div><label for="f-app">Tenant</label><select id="f-app" name="app">${opt('', 'All tenants', filters.app)}${tenants.map(t => opt(t.app_id, t.name, filters.app)).join('')}</select></div>
+ <div class="go"><button type="submit" class="btn">Apply</button><a class="btn ghost" href="/me">Clear</a></div>
+ </form>`;
+}
+
+function objectsTable(items) {
+ return `<table class="grid stack objects" id="me-objects"${items.length ? '' : ' hidden'}>
+ <caption class="sr">Your objects, newest first</caption>
+ <thead><tr><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Tenant and namespace</th><th scope="col" class="n">Size</th><th scope="col">Visibility</th><th scope="col">Status</th><th scope="col">Readiness</th><th scope="col" class="n">Derivatives</th><th scope="col">Created</th></tr></thead>
+ <tbody>${items.map(objectRow).join('\n')}</tbody>
+ </table>`;
+}
+
+function renderExplorer({ person, list, usage, filters, canOps = false }) {
+ const who = person.username ? `@${person.username}` : person.subject;
+ const tenants = usage.tenants.map(t => ({ app_id: t.app_id, name: t.name }));
+ if (filters.app && !tenants.some(t => t.app_id === filters.app)) tenants.push({ app_id: filters.app, name: filters.app });
+ const firstHref = `/me${query(filters)}`;
+ const nextHref = list.next_cursor ? `/me${query(filters, { cursor: list.next_cursor })}` : null;
+ const filtered = !!(filters.q || filters.kind || filters.status || filters.app);
+ const body = `
+ <h1>Your media</h1>
+ <p class="meta"><span>Signed in as <strong>${esc(who)}</strong></span><span aria-hidden="true">·</span><span>objects you own, in every app that stores media here</span>${canOps ? '<span aria-hidden="true">·</span><a href="/me/ops">Operator views</a>' : ''}</p>
+ <p class="note" id="me-readonly"><strong>Read-only.</strong> Change an object's visibility, delete it or restore it in the app that stored it (its tenant below): Media's object API takes those changes from the app, not from your sign-in.</p>
+ <section aria-labelledby="usage-h">
+ <h2 id="usage-h">Usage</h2>
+ <p class="hint">${esc(usage.note)} Uploading counts the declared sizes; deleted objects are kept for the retention period before their bytes are purged.</p>
+ ${usageTable(usage)}
+ </section>
+ <section aria-labelledby="objects-h">
+ <h2 id="objects-h">Objects</h2>
+ ${filtersForm(filters, tenants)}
+ <p id="me-status" class="hint" role="status" aria-live="polite">${list.objects.length ? `${num(list.objects.length)} object${list.objects.length === 1 ? '' : 's'} shown, newest first.` : ''}</p>
+ ${objectsTable(list.objects)}
+ <p class="empty" id="me-empty"${list.objects.length ? ' hidden' : ''}>${filtered ? 'No objects match these filters.' : 'No objects yet.'}</p>
+ <nav class="pager" aria-label="Pages of objects">
+ ${filters.cursor ? `<a class="btn ghost" href="${esc(firstHref)}">Newest</a>` : ''}
+ <a class="btn ghost" id="me-next" rel="next" href="${esc(nextHref || firstHref)}"${nextHref ? '' : ' hidden'}>Next page</a>
+ </nav>
+ </section>`;
+ return doc({ title: 'Your media', description: 'The media you own on OpenVibe.Media: uploads, copies, derivatives, visibility and usage.', body, script: true });
+}
+
+// ── Detail (/me/objects/:id) ─────────────────────────────────
+
+function table(caption, head, rows, { stack = true, numeric = [] } = {}) {
+ if (!rows.length) return '';
+ const th = head.map((h, i) => `<th scope="col"${numeric.includes(i) ? ' class="n"' : ''}>${esc(h)}</th>`).join('');
+ const tr = rows.map(r => `<tr>${r.map((c, i) => `<td data-label="${esc(head[i])}"${numeric.includes(i) ? ' class="n"' : ''}>${c}</td>`).join('')}</tr>`).join('\n');
+ return `<table class="grid${stack ? ' stack' : ''}"><caption class="sr">${esc(caption)}</caption><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;
+}
+
+function renderDetail({ person, obj }) {
+ const name = displayName(obj);
+ const facts = [
+ ['Id', `<code>${esc(obj.id)}</code>`],
+ ['Kind', esc(KIND_LABEL[obj.kind] || obj.kind)],
+ ['Tenant', `${esc(obj.tenant.name)} <span class="sub">${esc(obj.app_id)}${obj.tenant.sandbox ? ', sandbox' : ''}</span>`],
+ ['Namespace', esc(obj.namespace)],
+ ['File name', obj.filename ? esc(obj.filename) : '<span class="sub">none</span>'],
+ ['Type', esc(obj.mime_type || 'unknown')],
+ ['Size', `${esc(fmtBytes(obj.size_bytes))} <span class="sub">${num(obj.size_bytes)} bytes</span>`],
+ ['Visibility', `${esc(obj.visibility)}${obj.public_url ? ` · <a href="${esc(obj.public_url)}">open</a>` : ''}`],
+ ['Status', statusCell(obj)],
+ ['Readiness', readinessCell(obj)],
+ ['SHA-256', obj.content_hash ? `<code class="id">${esc(obj.content_hash)}</code>` : '<span class="sub">not computed yet</span>'],
+ ['Written through', obj.managed_by === 'v1' ? 'the app\'s v1 API (a VOD, clip, file or screenshot row)' : 'the object API (v2)'],
+ ['Created', esc(fmtWhen(obj.created_at))],
+ ['Updated', esc(fmtWhen(obj.updated_at))],
+ ];
+ if (obj.source) facts.push(['Made from', `<a href="/me/objects/${esc(obj.source.id)}">${esc(obj.source.id)}</a> <span class="sub">${esc(obj.source.relation.replace(/_/g, ' '))}</span>`]);
+ const body = `
+ <p><a href="/me">← Your media</a></p>
+ <h1>${esc(name)}</h1>
+ <p class="meta"><span>Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong></span></p>
+ <dl class="facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>
+ <section aria-labelledby="copies-h">
+ <h2 id="copies-h">Copies</h2>
+ ${table('Where the bytes are', ['Provider', 'Class', 'State', 'Size', 'Checked'], obj.locations.map(l => [
+ `${esc(l.provider)}${l.canonical ? ' <span class="tag">canonical</span>' : ''}`, esc(l.storage_class || ''), esc(l.state), esc(fmtBytes(l.size_bytes)), esc(fmtWhen(l.verified_at) || 'not yet'),
+ ]), { numeric: [3] }) || '<p class="empty">No stored copy.</p>'}
+ </section>
+ <section aria-labelledby="derived-h">
+ <h2 id="derived-h">Derivatives</h2>
+ ${table('Objects made from this one', ['Object', 'Made as', 'Kind', 'Status', 'Size'], obj.derivative_list.map(d => [
+ d.mine ? `<a href="/me/objects/${esc(d.id)}">${esc(d.id)}</a>` : `<code class="id">${esc(d.id)}</code>`, esc(d.name), esc(KIND_LABEL[d.kind] || d.kind), esc(STATUS_LABEL[d.lifecycle_status] || d.lifecycle_status), esc(fmtBytes(d.size_bytes)),
+ ]), { numeric: [4] }) || '<p class="empty">None.</p>'}
+ </section>
+ <section aria-labelledby="jobs-h">
+ <h2 id="jobs-h">Recent jobs</h2>
+ ${table('Jobs on this object, newest first', ['Job', 'Status', 'Error', 'Attempts', 'Created', 'Finished'], obj.jobs.map(j => [
+ esc(j.type), esc(j.status), esc(j.error_code || ''), `${num(j.attempts)} of ${num(j.max_attempts)}`, esc(fmtWhen(j.created_at)), esc(fmtWhen(j.finished_at)),
+ ])) || '<p class="empty">None.</p>'}
+ </section>
+ <p class="note">Read-only: visibility, deletion and restore are changed in the app that stored this object.</p>`;
+ return doc({ title: name, description: 'One of your media objects on OpenVibe.Media.', body, script: !!obj.upload });
+}
+
+// ── Operator views (/me/ops) ─────────────────────────────────
+
+function renderOps({ person, report, canRecompute, recomputed = null }) {
+ const r = report;
+ const opt = (v, label) => `<option value="${esc(v)}"${(r.scope === 'all' ? '' : r.scope) === v ? ' selected' : ''}>${esc(label)}</option>`;
+ const scopeQs = r.scope === 'all' ? '' : `?app=${encodeURIComponent(r.scope)}`;
+ const card = (value, label) => `<div class="card"><b>${value}</b><span>${esc(label)}</span></div>`;
+ const kv = (obj) => Object.entries(obj || {}).map(([k, v]) => `${esc(k.replace(/_/g, ' '))} ${num(v)}`).join(' · ') || 'none';
+ const t = r.tiering;
+ const bf = r.backfill;
+ const m = r.missing;
+ const body = `
+ <h1>Media operations</h1>
+ <p class="meta"><span>Signed in as <strong>${esc(person.username ? `@${person.username}` : person.subject)}</strong></span><span aria-hidden="true">·</span><span>generated ${esc(fmtWhen(r.generated_at))}</span></p>
+ <form class="filters" method="get" action="/me/ops" aria-label="Scope">
+ <div><label for="ops-app">Tenant</label><select id="ops-app" name="app">${opt('', 'All tenants')}${r.tenants.map(a => opt(a, a)).join('')}</select></div>
+ <div class="go"><button type="submit" class="btn">Show</button></div>
+ </form>
+ ${recomputed ? `<p class="note" role="status">Usage recomputed for ${num(recomputed)} namespace${recomputed === '1' ? '' : 's'}.</p>` : ''}
+ <div class="cols">
+ ${card(num(r.jobs.failed.total), 'failed jobs')}
+ ${card(num(m.no_good_copy.total), 'ready objects with no good copy')}
+ ${card(num(m.locations.missing_or_corrupt.length), 'missing or corrupt copies listed')}
+ ${card(num(Object.values(bf.unprojected).reduce((a, b) => a + b, 0)), 'rows without an object')}
+ ${card(num(t.pending_offload), 'VODs eligible to offload')}
+ </div>
+
+ <section aria-labelledby="jobs-h">
+ <h2 id="jobs-h">Failed jobs</h2>
+ <p class="hint">Jobs by status: ${kv(r.jobs.by_status)}.</p>
+ <h3>Last 7 days by type and error</h3>
+ ${table('Failed jobs in the last 7 days', ['Type', 'Error code', 'Jobs'], r.jobs.failed.last_7_days.map(x => [esc(x.type), esc(x.error_code || 'none'), num(x.count)]), { numeric: [2] }) || '<p class="empty">None.</p>'}
+ <h3>Most recent</h3>
+ ${table('Most recent failed jobs', ['Finished', 'Tenant', 'Job', 'Error', 'Attempts', 'Object'], r.jobs.failed.recent.map(j => [
+ esc(fmtWhen(j.finished_at)), esc(j.app_id), `${esc(j.type)}<span class="id">${esc(j.id)}</span>`,
+ `${j.error_code ? `<code>${esc(j.error_code)}</code>` : ''}${j.error ? `<pre class="err">${esc(j.error)}</pre>` : ''}`,
+ `${num(j.attempts)} of ${num(j.max_attempts)}`, j.object_id ? `<code class="id">${esc(j.object_id)}</code>` : '',
+ ])) || '<p class="empty">None.</p>'}
+ </section>
+
+ <section aria-labelledby="usage-h">
+ <h2 id="usage-h">Usage</h2>
+ <p class="hint">The namespaces' usage snapshot (refreshed at complete, delete and restore, when read, and hourly). Checks always count from the rows.</p>
+ ${canRecompute ? `<form method="post" action="/me/ops/recompute${scopeQs}"><button type="submit" class="btn">Recompute usage${r.scope === 'all' ? '' : ` of ${esc(r.scope)}`} now</button></form>` : '<p class="hint">Recomputing needs the staff capability <code>staff.site.configure</code>.</p>'}
+ ${table('Namespaces', ['Tenant', 'Namespace', 'Quota', 'Used', 'Reserved', 'Snapshot'], r.namespaces.map(n => [
+ esc(n.app_id), esc(n.namespace),
+ `${n.quota.bytes ? esc(fmtBytes(n.quota.bytes)) : 'no byte limit'}${n.quota.objects ? ` · ${num(n.quota.objects)} objects` : ''}`,
+ `${esc(fmtBytes(n.usage.used_bytes))} · ${num(n.usage.used_objects)}`, `${esc(fmtBytes(n.usage.reserved_bytes))} · ${num(n.usage.reserved_objects)}`,
+ esc(fmtWhen(n.usage.reconciled_at) || 'never'),
+ ])) || '<p class="empty">No namespaces.</p>'}
+ </section>
+
+ <section aria-labelledby="missing-h">
+ <h2 id="missing-h">Missing media</h2>
+ <p class="hint">${esc(m.note)}</p>
+ <p>Quarantined VODs: ${kv(m.vods_quarantined)}. Verification: ${kv(m.verification.statuses)}${m.verification.last_run ? `; last run ${esc(fmtWhen(m.verification.last_run.finished_at))}, ${num(m.verification.last_run.objects_checked)} checked, ${num(m.verification.last_run.no_good_copy)} with no good copy` : '; no run yet'}.</p>
+ <h3>No good copy</h3>
+ ${table('Ready objects with no good copy', ['Object', 'Tenant', 'Kind', 'Size', 'Last verification'], m.no_good_copy.objects.map(o => [
+ `<code class="id">${esc(o.id)}</code>${o.legacy_ref ? `<span class="sub">${esc(o.legacy_ref)}</span>` : ''}`, esc(o.app_id), esc(o.kind), esc(fmtBytes(o.size_bytes)),
+ o.last_verification ? `${esc(o.last_verification.status)} ${esc(fmtWhen(o.last_verification.at))}` : 'never',
+ ]), { numeric: [3] }) || '<p class="empty">None.</p>'}
+ <h3>Missing or corrupt copies</h3>
+ ${table('Copies recorded missing or corrupt', ['Object', 'Tenant', 'Kind', 'Provider', 'State', 'Checked'], m.locations.missing_or_corrupt.map(l => [
+ `<code class="id">${esc(l.object_id)}</code>${l.legacy_ref ? `<span class="sub">${esc(l.legacy_ref)}</span>` : ''}`, esc(l.app_id), esc(l.kind), esc(l.provider), esc(l.state), esc(fmtWhen(l.verified_at)),
+ ])) || '<p class="empty">None.</p>'}
+ <h3>Copies by provider and state</h3>
+ ${table('Copies by provider and state', ['Provider', 'State', 'Copies'], m.locations.by_provider_state.map(x => [esc(x.provider), esc(x.state), num(x.count)]), { numeric: [2] }) || '<p class="empty">None.</p>'}
+ </section>
+
+ <section aria-labelledby="backfill-h">
+ <h2 id="backfill-h">Backfill</h2>
+ <p class="hint">${esc(bf.note)}</p>
+ <dl class="facts">
+ <dt>Last object backfill</dt><dd>${bf.objects ? `${esc(fmtWhen(bf.objects.finished_at))}${bf.objects.dry_run ? ' (dry run)' : ''}: ${bf.objects.totals ? kv(bf.objects.totals) : 'no totals'}; ${num(bf.objects.errors)} errors, ${num(bf.objects.skipped)} skipped` : 'none recorded'}</dd>
+ <dt>Rows without an object</dt><dd>${kv(bf.unprojected)}</dd>
+ <dt>Owner subject unresolved</dt><dd>${num(bf.owner_subject.missing)}${bf.owner_subject.by_app.length ? ` (${bf.owner_subject.by_app.map(a => `${esc(a.app_id)} ${num(a.count)}`).join(', ')})` : ''}</dd>
+ <dt>Upload reservations seeded</dt><dd>${bf.namespace_reservations_seeded ? 'yes' : 'no'}</dd>
+ </dl>
+ </section>
+
+ <section aria-labelledby="tiering-h">
+ <h2 id="tiering-h">Tiering</h2>
+ <p class="hint">${esc(t.note)}</p>
+ <dl class="facts">
+ <dt>Providers</dt><dd>B2 ${t.providers.b2 ? 'configured' : 'not configured'} · R2 ${t.providers.r2 ? 'configured' : 'not configured'}</dd>
+ <dt>Policy</dt><dd>offload ${t.policy.enabled ? 'on' : 'off'} (age ${esc(t.policy.min_age_days)} d, at most ${esc(t.policy.max_views_for_cold)} views, idle ${esc(t.policy.min_last_access_days)} d) · R2 ${t.policy.r2_enabled ? 'on' : 'off'}</dd>
+ <dt>Sweep</dt><dd>${t.sweep.running ? 'running' : 'idle'}${t.sweep.stalled ? ` · <strong>stalled</strong> (${num(t.sweep.stalled_passes)} passes)` : ''}; last ${esc(fmtWhen(t.sweep.last_run_at) || 'not in this process')}${t.sweep.next_run_at ? `; next ${esc(fmtWhen(t.sweep.next_run_at))}` : ''}${t.sweep.last_result ? `; moved ${num(t.sweep.last_result.migrated)} to B2, ${num(t.sweep.last_result.promoted)} to R2, ${num(t.sweep.last_result.demoted)} back, ${num(t.sweep.last_result.errors)} errors` : ''}</dd>
+ <dt>Eligible to offload</dt><dd>${num(t.pending_offload)} VODs</dd>
+ <dt>R2 decisions, 24 h</dt><dd>promote: ${kv(t.decisions_24h.promote)} · demote: ${kv(t.decisions_24h.demote)}</dd>
+ </dl>
+ ${table('VODs by provider', ['Provider', 'VODs', 'Bytes'], Object.entries(t.vods).map(([p, v]) => [esc(p), num(v.count), esc(fmtBytes(v.bytes))]), { numeric: [1, 2] })}
+ ${table('Objects by canonical copy', ['Origin', 'Canonical copy', 'Objects', 'Bytes'], t.objects.map(o => [esc(o.origin), esc(o.canonical_provider), num(o.count), esc(fmtBytes(o.bytes))]), { numeric: [2, 3] })}
+ <h3>Recent refused or failed moves</h3>
+ ${table('Recent refused or failed tier moves', ['When', 'Tenant', 'VOD', 'Move', 'Outcome', 'Why'], t.recent_problems.map(d => [
+ esc(fmtWhen(d.decided_at)), esc(d.app_id || ''), esc(d.vod_id), `${esc(d.action)} ${esc(d.from_provider || '')} → ${esc(d.to_provider || '')}`, esc(d.outcome), esc(d.error || d.reason || ''),
+ ])) || '<p class="empty">None.</p>'}
+ </section>
+ <p class="hint">JSON: <a href="/api/v2/me/ops${scopeQs}">/api/v2/me/ops</a>.</p>`;
+ return doc({ title: 'Media operations', description: 'Operator views of OpenVibe.Media: failed jobs, usage, missing media, backfill and tiering.', body });
+}
+
+module.exports = {
+ renderSignIn, renderForbidden, renderDrill, renderExplorer, renderDetail, renderOps,
+ fmtBytes, fmtWhen, displayName, CLIENT_SOURCE, CLIENT_VERSION,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -0,0 +1,248 @@
+/**
+ * OpenVibe.Media — the object explorer (roadmap WS-G task 12; docs/object-model.md#object-explorer).
+ *
+ * The person signed in to openvibe.media (the Network access token: the ov_token cookie, or a bearer
+ * token) sees the media objects whose owner_subject is their Network subject (usr_…), in every tenant:
+ *
+ * GET /api/v2/me/objects ?cursor&limit (≤100)&status (a lifecycle, or all)&kind&q&app → { objects, next_cursor, limit }
+ * GET /api/v2/me/objects/:id one of them: copies, derivatives, recent jobs (404 when it is not theirs)
+ * GET /api/v2/me/usage their objects and bytes per tenant and namespace (no tenant's quota)
+ * GET /me, /me/objects/:id the same as pages (complete without JavaScript; /me/app.js enhances /me)
+ *
+ * Read-only: visibility, delete and restore stay with the apps. The object API takes them only from a
+ * tenant credential (tenantAuth answers a user JWT 403), so the explorer does not act for a person.
+ *
+ * Operator views, for Network staff (contracts staff map, ADR-022): staff.site.view reads,
+ * staff.site.configure recomputes (POST, same-origin only when the credential is the cookie):
+ *
+ * GET /api/v2/me/ops[?app] failed jobs, missing media, backfill, tiering, namespaces (me/ops.js)
+ * POST /api/v2/me/ops/recompute[?app] refresh the namespaces' usage snapshot
+ * GET /me/ops, POST /me/ops/recompute the page and its form
+ *
+ * The app-key twin of the operator report is GET/POST /api/v1/:app/admin/storage/ops (admin/routes.js).
+ * Tokens that are not a person's access token for this site are refused: service and app principals,
+ * FedCM assertions, and tokens whose audience does not include openvibe.media. Every answer is private,
+ * no-store and noindex. A restore drill (MEDIA_DRILL) signs nobody in: the API answers 503.
+ */
+'use strict';
+
+const express = require('express');
+const { http, ids, staff } = require('openvibe-contracts');
+const config = require('../config');
+const drill = require('../drill');
+const { extractToken } = require('../user-auth');
+const explorer = require('./explorer');
+const pages = require('./pages');
+
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const SITE_ORIGIN = (() => { try { return new URL(config.publicUrl).origin; } catch { return null; } })();
+
+function problem(res, status, code, detail) {
+ return http.sendProblem(res, status, code, { detail });
+}
+
+function privateHeaders(_req, res, next) {
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Robots-Tag', 'noindex, nofollow');
+ res.set('X-Content-Type-Options', 'nosniff');
+ next();
+}
+
+/**
+ * The person a verified token names, or { error }: a Network access token for this site (audience
+ * openvibe.media), a person (not a service or app principal, not a FedCM assertion), with a subject.
+ */
+function personFromClaims(claims) {
+ if (!claims) return { error: 'invalid' };
+ if (PRINCIPAL_SUB.test(String(claims.sub || '')) || claims.actor_type === 'service' || claims.actor_type === 'app') return { error: 'invalid' };
+ if (claims.typ === 'fedcm') return { error: 'invalid' };
+ const aud = Array.isArray(claims.aud) ? claims.aud : (claims.aud ? [claims.aud] : []);
+ if (!aud.includes('openvibe.media')) return { error: 'invalid' };
+ if (!ids.isSubjectId('user', claims.subject_id)) return { error: 'no_subject' };
+ return {
+ person: {
+ subject: claims.subject_id,
+ username: typeof claims.username === 'string' ? claims.username : null,
+ display_name: typeof claims.display_name === 'string' ? claims.display_name : null,
+ claims,
+ },
+ };
+}
+
+/** req.person (or req.personError: none | invalid | no_subject | drill) and req.viaCookie. Never blocks. */
+function identify(auth) {
+ return async (req, _res, next) => {
+ req.person = null;
+ if (drill.enabled) { req.personError = 'drill'; return next(); }
+ const token = extractToken(req);
+ if (!token) { req.personError = 'none'; return next(); }
+ req.viaCookie = !(req.headers.authorization && req.headers.authorization.startsWith('Bearer '));
+ let claims = null;
+ try { claims = await auth.verify(token); } catch { claims = null; }
+ const r = personFromClaims(claims);
+ if (r.person) req.person = r.person; else req.personError = r.error;
+ next();
+ };
+}
+
+/** A cookie-borne write must come from this site's own pages (the cookie is SameSite=Lax as well). */
+function sameOrigin(req) {
+ if (!req.viaCookie) return true;
+ const site = String(req.headers['sec-fetch-site'] || '');
+ if (site) return site === 'same-origin';
+ const origin = String(req.headers.origin || '');
+ return !!origin && origin === SITE_ORIGIN;
+}
+
+const can = (person, capability) => {
+ try { return !!person && staff.can(person.claims, capability); } catch { return false; }
+};
+
+/** 401/503 problems for an API caller who is not a signed-in person. */
+function needPerson(req, res) {
+ if (req.person) return true;
+ if (req.personError === 'drill') problem(res, 503, 'media.drill_no_sign_in', 'This is a restore-drill instance (MEDIA_DRILL): no sign-in');
+ else if (req.personError === 'no_subject') problem(res, 401, 'auth.no_subject', 'Your sign-in carries no network subject: sign in again');
+ else if (req.personError === 'invalid') problem(res, 401, 'auth.invalid', 'Invalid or expired token');
+ else problem(res, 401, 'auth.required', 'Sign in to see your media');
+ return false;
+}
+
+function opsScope(req) {
+ const app = req.query.app;
+ if (app == null || app === '') return { appId: null };
+ return /^[A-Za-z0-9_.-]{1,80}$/.test(String(app)) ? { appId: String(app) } : { error: 'Bad app' };
+}
+
+/**
+ * { api, pages }: `api` is mounted at /api/v2/me (ahead of /api/v2/:app/…, so "me" is never a tenant
+ * there), `pages` at /me. `auth` is user-auth.createAuthClient's (verify(token) → claims | null).
+ */
+function createMeRoutes({ auth }) {
+ const api = express.Router();
+ api.use(privateHeaders, identify(auth));
+
+ api.get('/objects', (req, res) => {
+ if (!needPerson(req, res)) return;
+ const f = explorer.parseFilters(req.query);
+ if (f.error) return problem(res, 400, 'media.me.invalid', f.error);
+ try {
+ res.json(explorer.list(req.person.subject, f.filters));
+ } catch (err) {
+ console.error('[Me] list error:', err.message);
+ problem(res, 500, 'media.me.list_failed', 'Failed to list your objects');
+ }
+ });
+
+ api.get('/objects/:id', (req, res) => {
+ if (!needPerson(req, res)) return;
+ const obj = explorer.detail(req.person.subject, req.params.id);
+ if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object of yours');
+ res.json(obj);
+ });
+
+ api.get('/usage', (req, res) => {
+ if (!needPerson(req, res)) return;
+ try {
+ res.json(explorer.usage(req.person.subject));
+ } catch (err) {
+ console.error('[Me] usage error:', err.message);
+ problem(res, 500, 'media.me.usage_failed', 'Failed to count your usage');
+ }
+ });
+
+ api.get('/ops', (req, res) => {
+ if (!needPerson(req, res)) return;
+ if (!can(req.person, 'staff.site.view')) return problem(res, 403, 'capability.denied', 'staff.site.view not granted');
+ const s = opsScope(req);
+ if (s.error) return problem(res, 400, 'media.me.invalid', s.error);
+ try {
+ res.json(require('./ops').report({ appId: s.appId, limit: req.query.limit }));
+ } catch (err) {
+ console.error('[Me] ops report error:', err.message);
+ problem(res, 500, 'media.ops.report_failed', 'Failed to build the operator report');
+ }
+ });
+
+ api.post('/ops/recompute', (req, res) => {
+ if (!needPerson(req, res)) return;
+ if (!can(req.person, 'staff.site.configure')) return problem(res, 403, 'capability.denied', 'staff.site.configure not granted');
+ if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');
+ const s = opsScope(req);
+ if (s.error) return problem(res, 400, 'media.me.invalid', s.error);
+ const out = require('./ops').recompute({ appId: s.appId });
+ console.log(`[Me] Namespace usage recomputed by ${req.person.subject} (${out.scope}): ${out.namespaces} namespace(s)`);
+ res.json(out);
+ });
+
+ api.use((req, res) => problem(res, 404, 'media.not_found', 'No such route'));
+
+ // ── Pages ────────────────────────────────────────────────
+ const site = express.Router();
+ site.use(privateHeaders);
+
+ // The enhancement script (client.js). Versioned by content: a matching ?v= is cached for a year.
+ site.get('/app.js', (req, res) => {
+ res.set('Cache-Control', req.query.v === pages.CLIENT_VERSION ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.type('application/javascript').send(pages.CLIENT_SOURCE);
+ });
+
+ site.use(identify(auth));
+ const html = (res, status, body) => res.status(status).type('html').send(body);
+
+ /** The page for someone who is not (validly) signed in: the sign-in prompt, or the drill notice. */
+ function signedOut(req, res, heading, next) {
+ if (req.personError === 'drill') return html(res, 503, pages.renderDrill(heading));
+ const reason = req.personError === 'no_subject' ? 'Your sign-in carries no network subject yet: sign in again.'
+ : req.personError === 'invalid' ? 'Your sign-in has expired.' : null;
+ return html(res, 200, pages.renderSignIn({ next, heading, reason }));
+ }
+
+ site.get('/', (req, res) => {
+ if (!req.person) return signedOut(req, res, 'Your media', '/me');
+ const f = explorer.parseFilters(req.query);
+ const filters = f.error ? explorer.parseFilters({}).filters : f.filters;
+ try {
+ html(res, f.error ? 400 : 200, pages.renderExplorer({
+ person: req.person, filters, list: explorer.list(req.person.subject, filters), usage: explorer.usage(req.person.subject),
+ canOps: can(req.person, 'staff.site.view'),
+ }));
+ } catch (err) {
+ console.error('[Me] page error:', err.message);
+ html(res, 500, pages.renderSignIn({ heading: 'Your media', reason: 'Something went wrong loading your media.' }));
+ }
+ });
+
+ site.get('/objects/:id', (req, res, next) => {
+ if (!req.person) return signedOut(req, res, 'Your media', `/me/objects/${encodeURIComponent(req.params.id)}`);
+ const obj = explorer.detail(req.person.subject, req.params.id);
+ if (!obj) return next(); // the site's own not-found, exactly as for an id that does not exist
+ html(res, 200, pages.renderDetail({ person: req.person, obj }));
+ });
+
+ site.get('/ops', (req, res) => {
+ if (!req.person) return signedOut(req, res, 'Media operations', '/me/ops');
+ if (!can(req.person, 'staff.site.view')) return html(res, 403, pages.renderForbidden({ person: req.person, need: 'staff.site.view' }));
+ const s = opsScope(req);
+ html(res, s.error ? 400 : 200, pages.renderOps({
+ person: req.person, report: require('./ops').report({ appId: s.error ? null : s.appId }), canRecompute: can(req.person, 'staff.site.configure'),
+ recomputed: /^\d+$/.test(String(req.query.recomputed || '')) ? String(req.query.recomputed) : null,
+ }));
+ });
+
+ site.post('/ops/recompute', (req, res) => {
+ if (!req.person) return signedOut(req, res, 'Media operations', '/me/ops');
+ if (!can(req.person, 'staff.site.configure')) return html(res, 403, pages.renderForbidden({ person: req.person, need: 'staff.site.configure' }));
+ if (!sameOrigin(req)) return problem(res, 403, 'media.request.cross_origin', 'Only this site\'s own pages may ask for this');
+ const s = opsScope(req);
+ const out = require('./ops').recompute({ appId: s.error ? null : s.appId });
+ console.log(`[Me] Namespace usage recomputed by ${req.person.subject} (${out.scope}): ${out.namespaces} namespace(s)`);
+ const qs = new URLSearchParams({ ...(s.appId ? { app: s.appId } : {}), recomputed: String(out.namespaces) });
+ res.redirect(303, `/me/ops?${qs}`);
+ });
+
+ return { api, pages: site };
+}
+
+module.exports = { createMeRoutes, personFromClaims, sameOrigin };
@@ -68,8 +68,9 @@ function sitemapEntries() {
@@ -68,8 +68,9 @@ function sitemapEntries() {
function robotsTxt() {
function robotsTxt() {
return seo.robotsTxt({
return seo.robotsTxt({
sitemaps: [`${config.publicUrl}/sitemap.xml`],
sitemaps: [`${config.publicUrl}/sitemap.xml`],
- // APIs, sign-in, signed object bytes, operator and dev-data endpoints are not pages.
- disallow: ['/api/', '/auth/', '/internal/', '/o/', '/metrics', '/release-metrics', '/live/'],
+ // APIs, sign-in, signed object bytes, operator and dev-data endpoints are not pages; /me is a
+ // signed-in person's own media (noindex as well).
+ disallow: ['/api/', '/auth/', '/internal/', '/o/', '/metrics', '/release-metrics', '/live/', '/me'],
});
});
}
}
@@ -138,6 +138,8 @@ function baseCss() {
@@ -138,6 +138,8 @@ function baseCss() {
function frameScripts({ history, footer } = {}) {
function frameScripts({ history, footer } = {}) {
const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, logoutUrl: '/auth/logout?next={path}', sessionUrl: '/auth/me' };
const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, logoutUrl: '/auth/logout?next={path}', sessionUrl: '/auth/me' };
if (history) navOpts.history = history;
if (history) navOpts.history = history;
+ // The account menu's section for this site: the signed-in person's own media (server/me/, WS-G task 12).
+ navOpts.menu = { before: [{ id: 'media-mine', label: 'Your media', href: '/me', icon: 'fa-photo-film' }] };
const footOpts = {
const footOpts = {
service: 'media',
service: 'media',
variant: (footer && footer.variant) || 'compact',
variant: (footer && footer.variant) || 'compact',
@@ -1333,6 +1333,23 @@ async function probeProvider(name) {
@@ -1333,6 +1333,23 @@ async function probeProvider(name) {
}
}
}
}
+/**
+ * The sweep's state alone, without the local directory walk getStatus() makes (the operator views,
+ * server/me/ops.js, read it on every page load).
+ */
+function sweepInfo() {
+ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
+ return {
+ running: sweepRunning,
+ startedAt: iso(sweepState.startedAt),
+ lastRunAt: iso(sweepState.lastRunAt),
+ nextRunAt: iso(sweepState.nextRunAt),
+ lastResult: sweepState.lastResult,
+ stalled: sweepState.stalled,
+ stalledPasses: sweepState.stalledPasses,
+ };
+}
+
function getStatus() {
function getStatus() {
const settings = getSettings();
const settings = getSettings();
const localDisk = diskUsage(config.vod.path);
const localDisk = diskUsage(config.vod.path);
@@ -1432,6 +1449,7 @@ module.exports = {
@@ -1432,6 +1449,7 @@ module.exports = {
start,
start,
stop,
stop,
getStatus,
getStatus,
+ sweepInfo,
bucketStatus,
bucketStatus,
probeProvider,
probeProvider,
getBucketUsage,
getBucketUsage,
@@ -280,6 +280,12 @@ const safe = { DB_PATH, HOST: '127.0.0.1', PORT: '14100' };
@@ -280,6 +280,12 @@ const safe = { DB_PATH, HOST: '127.0.0.1', PORT: '14100' };
}
}
const login = await request('GET', '/auth/login');
const login = await request('GET', '/auth/login');
assert.strictEqual(login.status, 503);
assert.strictEqual(login.status, 503);
+ // The object explorer signs nobody in either, and verifies no token (that would ask Network for its key).
+ const mine = await request('GET', '/api/v2/me/objects', { headers: { Cookie: 'ov_token=a.b.c' } });
+ assert.deepStrictEqual([mine.status, mine.json && mine.json.code], [503, 'media.drill_no_sign_in']);
+ const mePage = await request('GET', '/me', { headers: { Cookie: 'ov_token=a.b.c' } });
+ assert.ok(mePage.status === 503 && mePage.text.includes('restore-drill'), `/me: ${mePage.status}`);
+ assert.deepStrictEqual(drill.blocked, [], 'nothing tried to leave');
assert.deepStrictEqual(counts(), countsBefore);
assert.deepStrictEqual(counts(), countsBefore);
});
});
await check('webhooks and the Events relay stay off even when called', async () => {
await check('webhooks and the Events relay stay off even when called', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,382 @@
@@ -0,0 +1,382 @@
+'use strict';
+// The object explorer (roadmap WS-G task 12; server/me/): a signed-in person's own objects across
+// tenants (/api/v2/me/objects, /usage, /me pages) and the operator views (/api/v2/me/ops, /me/ops,
+// /api/v1/:app/admin/storage/ops).
+// - owner scoping: only objects whose owner_subject is the caller's subject, never another person's
+// (list, filters, detail, page), and only a person's access token for this site identifies them;
+// - signed out: the API answers 401, the page is a sign-in prompt;
+// - cursor pagination, filters, upload progress (multipart parts, stored bytes, a PUT streaming),
+// derivatives, holds (only whether), usage per tenant and namespace with no quota;
+// - the page renders without JavaScript (rows, pager, usage), escapes what it shows, is noindex,
+// is left out of the sitemap and disallowed in robots.txt;
+// - operator views for staff.site.view only; the recompute for staff.site.configure, same-origin
+// only; the app-key twin in the admin routes (never acting for a user).
+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-me-'));
+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.MEDIA_DRILL;
+
+const { ids } = require('openvibe-contracts');
+const db = require('../server/db/database');
+require('../server/views/service').ensureSchema();
+const model = require('../server/objects/model');
+const namespaces = require('../server/objects/namespaces');
+const { createMeRoutes, personFromClaims } = require('../server/me/routes');
+const express = require('express');
+
+db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });
+db.upsertApp({ app_id: 'tools', name: 'OpenVibe.Tools', api_key: 'tools-key' });
+for (const a of ['live', 'tools']) db.ensureRootNamespace(db.getApp(a));
+namespaces.ensure(db.getApp('live'), 'live.avatars');
+
+const ANA = `usr_${ids.ulid()}`;
+const BEN = `usr_${ids.ulid()}`;
+const ALEX = `usr_${ids.ulid()}`;
+const base = Date.now() - 3600e3;
+let tick = 0;
+const make = (o) => model.createObject({ app_id: 'live', kind: 'file', visibility: 'private', lifecycle_status: 'ready', mime_type: 'text/plain', size_bytes: 100, createdMs: base + (tick++) * 1000, ...o });
+const present = (id, bytes, key = path.join(process.env.OBJECTS_PATH, id)) => model.upsertLocation(id, { provider: 'local', key, state: 'present', size_bytes: bytes, verified: true });
+
+// ── Fixtures: Ana's objects ──
+const anaReady = [];
+for (let i = 0; i < 30; i++) {
+ const id = make({ owner_subject: ANA, metadata: { filename: `note-${i}.txt` }, size_bytes: 1000 + i });
+ present(id, 1000 + i);
+ anaReady.push(id);
+}
+const tricky = make({ owner_subject: ANA, visibility: 'public', metadata: { filename: '<script>alert(1)</script>.txt' }, size_bytes: 7 });
+present(tricky, 7);
+const avatar = make({ owner_subject: ANA, namespace: 'live.avatars', kind: 'avatar', mime_type: 'image/png', metadata: { filename: 'me.png' }, size_bytes: 500 });
+present(avatar, 500);
+const inTools = make({ app_id: 'tools', owner_subject: ANA, metadata: { filename: 'result.json' }, mime_type: 'application/json', size_bytes: 2048 });
+present(inTools, 2048);
+// Uploading, multipart: 2 of 4 parts of 1 MiB received.
+const multi = make({ owner_subject: ANA, lifecycle_status: 'uploading', mime_type: 'video/mp4', kind: 'vod', metadata: { filename: 'big.mp4' }, size_bytes: 4 * 1048576 });
+namespaces.reserve(model.getObject(multi), 4 * 1048576);
+db.run("INSERT INTO media_uploads (id, object_id, app_id, part_size, total_size, parts_expected, status, expires_at) VALUES ('mup_TEST', ?, 'live', 1048576, ?, 4, 'active', datetime('now', '+1 day'))", [multi, 4 * 1048576]);
+for (const n of [1, 3]) db.run("INSERT INTO media_upload_parts (upload_id, part_number, size_bytes, sha256) VALUES ('mup_TEST', ?, 1048576, 'x')", [n]);
+// Uploading, single part, bytes stored and waiting for complete.
+const stored = make({ owner_subject: ANA, lifecycle_status: 'uploading', metadata: { filename: 'stored.txt' }, size_bytes: 300 });
+present(stored, 300);
+// Uploading, a PUT still streaming (a temp file under OBJECTS_PATH/.tmp).
+const streaming = make({ owner_subject: ANA, lifecycle_status: 'uploading', metadata: { filename: 'streaming.txt' }, size_bytes: 1000 });
+fs.mkdirSync(path.join(process.env.OBJECTS_PATH, '.tmp'), { recursive: true });
+fs.writeFileSync(path.join(process.env.OBJECTS_PATH, '.tmp', `${streaming}-abcd1234`), Buffer.alloc(250));
+const deleted = make({ owner_subject: ANA, lifecycle_status: 'deleted', metadata: { filename: 'gone.txt', retention_until: new Date(Date.now() + 864e5).toISOString() }, size_bytes: 40 });
+const failed = make({ owner_subject: ANA, lifecycle_status: 'failed', metadata: { filename: 'expired.txt', failure: 'upload_expired' }, size_bytes: 60 });
+// A derivative (waveform variant) of one of Ana's, and a held one.
+const wave = make({ owner_subject: ANA, kind: 'asset', mime_type: 'image/png', metadata: { derived_from: anaReady[0] }, size_bytes: 10 });
+present(wave, 10);
+model.setRelationship(wave, 'derived_from', anaReady[0], {});
+model.setVariant(anaReady[0], 'waveform', wave, 'object.waveform@1');
+model.placeHold({ object_id: anaReady[1], kind: 'evidence', reason: 'SECRET-HOLD-REASON', created_by: 'staff:x', note: 'SECRET-NOTE' });
+// ── Ben's objects (must never show up for Ana) ──
+const benIds = [];
+for (let i = 0; i < 3; i++) { const id = make({ owner_subject: BEN, metadata: { filename: `ben-${i}.txt` }, size_bytes: 999999 }); present(id, 999999); benIds.push(id); }
+const benUploading = make({ owner_subject: BEN, lifecycle_status: 'uploading', size_bytes: 5 });
+// An object with no subject at all.
+make({ owner_subject: null, owner_user_id: 5, metadata: { filename: 'nobody.txt' } });
+// A failed job, for the operator views.
+db.run(`INSERT INTO media_jobs (id, app_id, object_id, job_type, status, error, error_code, attempts, max_attempts, finished_at)
+ VALUES ('mjob_TESTFAILED', 'live', ?, 'object.sprite', 'failed', 'ffmpeg exited 1', 'no_video', 1, 3, CURRENT_TIMESTAMP)`, [anaReady[2]]);
+// A missing copy and a no-good-copy object.
+const lost = make({ owner_subject: BEN, size_bytes: 1 });
+model.upsertLocation(lost, { provider: 'b2', key: 'k/lost', state: 'missing', size_bytes: 1, verified: true });
+
+// ── Tokens: what auth.verify (the JWKS-backed verifier) would answer ──
+const AUD = ['openvibe.live', 'openvibe.media', 'openvibe.network'];
+const CLAIMS = {
+ ana: { sub: 7, subject_id: ANA, username: 'ana', role: 'user', aud: AUD },
+ ben: { sub: 8, subject_id: BEN, username: 'ben', role: 'user', aud: AUD },
+ admin: { sub: 1, subject_id: ALEX, username: 'alex', role: 'admin', aud: AUD },
+ mod: { sub: 2, subject_id: `usr_${ids.ulid()}`, username: 'mo', role: 'global_mod', aud: AUD },
+ liveOnly: { sub: 7, subject_id: ANA, username: 'ana', aud: ['openvibe.live'] },
+ service: { sub: 'svc:community', subject_id: ANA, aud: AUD, actor_type: 'service' },
+ fedcm: { sub: 7, subject_id: ANA, aud: AUD, typ: 'fedcm' },
+ noSubject: { sub: 7, username: 'ana', aud: AUD },
+};
+const auth = { verify: async (token) => CLAIMS[token] || null };
+
+const app = express();
+app.use((req, _res, next) => { // as server/index.js parses cookies
+ req.cookies = {};
+ for (const part of String(req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i > 0) req.cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); }
+ next();
+});
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+const me = createMeRoutes({ auth });
+app.use('/api/v2/me', me.api);
+app.use('/me', me.pages);
+app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
+app.use('/', require('../server/public/crawl'));
+app.use(require('../server/not-found').notFound);
+
+(async () => {
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const call = (method, p, { token = null, cookie = null, headers = {}, body = null } = {}) => new Promise((resolve, reject) => {
+ const data = body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body));
+ const h = { ...headers };
+ if (token) h.authorization = `Bearer ${token}`;
+ if (cookie) h.cookie = `ov_token=${cookie}`;
+ if (data) { h['content-type'] = h['content-type'] || 'application/json'; h['content-length'] = Buffer.byteLength(data); }
+ const rq = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers: h }, (res) => {
+ let b = ''; res.on('data', (c) => { b += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(b); } catch { /* html */ } resolve({ status: res.statusCode, headers: res.headers, text: b, json }); });
+ });
+ rq.on('error', reject);
+ rq.end(data);
+ });
+ const all = async (token, qs = '') => {
+ const seen = [];
+ let cursor = null, pages = 0;
+ do {
+ const r = await call('GET', `/api/v2/me/objects?limit=7${qs}${cursor ? `&cursor=${cursor}` : ''}`, { token });
+ assert.strictEqual(r.status, 200, r.text);
+ seen.push(...r.json.objects);
+ cursor = r.json.next_cursor;
+ assert.ok(++pages < 50);
+ } while (cursor);
+ return seen;
+ };
+ try {
+ // ── Signed out ──
+ let r = await call('GET', '/api/v2/me/objects');
+ assert.deepStrictEqual([r.status, r.json.code], [401, 'auth.required']);
+ assert.strictEqual(r.headers['cache-control'], 'private, no-store');
+ assert.strictEqual((await call('GET', '/api/v2/me/usage')).status, 401);
+ assert.strictEqual((await call('GET', `/api/v2/me/objects/${anaReady[0]}`)).status, 401);
+ r = await call('GET', '/me');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('href="/auth/login?next=%2Fme"'), 'the sign-in prompt comes back to /me');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));
+ assert.ok(r.headers['x-robots-tag'].includes('noindex') && r.headers['cache-control'] === 'private, no-store');
+ assert.ok(!r.text.includes(anaReady[0]) && !r.text.includes('<table'), 'nothing of anyone is shown');
+ assert.ok(r.text.includes('navbar.js') && r.text.includes('"sessionUrl":"/auth/me"'), 'in the OpenVibe Frame');
+ console.log('✅ signed out: the API answers 401, /me is a sign-in prompt in the Frame');
+
+ // ── Only a person's access token for this site identifies them ──
+ assert.strictEqual((await call('GET', '/api/v2/me/objects', { token: 'forged' })).json.code, 'auth.invalid');
+ for (const t of ['liveOnly', 'service', 'fedcm']) {
+ r = await call('GET', '/api/v2/me/objects', { token: t });
+ assert.deepStrictEqual([r.status, r.json.code], [401, 'auth.invalid'], t);
+ }
+ assert.strictEqual((await call('GET', '/api/v2/me/objects', { token: 'noSubject' })).json.code, 'auth.no_subject');
+ assert.ok(personFromClaims(CLAIMS.ana).person && !personFromClaims(null).person);
+ r = await call('GET', '/me', { cookie: 'noSubject' });
+ assert.ok(r.text.includes('no network subject') && r.text.includes('/auth/login'), 'a sign-in without a subject is asked to sign in again');
+ console.log('✅ identity: audience openvibe.media, a person (no principals, no FedCM assertions) with a usr_ subject');
+
+ // ── Owner scoping and pagination ──
+ const anaAll = await all('ana', '&status=all');
+ const anaIds = anaAll.map(o => o.id);
+ assert.strictEqual(new Set(anaIds).size, anaIds.length, 'no object twice across pages');
+ assert.deepStrictEqual([...anaIds].sort().reverse(), anaIds, 'newest first');
+ const expected = db.all('SELECT id FROM media_objects WHERE owner_subject = ? ORDER BY id DESC', [ANA]).map(x => x.id);
+ assert.deepStrictEqual(anaIds, expected, 'every one of her objects, in every tenant and status');
+ for (const b of [...benIds, benUploading, lost]) assert.ok(!anaIds.includes(b), 'never another person\'s object');
+ const benAll = await all('ben', '&status=all');
+ assert.deepStrictEqual(benAll.map(o => o.id).sort(), [...benIds, benUploading, lost].sort());
+ r = await call('GET', `/api/v2/me/objects/${benIds[0]}`, { token: 'ana' });
+ assert.deepStrictEqual([r.status, r.json.code], [404, 'media.object.not_found'], 'another person\'s object is not found');
+ assert.strictEqual((await call('GET', `/me/objects/${benIds[0]}`, { cookie: 'ana' })).status, 404, 'nor its page');
+ r = await call('GET', '/api/v2/me/objects?limit=10', { token: 'ana' });
+ assert.strictEqual(r.json.objects.length, 10);
+ assert.strictEqual(r.json.next_cursor, r.json.objects[9].id);
+ const page2 = await call('GET', `/api/v2/me/objects?limit=10&cursor=${r.json.next_cursor}`, { token: 'ana' });
+ assert.ok(page2.json.objects.every(o => o.id < r.json.next_cursor));
+ assert.strictEqual((await call('GET', '/api/v2/me/objects?cursor=nope', { token: 'ana' })).status, 400);
+ assert.strictEqual((await call('GET', '/api/v2/me/objects?limit=1000', { token: 'ana' })).json.limit, 100);
+ assert.strictEqual((await call('GET', '/api/v2/me/objects?status=gone', { token: 'ana' })).status, 400);
+ const byDefault = await all('ana');
+ assert.ok(!byDefault.some(o => o.lifecycle_status === 'deleted') && byDefault.length === anaIds.length - 1, 'deleted objects only on request');
+ console.log(`✅ scoping + pagination: ${anaIds.length} of Ana's objects over pages of 7, none of Ben's; another person's id is a 404`);
+
+ // ── Filters ──
+ r = await call('GET', '/api/v2/me/objects?status=uploading', { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects.map(o => o.id).sort(), [multi, stored, streaming].sort());
+ r = await call('GET', '/api/v2/me/objects?app=tools', { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects.map(o => o.id), [inTools]);
+ assert.deepStrictEqual([r.json.objects[0].tenant.name, r.json.objects[0].app_id], ['OpenVibe.Tools', 'tools']);
+ r = await call('GET', '/api/v2/me/objects?kind=avatar', { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects.map(o => [o.id, o.namespace]), [[avatar, 'live.avatars']]);
+ r = await call('GET', '/api/v2/me/objects?q=NOTE-2', { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects.map(o => o.filename).sort(), ['note-2.txt', 'note-20.txt', 'note-21.txt', 'note-22.txt', 'note-23.txt', 'note-24.txt', 'note-25.txt', 'note-26.txt', 'note-27.txt', 'note-28.txt', 'note-29.txt']);
+ r = await call('GET', `/api/v2/me/objects?q=${benIds[0]}`, { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects, [], 'searching for someone else\'s id finds nothing');
+ r = await call('GET', '/api/v2/me/objects?q=ben-', { token: 'ana' });
+ assert.deepStrictEqual(r.json.objects, []);
+ console.log('✅ filters: status, tenant, kind, search (name, title, type, id), all within her own objects');
+
+ // ── Upload progress, derivatives, holds ──
+ const byId = Object.fromEntries(anaAll.map(o => [o.id, o]));
+ assert.deepStrictEqual(
+ (({ method, waiting_for, received_bytes, parts_received, parts_expected, declared_bytes, percent }) => ({ method, waiting_for, received_bytes, parts_received, parts_expected, declared_bytes, percent }))(byId[multi].upload),
+ { method: 'multipart', waiting_for: 'parts', received_bytes: 2 * 1048576, parts_received: 2, parts_expected: 4, declared_bytes: 4 * 1048576, percent: 50 });
+ assert.ok(byId[multi].upload.expires_at && byId[multi].upload.session_expires_at, 'when the upload fails if unfinished');
+ assert.deepStrictEqual([byId[stored].upload.waiting_for, byId[stored].upload.received_bytes, byId[stored].upload.percent], ['complete', 300, 100]);
+ assert.deepStrictEqual([byId[streaming].upload.streaming, byId[streaming].upload.received_bytes, byId[streaming].upload.percent], [true, 250, 25]);
+ assert.strictEqual(byId[anaReady[0]].upload, null);
+ assert.strictEqual(byId[anaReady[0]].derivatives, 1, 'the waveform counts as a derivative');
+ assert.strictEqual(byId[anaReady[3]].derivatives, 0);
+ assert.strictEqual(byId[anaReady[1]].held, true);
+ assert.strictEqual(byId[failed].failure, 'upload_expired');
+ assert.ok(byId[deleted].deleted.retention_until && byId[deleted].deleted.purged === false);
+ assert.strictEqual(byId[tricky].public_url, `https://media.test/o/${tricky}`, 'a public ready object links to its bytes');
+ assert.strictEqual(byId[anaReady[0]].public_url, null, 'a private one does not');
+ assert.deepStrictEqual(byId[anaReady[0]].readiness, { metadata: true, bytes_verified: true, playable: false, reason: 'not_playback_kind' });
+ const listText = JSON.stringify(anaAll);
+ for (const secret of ['SECRET-HOLD-REASON', 'SECRET-NOTE', 'staff:x', 'evidence', process.env.OBJECTS_PATH, 'canonical_key']) {
+ assert.ok(!listText.includes(secret), `the owner's view never carries ${secret}`);
+ }
+ r = await call('GET', `/api/v2/me/objects/${anaReady[0]}`, { token: 'ana' });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json.derivative_list.map(d => [d.id, d.name, d.kind, d.mine]), [[wave, 'waveform', 'asset', true]]);
+ assert.deepStrictEqual(r.json.locations.map(l => [l.provider, l.state, l.canonical]), [['local', 'present', false]]);
+ assert.deepStrictEqual(r.json.jobs, []);
+ r = await call('GET', `/api/v2/me/objects/${wave}`, { token: 'ana' });
+ assert.deepStrictEqual(r.json.source, { id: anaReady[0], relation: 'derived_from' });
+ r = await call('GET', `/api/v2/me/objects/${anaReady[2]}`, { token: 'ana' });
+ assert.deepStrictEqual(r.json.jobs.map(j => [j.id, j.type, j.status, j.error_code]), [['mjob_TESTFAILED', 'object.sprite', 'failed', 'no_video']]);
+ assert.ok(!JSON.stringify(r.json).includes('ffmpeg exited'), 'job error text stays with operators');
+ console.log('✅ uploads (multipart parts, stored bytes, a PUT streaming), derivatives, holds (only whether), no keys or staff notes');
+
+ // ── Usage ──
+ r = await call('GET', '/api/v2/me/usage', { token: 'ana' });
+ assert.strictEqual(r.status, 200);
+ const u = r.json;
+ const sum = (list) => list.reduce((a, id) => a + Number(model.getObject(id).size_bytes), 0);
+ const readyLive = [...anaReady, tricky, wave];
+ assert.strictEqual(u.totals.stored_bytes, sum([...readyLive, avatar, inTools]));
+ assert.strictEqual(u.totals.objects, anaIds.length - 1, 'deleted objects are not counted as objects');
+ assert.deepStrictEqual(u.totals.by_status.uploading, { objects: 3, bytes: 4 * 1048576 + 300 + 1000 });
+ assert.deepStrictEqual(u.totals.by_status.deleted, { objects: 1, bytes: 40 });
+ assert.deepStrictEqual(u.tenants.map(t => t.app_id), ['live', 'tools']);
+ const live = u.tenants[0];
+ assert.deepStrictEqual(live.namespaces.map(n => n.namespace), ['live', 'live.avatars']);
+ assert.strictEqual(live.namespaces[0].stored_bytes, sum(readyLive));
+ assert.strictEqual(live.namespaces[1].stored_bytes, 500);
+ assert.strictEqual(u.tenants[1].stored_bytes, 2048);
+ assert.strictEqual(u.quotas, null);
+ assert.ok(!/quota_bytes|quota_objects|used_bytes/.test(JSON.stringify(u)), 'no tenant quota or tenant totals');
+ const ub = (await call('GET', '/api/v2/me/usage', { token: 'ben' })).json;
+ assert.strictEqual(ub.totals.stored_bytes, 3 * 999999 + 1, 'Ben\'s usage is his own');
+ console.log('✅ usage: her objects and bytes per tenant and namespace, by status; no quotas');
+
+ // ── The page, without JavaScript ──
+ r = await call('GET', '/me', { cookie: 'ana' });
+ assert.strictEqual(r.status, 200);
+ const rows = r.text.match(/<tr data-id="med_/g) || [];
+ assert.strictEqual(rows.length, 25, 'the first page of objects is in the HTML');
+ assert.ok(r.text.includes('Signed in as <strong>@ana</strong>'));
+ assert.ok(r.text.includes('<script>alert(1)</script>.txt') && !r.text.includes('<script>alert(1)'), 'names are escaped');
+ const next = /id="me-next" rel="next" href="([^"]+)"/.exec(r.text);
+ assert.ok(next && /cursor=med_/.test(next[1]) && !/me-next[^>]*hidden/.test(r.text.slice(r.text.indexOf('id="me-next"') - 40, r.text.indexOf('id="me-next"') + 200)), 'a next-page link');
+ assert.ok(/<script src="\/me\/app\.js\?v=[0-9a-f]{12}" defer><\/script>/.test(r.text), 'the enhancement is a same-origin script');
+ assert.ok(r.text.includes('Read-only.') && r.text.includes('class="grid stack usage"') && r.text.includes('live.avatars'));
+ assert.ok(r.text.includes('<progress max="100" value="50"'), 'upload progress without JavaScript');
+ for (const b of benIds) assert.ok(!r.text.includes(b));
+ assert.ok(/<label for="f-q">/.test(r.text) && /<caption class="sr">/.test(r.text), 'labelled filters, captioned tables');
+ assert.ok(!/\bfree\b/i.test(r.text), 'no cost claims in served HTML');
+ assert.ok(r.text.includes('"menu":{"before":[{"id":"media-mine","label":"Your media","href":"/me"'), 'the account menu links /me');
+ assert.ok(!r.text.includes('href="/me/ops"'), 'no operator link for a user');
+ assert.ok((await call('GET', '/me', { cookie: 'admin' })).text.includes('href="/me/ops"'), 'staff get one');
+ r = await call('GET', next[1].replace(/&/g, '&'), { cookie: 'ana' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual((r.text.match(/<tr data-id="med_/g) || []).length, anaIds.length - 1 - 25, 'the second page');
+ assert.ok(r.text.includes('>Newest</a>'));
+ r = await call('GET', '/me/?kind=avatar&app=live', { cookie: 'ana' });
+ assert.strictEqual((r.text.match(/<tr data-id="med_/g) || []).length, 1, '/me/ and filters as a GET form');
+ assert.ok(r.text.includes('<option value="avatar" selected>'));
+ r = await call('GET', `/me/objects/${anaReady[0]}`, { cookie: 'ana' });
+ assert.ok(r.status === 200 && r.text.includes('waveform') && r.text.includes('Copies') && r.text.includes('noindex'));
+ r = await call('GET', `/me/objects/${multi}`, { cookie: 'ana' });
+ assert.ok(r.text.includes('data-upload=') && r.text.includes('/me/app.js'), 'an upload in progress is polled on its page');
+ r = await call('GET', '/me/app.js?v=x');
+ assert.ok(r.status === 200 && /javascript/.test(r.headers['content-type']) && r.text.includes('/api/v2/me/objects'));
+ assert.ok(!/innerHTML/.test(r.text), 'the script builds nodes, never markup');
+ console.log('✅ /me without JavaScript: first page, pager, filters, usage, progress; escaped; detail pages');
+
+ // ── Crawlers ──
+ r = await call('GET', '/sitemap.xml');
+ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
+ assert.ok(r.status === 200 && locs.length && locs.every(l => !/^https:\/\/media\.test\/me([/?]|$)/.test(l)), 'the sitemap never lists /me');
+ r = await call('GET', '/robots.txt');
+ assert.ok(/^Disallow: \/me$/m.test(r.text), 'robots.txt keeps crawlers out of /me');
+ console.log('✅ /me is not in the sitemap and is disallowed in robots.txt');
+
+ // ── Operator views ──
+ r = await call('GET', '/api/v2/me/ops', { token: 'ana' });
+ assert.deepStrictEqual([r.status, r.json.code], [403, 'capability.denied']);
+ assert.strictEqual((await call('GET', '/api/v2/me/ops', { token: 'mod' })).status, 403, 'a global_mod does not hold staff.site.view');
+ assert.strictEqual((await call('GET', '/api/v2/me/ops')).status, 401);
+ r = await call('GET', '/me/ops', { cookie: 'ana' });
+ assert.ok(r.status === 403 && r.text.includes('staff.site.view') && !r.text.includes('mjob_TESTFAILED'));
+ r = await call('GET', '/api/v2/me/ops', { token: 'admin' });
+ assert.strictEqual(r.status, 200, r.text);
+ const rep = r.json;
+ assert.strictEqual(rep.scope, 'all');
+ assert.deepStrictEqual(rep.jobs.failed.recent.map(j => [j.id, j.error_code, j.error]), [['mjob_TESTFAILED', 'no_video', 'ffmpeg exited 1']]);
+ assert.deepStrictEqual(rep.jobs.failed.last_7_days, [{ type: 'object.sprite', error_code: 'no_video', count: 1 }]);
+ assert.ok(rep.missing.no_good_copy.objects.some(o => o.id === lost), 'a ready object whose only copy is missing');
+ assert.ok(rep.missing.locations.missing_or_corrupt.some(l => l.object_id === lost && l.provider === 'b2'));
+ assert.ok(rep.backfill.unprojected && rep.backfill.owner_subject.missing >= 1);
+ assert.ok(rep.tiering.objects.some(o => o.origin === 'native') && rep.tiering.sweep && 'pending_offload' in rep.tiering);
+ assert.ok(rep.namespaces.some(n => n.namespace === 'live.avatars'));
+ r = await call('GET', '/api/v2/me/ops?app=tools', { token: 'admin' });
+ assert.deepStrictEqual([r.json.scope, r.json.jobs.failed.total, r.json.namespaces.map(n => n.namespace)], ['tools', 0, ['tools']]);
+ r = await call('GET', '/me/ops', { cookie: 'admin' });
+ assert.ok(r.status === 200 && r.text.includes('mjob_TESTFAILED') && r.text.includes('action="/me/ops/recompute"') && r.text.includes('noindex'));
+ console.log('✅ operator views: staff.site.view only (not users, not global mods); failed jobs, missing media, backfill, tiering, namespaces');
+
+ // ── Recompute: staff.site.configure, same-origin ──
+ db.run("UPDATE media_namespaces SET used_bytes = 0, used_objects = 0, reconciled_at = NULL WHERE namespace = 'live'");
+ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin' })).status, 403, 'a cookie-borne write needs this site as its origin');
+ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { origin: 'https://evil.test' } })).status, 403);
+ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { 'sec-fetch-site': 'cross-site', origin: 'https://media.test' } })).status, 403);
+ assert.strictEqual((await call('POST', '/api/v2/me/ops/recompute', { token: 'ana' })).status, 403, 'users cannot recompute');
+ assert.strictEqual(db.get("SELECT used_bytes FROM media_namespaces WHERE namespace = 'live'").used_bytes, 0, 'nothing ran');
+ r = await call('POST', '/api/v2/me/ops/recompute', { cookie: 'admin', headers: { origin: 'https://media.test' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.json.namespaces, r.json.scope], [db.get('SELECT COUNT(*) AS n FROM media_namespaces').n, 'all']);
+ const liveRow = db.get("SELECT * FROM media_namespaces WHERE namespace = 'live'");
+ assert.strictEqual(liveRow.used_bytes, namespaces.usage(db.getApp('live'), 'live').used_bytes);
+ assert.ok(liveRow.used_bytes > 0 && liveRow.reconciled_at, 'the snapshot is counted again from the rows');
+ r = await call('POST', '/me/ops/recompute?app=tools', { cookie: 'admin', headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/x-www-form-urlencoded' }, body: 'x=1' });
+ assert.deepStrictEqual([r.status, r.headers.location], [303, '/me/ops?app=tools&recomputed=1'], 'the form posts and comes back to the page');
+ r = await call('POST', '/api/v2/me/ops/recompute', { token: 'admin' });
+ assert.strictEqual(r.status, 200, 'a bearer token is not a cookie: no cross-site risk');
+ console.log('✅ recompute: staff.site.configure, same-origin for the cookie, refreshes the snapshot; the page form redirects back');
+
+ // ── The app-key twin in the admin routes ──
+ r = await call('GET', '/api/v1/live/admin/storage/ops', { token: 'live-key' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.scope, 'live');
+ assert.ok(r.json.namespaces.every(n => n.app_id === 'live'));
+ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/ops', { token: 'live-key', headers: { 'x-ov-user-id': '5' } })).status, 403, 'never acting for a user');
+ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/ops', { token: 'ana' })).status, 401);
+ r = await call('POST', '/api/v1/tools/admin/storage/ops/recompute', { token: 'tools-key' });
+ assert.deepStrictEqual([r.status, r.json.scope, r.json.namespaces], [200, 'tools', 1]);
+ console.log('✅ /api/v1/:app/admin/storage/ops: the same report for the app key, scoped to its tenant');
+
+ // ── Routes under /api/v2/me are the explorer's only ──
+ r = await call('GET', '/api/v2/me/namespaces', { token: 'ana' });
+ assert.deepStrictEqual([r.status, r.json.code], [404, 'media.not_found']);
+ console.log('me-explorer: all checks passed');
+ } finally {
+ server.close();
+ try { db.close(); } catch { /* */ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });