openvibers/openvibe.connect Commits

The inspector (CON-8 v0): a share's page lists the requests through it (#7)

OP OpenVibers committed 11 days ago · f4acc3868f · 9 files, +232 −6

The last 100 requests per share, newest first, on the share page and at GET /api/v1/shares/:id/requests
(?after=<seq>), for the owner only. Each holds the method, the path with every query value shown as '…',
the status, the time to the end, bytes in and out, the visitor's OpenVibe name, WebSocket or not, and why
it failed (nothing listening, timed out, visitor left…). Memory only, never written; no headers, no bodies.
The page adds new requests every 3 seconds while it is visible, and an open one is asked about until it
ends. Closing a share forgets its list.

Changes

ddiff --git a/README.md b/README.md
iindex b6efdfc..f210fa2 100644
---- a/README.md
++++ b/README.md
@@@ -55,6 +55,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
++| `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) |
## How it works
@@@ -68,6 +69,9 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)
- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.
- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.
- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.
++- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memory
++ only, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,
++ no bodies; a restart forgets them.
- `public/ov-connect.js` — the client.
The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex 430119b..0b1c4f1 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -135,6 +135,23 @@
.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
++/* The inspector (CON-8): one line per request, the status first; on a phone the path wraps under it. */
++.cn-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.cn-req { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 12px; padding: 8px 12px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cn-req-badge { min-width: 3.2em; padding: 1px 8px; border-radius: 999px; font: 700 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; background: color-mix(in srgb, var(--ovk-muted) 16%, transparent); }
++.cn-req.is-s2 .cn-req-badge { background: color-mix(in srgb, #22c55e 20%, transparent); }
++.cn-req.is-s3 .cn-req-badge { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 18%, transparent); }
++.cn-req.is-s4 .cn-req-badge { background: color-mix(in srgb, #f59e0b 22%, transparent); }
++.cn-req.is-s5 .cn-req-badge, .cn-req.is-err .cn-req-badge { background: color-mix(in srgb, #ef4444 22%, transparent); }
++.cn-req-line { min-width: 0; overflow-wrap: anywhere; font-size: 13.5px; }
++.cn-req-line b { margin-right: 4px; }
++.cn-req time { color: var(--ovk-muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }
++.cn-req-meta { grid-column: 2 / -1; color: var(--ovk-muted); font-size: 12.5px; }
++.cn-req-meta:empty { display: none; }
++.cn-req.is-new { animation: cn-req-in .5s ease-out; }
++@keyframes cn-req-in { from { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 14%, var(--ovk-panel)); } }
++@media (prefers-reduced-motion: reduce) { .cn-req.is-new { animation: none; } }
++@media (max-width: 520px) { .cn-req { grid-template-columns: auto minmax(0, 1fr); } .cn-req time { grid-column: 2; grid-row: 1; justify-self: end; } .cn-req-line { grid-column: 1 / -1; } }
.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
.cn-policy h2 { margin: 0; }
.cn-danger { display: grid; gap: 12px; }
ddiff --git a/public/js/connect.js b/public/js/connect.js
iindex cae90e8..2c813cd 100644
---- a/public/js/connect.js
++++ b/public/js/connect.js
@@@ -30,3 +30,68 @@
}
setTimeout(ask, 4000);
})();
++
++/* The inspector (CON-8): new requests through the share appear at the top of the list, every 3 seconds while the page
++ is visible, for up to 30 minutes; a request still open is asked about again until it ends. The rows match
++ server/views/index.js requestRow. */
++(function () {
++ 'use strict';
++ var box = document.querySelector('[data-cn-reqs]');
++ if (!box || !window.fetch) return;
++ var id = box.getAttribute('data-cn-reqs');
++ var list = box.querySelector('[data-cn-reqs-list]');
++ var empty = box.querySelector('[data-cn-reqs-empty]');
++ var KEEP = 100;
++ var ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };
++ var started = Date.now();
++ function size(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++ function row(q) {
++ var tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';
++ var li = el('li', 'cn-req is-' + tone);
++ li.setAttribute('data-seq', q.seq);
++ li.appendChild(el('span', 'cn-req-badge', q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (ERRORS[q.error] || q.error || '?')));
++ var line = el('code', 'cn-req-line');
++ line.appendChild(el('b', null, q.ws ? 'WS' : q.method));
++ line.appendChild(document.createTextNode(' ' + q.path));
++ li.appendChild(line);
++ var meta = [q.ms != null ? q.ms + ' ms' : null, q.in ? size(q.in) + ' in' : null, q.out ? size(q.out) + ' out' : null,
++ q.error && q.status ? (ERRORS[q.error] || q.error) : null, q.who ? '@' + q.who : null].filter(Boolean).join(' · ');
++ li.appendChild(el('span', 'cn-req-meta', meta));
++ var t = el('time', null, String(q.at).slice(11, 19));
++ t.setAttribute('datetime', q.at);
++ li.appendChild(t);
++ return li;
++ }
++ function seqs() { return Array.prototype.map.call(list.children, function (li) { return Number(li.getAttribute('data-seq')) || 0; }); }
++ function after() {
++ // The oldest request still open is asked about again, so it ends on the page too.
++ var open = Array.prototype.filter.call(list.children, function (li) { return li.classList.contains('is-open'); })
++ .map(function (li) { return Number(li.getAttribute('data-seq')) || 0; });
++ if (open.length) return Math.min.apply(null, open) - 1;
++ var all = seqs();
++ return all.length ? Math.max.apply(null, all) : 0;
++ }
++ function apply(items) {
++ // Newest first from the API; inserted oldest first so the newest ends on top.
++ items.slice().reverse().forEach(function (q) {
++ var old = list.querySelector('[data-seq="' + q.seq + '"]');
++ var li = row(q);
++ if (old) { list.replaceChild(li, old); return; }
++ li.classList.add('is-new');
++ list.insertBefore(li, list.firstChild);
++ });
++ while (list.children.length > KEEP) list.removeChild(list.lastChild);
++ if (empty) empty.hidden = list.children.length > 0;
++ }
++ function ask() {
++ if (Date.now() - started > 30 * 60 * 1000) return;
++ if (document.hidden) { setTimeout(ask, 3000); return; }
++ fetch('/api/v1/shares/' + encodeURIComponent(id) + '/requests?after=' + after(), { credentials: 'same-origin', headers: { accept: 'application/json' } })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (j) { if (j && Array.isArray(j.requests) && j.requests.length) apply(j.requests); })
++ .catch(function () { /* the next ask tries again */ })
++ .then(function () { setTimeout(ask, 3000); });
++ }
++ setTimeout(ask, 3000);
++})();
ddiff --git a/server/edge/inspector.js b/server/edge/inspector.js
nnew file mode 100644
iindex 0000000..af701d7
---- /dev/null
++++ b/server/edge/inspector.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * The inspector (CON-8 v0): the last 100 requests through each share, for its owner, so someone sharing an app can
++ * see what reaches it as it happens (the share page lists them and adds new ones every few seconds).
++ *
++ * Memory only: a restart forgets them and nothing is written anywhere. Each entry holds what a log line would:
++ * the method; the path, with every query value replaced by "…" (a token in a query string never reaches a page);
++ * the status, the time to the end, the bytes each way; the visitor's OpenVibe name when the share asked for one;
++ * whether it was a WebSocket; and why it failed (timeout, nothing listening…).
++ * No headers, no bodies, no addresses.
++ *
++ * begin(shareId, { method, path, who, ws }) → entry; set entry.status / add to entry.in and entry.out as it goes
++ * end(entry, { status?, error? }) → stamps the time taken (once)
++ * list(shareId, { after }) → newest first, only entries after that sequence number
++ * forget(shareId) → a closed share's list goes
++ */
++
++const KEEP = 100;
++const PATH_MAX = 300;
++
++/** The path as the inspector shows it: query values become "…", and only the first 12 keys are kept. */
++function redact(url) {
++ const s = String(url || '/');
++ const q = s.indexOf('?');
++ const path = (q === -1 ? s : s.slice(0, q)).slice(0, PATH_MAX) || '/';
++ if (q === -1) return path;
++ const keys = s.slice(q + 1).split('&').map((kv) => kv.split('=')[0]).filter(Boolean).slice(0, 12)
++ .map((k) => k.slice(0, 40).replace(/[^\w.%~[\]-]/g, ''));
++ return keys.length ? `${path}?${keys.map((k) => `${k}=…`).join('&')}` : path;
++}
++
++function createInspector({ now = () => Date.now(), keep = KEEP } = {}) {
++ const rings = new Map(); // share id → { seq, items }
++
++ function begin(shareId, { method = 'GET', path = '/', who = null, ws = false } = {}) {
++ let r = rings.get(shareId);
++ if (!r) { r = { seq: 0, items: [] }; rings.set(shareId, r); }
++ const entry = {
++ seq: ++r.seq, at: new Date(now()).toISOString(), method: String(method).slice(0, 12), path: redact(path),
++ who: who && who.username ? String(who.username) : null, ws: Boolean(ws),
++ status: null, ms: null, in: 0, out: 0, error: null, started: now(),
++ };
++ r.items.push(entry);
++ if (r.items.length > keep) r.items.splice(0, r.items.length - keep);
++ return entry;
++ }
++
++ function end(entry, { status = null, error = null } = {}) {
++ if (!entry || entry.ms != null) return;
++ if (status != null) entry.status = status;
++ if (error) entry.error = String(error).slice(0, 40);
++ entry.ms = Math.max(0, now() - entry.started);
++ }
++
++ const wire = (e) => ({ seq: e.seq, at: e.at, method: e.method, path: e.path, who: e.who, ws: e.ws, status: e.status, ms: e.ms, in: e.in, out: e.out, error: e.error, open: e.ms == null });
++
++ function list(shareId, { after = 0 } = {}) {
++ const r = rings.get(shareId);
++ if (!r) return [];
++ const from = Number(after) || 0;
++ return r.items.filter((e) => e.seq > from).reverse().map(wire);
++ }
++
++ function forget(shareId) { rings.delete(shareId); }
++
++ return { begin, end, list, forget, KEEP: keep };
++}
++
++module.exports = { createInspector, redact, KEEP };
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex d2c7513..9c49db2 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -37,6 +37,8 @@ function privateCache(value) {
}
function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {
++ // The last requests through each share, for its owner's page (CON-8 v0; memory only).
++ const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domain;
const cache = new Map(); // host → { share, at }
const windows = new Map(); // share id → { start, count }
@@@ -231,6 +233,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
if (!admitted) return;
const { who, tunnel } = admitted;
visit(share, who);
++ const rec = inspector.begin(share.id, { method: req.method, path: url, who });
++ res.on('finish', () => inspector.end(rec, { status: res.statusCode }));
++ res.on('close', () => inspector.end(rec, { error: res.writableFinished ? null : 'visitor_gone' }));
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
let finished = false;
@@@ -244,11 +249,13 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
tunnel.abort(id, 'bad_status');
return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');
}
++ rec.status = status;
try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
return undefined;
},
onData(buf) {
addBytes(share, 'out', buf.length);
++ rec.out += buf.length;
if (!res.write(buf)) {
tunnel.send({ t: 'pause', id });
res.once('drain', () => tunnel.send({ t: 'resume', id }));
@@@ -258,6 +265,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
onAbort(m) {
finished = true;
clearTimeout(headTimer);
++ if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (res.headersSent) { res.destroy(); return; }
if (m && m.code === 'local_refused') {
sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);
@@@ -273,6 +281,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
headTimer = setTimeout(() => {
if (res.headersSent || finished) return;
finished = true;
++ rec.error = 'timeout';
tunnel.abort(id, 'timeout');
sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);
}, config.shares.requestTimeoutMs);
@@@ -282,8 +291,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
req.on('data', (chunk) => {
received += chunk.length;
addBytes(share, 'in', chunk.length);
++ rec.in += chunk.length;
if (received > config.shares.maxBodyBytes) {
-- if (!finished) { finished = true; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }
++ if (!finished) { finished = true; rec.error = 'too_large'; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }
req.destroy();
return;
}
@@@ -305,6 +315,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
if (!admitted) return;
const { who, tunnel } = admitted;
visit(share, who);
++ const rec = inspector.begin(share.id, { method: req.method, path: String(req.url || '/'), who, ws: true });
++ socket.on('close', () => inspector.end(rec));
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
const lines = [`${req.method} ${req.url} HTTP/1.1`];
@@@ -313,13 +325,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
let opened = false;
const id = tunnel.open({
onData(buf) {
++ if (!opened) rec.status = /^HTTP\/1\.[01] (\d{3})/.test(buf.toString('latin1', 0, 16)) ? Number(RegExp.$1) : null;
opened = true;
addBytes(share, 'out', buf.length);
++ rec.out += buf.length;
if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }
},
onEnd() { tunnel.release(id); socket.end(); },
onAbort(m) {
++ if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (!opened && m && m.code === 'local_refused') {
++ rec.status = 502;
socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);
} else socket.destroy();
},
@@@ -327,9 +343,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
onResume() { socket.resume(); },
});
tunnel.send({ t: 'raw', id, head: headText });
-- if (head && head.length) { addBytes(share, 'in', head.length); tunnel.data(id, head); }
++ if (head && head.length) { addBytes(share, 'in', head.length); rec.in += head.length; tunnel.data(id, head); }
socket.on('data', (chunk) => {
addBytes(share, 'in', chunk.length);
++ rec.in += chunk.length;
tunnel.data(id, chunk);
if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }
});
@@@ -342,7 +359,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
/** The grant refused someone: counted on the share page as "turned away". */
function denied(share, who) { visit(share, who, { denied: true }); }
-- return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders };
++ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders, inspector };
}
/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a0e2998..383c864 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -11,6 +11,8 @@
* DELETE /shares/:id close it (the host answers 410 from then on)
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* GET /shares/:id/visits who opened it in the last 7 days
++ * GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);
++ * memory only, query values shown as "…" (edge/inspector.js)
*
* Errors are application/problem+json with a `code`.
*/
@@@ -72,6 +74,7 @@ function createApi(ctx) {
const closed = await shares.revoke(owner(req), share.id);
tunnels.close(share.id, 4003, 'share closed');
proxy.forget(closed.host);
++ proxy.inspector.forget(share.id);
return res.json({ share: shape(closed) });
}));
@@@ -83,6 +86,11 @@ function createApi(ctx) {
return res.json({ share: shape(updated), key });
}));
++ r.get('/shares/:id/requests', guard(async (req, res) => {
++ const share = await mine(req, res);
++ return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
++ }));
++
r.get('/shares/:id/visits', guard(async (req, res) => {
const share = await mine(req, res);
return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d8045b3..8fa82be 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
const visits = await shares.visits(share.id);
-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
}
r.get('/shares/:id', async (req, res) => {
@@@ -126,6 +126,7 @@ function createPageRoutes(ctx) {
const closed = await shares.revoke(owner(req), share.id);
tunnels.close(share.id, 4003, 'share closed');
proxy.forget(closed.host);
++ proxy.inspector.forget(share.id);
return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });
});
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 3ce5b59..54e0d55 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -190,7 +190,24 @@ function keyBlock(share, key, baseUrl) {
</section>`;
}
--function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
++/** Bytes as a person reads them. */
++function size(n) {
++ const b = Number(n) || 0;
++ return b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`;
++}
++
++const REQ_ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };
++
++/** One request in the inspector (public/js/connect.js draws the same row for new ones). */
++function requestRow(q) {
++ const tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';
++ const badge = q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (REQ_ERRORS[q.error] || q.error || '?');
++ const meta = [q.ms != null ? `${q.ms} ms` : null, q.in ? `${size(q.in)} in` : null, q.out ? `${size(q.out)} out` : null,
++ q.error && q.status ? (REQ_ERRORS[q.error] || q.error) : null, q.who ? `@${q.who}` : null].filter(Boolean).join(' · ');
++ return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;
++}
++
++function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
@@@ -231,6 +248,10 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
</dl>
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
++${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
++<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
++<ol class="cn-reqs" data-cn-reqs-list>${requests.map(requestRow).join('')}</ol>
++<p class="cn-empty" data-cn-reqs-empty${requests.length ? ' hidden' : ''}>No requests yet${connected ? '. Open the address and they appear here' : ''}.</p></section>`}
${policy}
${danger}
</div>`;
@@@ -249,4 +270,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
--module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago };
++module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };
ddiff --git a/test/share.test.js b/test/share.test.js
iindex 502b586..59b6145 100644
---- a/test/share.test.js
++++ b/test/share.test.js
@@@ -254,6 +254,29 @@ async function until(fn, ms = 3000) {
assert.strictEqual(s.connected, true);
});
++ await check('the inspector (CON-8): the requests through the share, newest first, query values hidden, the owner\'s only', async () => {
++ const r = await t.share(made.share.host, '/hello?token=s3cret-value&page=2', { cookie: alexCookie, headers: { accept: 'application/json' } });
++ assert.strictEqual(r.status, 200);
++ let list = [];
++ await until(async () => { list = (await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: alex })).json().requests; return list[0] && !list[0].open; });
++ const top = list[0];
++ assert.deepStrictEqual([top.method, top.path, top.status, top.who], ['GET', '/hello?token=…&page=…', 200, 'Alex_Dev']);
++ assert.ok(top.ms >= 0 && top.out > 0, JSON.stringify(top));
++ assert.ok(!JSON.stringify(list).includes('s3cret'), 'a query value never reaches the list');
++ assert.ok(list.some((q) => q.ws && q.status === 101), 'the WebSocket is there, with its 101');
++ assert.ok(list.some((q) => q.method === 'POST' && q.in > 0), 'a body counts its bytes in');
++ assert.ok(list.every((q, i) => i === 0 || q.seq < list[i - 1].seq), 'newest first');
++ const newer = (await t.get(`/api/v1/shares/${made.share.id}/requests?after=${top.seq}`, { as: alex })).json().requests;
++ assert.deepStrictEqual(newer, [], 'after= answers only newer ones');
++ const page = await t.get(`/shares/${made.share.id}`, { as: alex });
++ assert.ok(page.text.includes(`data-cn-reqs="${made.share.id}"`));
++ assert.ok(page.text.includes('<b>GET</b> /hello?token=…&amp;page=…'), 'the page lists it');
++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: bob })).status, 404, 'nobody else');
++ const dead = (await t.get('/api/v1/shares', { as: alex })).json().shares.find((s) => s.label === 'dead');
++ const refused = (await t.get(`/api/v1/shares/${dead.id}/requests`, { as: alex })).json().requests[0];
++ assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');
++ });
++
await check('another person cannot see, change or close someone else\'s share', async () => {
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);