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

OP OpenVibers committed 26 Sept 2026 · 15b8f93ea7 · 14 files, +1968 −4

- Owner API (server/me/): GET /api/v2/me/objects (?cursor&limit&status&kind&q&app, newest first) with tenant, namespace, kind, name, type, size, visibility, lifecycle, readiness, derivatives, held, upload progress (multipart parts, stored bytes, a PUT still streaming, the reservation's expiry), failure and retention; GET /api/v2/me/objects/:id adds copies, derivatives, source and recent jobs; GET /api/v2/me/usage counts the person's own objects and bytes per tenant and namespace, never a tenant's quota. Another person's object is a 404. Only a person's access token for openvibe.media identifies them (no principals, no FedCM assertions, a usr_ subject required). Read-only: the object API takes visibility, delete and restore only from tenant credentials (a user JWT is 403 there), so the explorer does not act for a person.
- Pages: /me and /me/objects/:id in the OpenVibe Frame, complete without JavaScript (usage, first page, GET filters, next-page link, progress bars); /me/app.js (same-origin, content-hashed) adds filtering and load more without a reload and polls uploads while visible. Signed out: a sign-in prompt. noindex, private, no-store; /me disallowed in robots.txt and never in the sitemap; the account menu links it. Checked in headless Chrome at 390/768/1280 (no horizontal overflow) and with axe (no WCAG 2.1 AA violations).
- Operator views (server/me/ops.js, database reads only): failed jobs (by type and error code, most recent with error text), missing media (no good copy, missing or corrupt copies, quarantined VODs, last verification), backfill (last report, rows without an object, unresolved owner subjects), tiering (providers, policy, per-provider counts, eligible offloads, R2 decisions, the sweep via the new vodStorage.sweepInfo()), namespaces with quota and snapshot. /me/ops and GET /api/v2/me/ops need staff.site.view; the recompute (POST /api/v2/me/ops/recompute, the page's form) needs staff.site.configure and, with the cookie, a same-origin request. The app-key twin: GET/POST /api/v1/:app/admin/storage/ops[/recompute], scoped to the app, never acting for a user.
- A restore drill signs nobody in: /me and /api/v2/me answer 503 and verify no token (drill-mode test).
- Test: me-explorer.test.js (new); docs/object-model.md#object-explorer, README.

Changes

ddiff --git a/README.md b/README.md
iindex 953f4e6..594ac38 100644
---- a/README.md
++++ b/README.md
@@@ -41,6 +41,8 @@ server/
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
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),
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 how
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
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.
| 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. |
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 574ae60..6b10cc5 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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
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
Code: `server/objects/readiness.js`. Test: `test/readiness.test.js`.
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex b421708..5385751 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.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.
ddiff --git a/server/index.js b/server/index.js
iindex f3ee8c9..6b9cab5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/me/client.js b/server/me/client.js
nnew file mode 100644
iindex 0000000..d23f585
---- /dev/null
++++ b/server/me/client.js
@@@ -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();
++})();
ddiff --git a/server/me/explorer.js b/server/me/explorer.js
nnew file mode 100644
iindex 0000000..f535663
---- /dev/null
++++ b/server/me/explorer.js
@@@ -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 };
ddiff --git a/server/me/ops.js b/server/me/ops.js
nnew file mode 100644
iindex 0000000..92a3618
---- /dev/null
++++ b/server/me/ops.js
@@@ -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 };
ddiff --git a/server/me/pages.js b/server/me/pages.js
nnew file mode 100644
iindex 0000000..5e62f45
---- /dev/null
++++ b/server/me/pages.js
@@@ -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,
++};
ddiff --git a/server/me/routes.js b/server/me/routes.js
nnew file mode 100644
iindex 0000000..836d4c5
---- /dev/null
++++ b/server/me/routes.js
@@@ -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 };
ddiff --git a/server/public/crawl.js b/server/public/crawl.js
iindex 48cb04c..2914b75 100644
---- a/server/public/crawl.js
++++ b/server/public/crawl.js
@@@ -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'],
});
}
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex 7451929..6b1fcc3 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -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',
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
iindex 9ebcc49..7fd7717 100644
---- a/server/vod/vod-storage.js
++++ b/server/vod/vod-storage.js
@@@ -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,
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex dbddb27..921264e 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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 () => {
ddiff --git a/test/me-explorer.test.js b/test/me-explorer.test.js
nnew file mode 100644
iindex 0000000..e480bce
---- /dev/null
++++ b/test/me-explorer.test.js
@@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;.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(/&amp;/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); });