openvibers/openvibe.bot Commits

SEO-7: a gated bot page answers on openvibe.bot with noindex, not by redirecting to Network sign-in (#219)

OP OpenVibers committed 15 days ago · 962f452152 · 9 files, +57 −16

* SEO-7: a gated bot page answers on openvibe.bot with noindex, not by redirecting to Network sign-in

Every page handler that threw 401 was answered with a 302 to /auth/login, which 302s to
openvibe.network/oauth/authorize; the page there carries
<link rel="canonical" href="https://openvibe.network/login">, which tools/seo-audit.js reported as
openvibe.bot/link's canonical — and a crawler never saw a page of ours.

page() now renders renderSignInPage() for a 401 on an HTML GET: 401, <meta name="robots"
content="noindex, nofollow">, no canonical, and a "Sign in with OpenVibe" link to
/auth/login?next=…. Non-HTML clients and form POSTs keep the redirect they had. render.js's page()
gained a robots option (it had none).

test/panel.test.js asserts the rule for the gated pages (/robots/new, /panel/:id, /pair/, /link,
/machines) and for the public front page (self-canonical, index, follow); claims, community,
directory, embed-drive and worker-machines tests follow the new answer.

* Bot: the gate's sign-in link carries the path only, and STATUS.json catches up

The 401 page put the whole requested URL, query and all, into the sign-in link, so an
anonymous response could echo words from a private page — Chat's security suite reads every
401 body for exactly this. The link now returns to the path.
STATUS.json's updated date follows today's package.json change (the docs test checks it).

* Bot: the gate test expects the path-only sign-in link

The link no longer echoes /link's query string, so the device code is asked for again after
sign-in — and the test now asserts the code never appears in an anonymous page.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex f9753b1..a023993 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -63,5 +63,5 @@
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"
],
-- "updated": "2026-10-28"
++ "updated": "2026-10-29"
}
ddiff --git a/server/web/render.js b/server/web/render.js
iindex ebb2132..b573718 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -63,7 +63,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
--function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null } = {}) {
++function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null } = {}) {
return `<!doctype html>
<html lang="en">
<head>
@@@ -71,7 +71,8 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#11151c">
--<title>${esc(title)} — OpenVibe.Bot</title>${description ? `
++<title>${esc(title)} — OpenVibe.Bot</title>${robots ? `
++<meta name="robots" content="${esc(robots)}">` : ''}${description ? `
<meta name="description" content="${esc(description)}">
<meta property="og:title" content="${esc(title)} — OpenVibe.Bot">
<meta property="og:description" content="${esc(description)}">` : ''}${canonical ? `
@@@ -635,6 +636,21 @@ ${first}
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
++/**
++ * A gated page's answer on this origin (audit SEO-7): noindex, and a link to Network sign-in instead of a
++ * redirect there. A redirect handed a crawler openvibe.network's canonical as this page's; this keeps the
++ * page on openvibe.bot, says noindex, and a person is one click from signing in.
++ */
++function renderSignInPage({ next = '/robots', what = 'this page' } = {}) {
++ const body = `${topbar('')}
++<main class="page">
++<h1>Sign in to continue</h1>
++<p>${esc(what.charAt(0).toUpperCase() + what.slice(1))} is for OpenVibe accounts. Sign in and you come straight back here.</p>
++<p><a class="button primary" href="/auth/login?next=${esc(encodeURIComponent(next))}">Sign in with OpenVibe</a></p>
++</main>`;
++ return page('Sign in to continue', body, { robots: 'noindex, nofollow' });
++}
++
/**
* /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.bot/install | sh` with no code.
* renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;
@@@ -1003,4 +1019,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex b4ca2fb..f53b04b 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@@ -258,7 +258,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const page = (fn) => async (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
try { await fn(req, res); } catch (e) {
-- if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ // A gated page answers here, noindex, instead of redirecting to Network sign-in: the redirect
++ // gave a crawler openvibe.network's canonical as this page's (audit SEO-7). A form still
++ // redirects, because its session ended and sign-in brings the person back to the page.
++ if (e.status === 401) {
++ if (req.method !== 'GET' || !req.accepts('html')) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ return res.status(401).type('html').send(renderSignInPage({ next: req.path, what: 'this page' }));
++ }
// A refusal the person can act on (a bad form value, a robot with no stream, OpenRestream not answering) says so
// with its own status instead of the generic error page.
if ([403, 404, 409, 422, 502, 503].includes(e.status) && e.code) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);
ddiff --git a/test/claims.test.js b/test/claims.test.js
iindex 60a1ff2..4cf9f36 100644
---- a/test/claims.test.js
++++ b/test/claims.test.js
@@@ -49,8 +49,14 @@ const { boot, check, done } = require('./helpers/app');
await check('/link: sign in first, then the device, what it found, and its choices', async () => {
const out = await get(`/link?code=${claim.user_code}`);
-- assert.strictEqual(out.status, 302);
-- assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Flink%3Fcode%3D/);
++ const gate = await out.text();
++ // A gated page answers here with noindex (SEO-7): no redirect whose destination carried another origin's canonical.
++ assert.strictEqual(out.status, 401);
++ assert.match(gate, /<meta name="robots" content="noindex, nofollow">/);
++ // The sign-in link goes back to the path only: an anonymous page must not echo a query string
++ // (a code in it would come back in the body), so the device code is asked for again after sign-in.
++ assert.match(gate, /href="\/auth\/login\?next=%2Flink"/);
++ assert.ok(!gate.includes(claim.user_code), 'the gate does not echo the device code');
const r = await get(`/link?code=${claim.user_code.toLowerCase().replace('-', ' ')}`, alex);
assert.strictEqual(r.status, 200);
const html = await r.text();
ddiff --git a/test/community.test.js b/test/community.test.js
iindex e0ce61f..56d6d69 100644
---- a/test/community.test.js
++++ b/test/community.test.js
@@@ -50,7 +50,8 @@ const PROFILE = {
const submit = (u, profile, extra = {}, headers = {}) => post('/robots/submit', u, { profile: typeof profile === 'string' ? profile : JSON.stringify(profile), driver_url: 'https://github.com/mia/ballbot-driver', guide_url: 'https://openvibe.wiki/robots/ballbot', license: 'MIT', notes: 'Tested on two boards.', ...extra }, headers);
await check('/robots/submit: signed in only; a profile template; refusals come back on the form', async () => {
-- assert.strictEqual((await get('/robots/submit')).status, 302);
++ // A gated page answers here with noindex (SEO-7), never a redirect off the site.
++ assert.strictEqual((await get('/robots/submit')).status, 401);
const form = await (await get('/robots/submit', mia)).text();
assert.ok(form.includes('name="profile"') && form.includes('community.my-robot') && form.includes('<option value="MIT"'));
const bad = await submit(mia, '{ not json');
ddiff --git a/test/directory.test.js b/test/directory.test.js
iindex 5aba238..219b92d 100644
---- a/test/directory.test.js
++++ b/test/directory.test.js
@@@ -95,8 +95,11 @@ const { loadProfiles } = require('../server/profiles');
await check('/robots/new: signed in only; one choice per model, grouped by how it connects, the asked one chosen', async () => {
const out = await get('/robots/new');
-- assert.strictEqual(out.status, 302);
-- assert.strictEqual(out.headers.get('location'), `/auth/login?next=${encodeURIComponent('/robots/new')}`);
++ const gate = await out.text();
++ // A gated page answers here with noindex (SEO-7), not a redirect to Network sign-in.
++ assert.strictEqual(out.status, 401);
++ assert.match(gate, /<meta name="robots" content="noindex, nofollow">/);
++ assert.match(gate, /href="\/auth\/login\?next=%2Frobots%2Fnew"/);
const html = await (await get('/robots/new', alex)).text();
for (const p of profiles) assert.ok(html.includes(`name="profile_id" value="${p.id}"`), p.id);
assert.match(html, /value="sim\.rover" checked/, 'the simulated rover by default');
ddiff --git a/test/embed-drive.test.js b/test/embed-drive.test.js
iindex c3b5fbb..43abc76 100644
---- a/test/embed-drive.test.js
++++ b/test/embed-drive.test.js
@@@ -83,8 +83,9 @@ const { renderPanel } = require('../server/web/render');
const html = await page.text();
assert.ok(html.includes('data-drive-page') && html.includes(`data-robot-id="${mine.id}"`) && html.includes('/panel/drive.js'));
const out = await fetch(`${t.base}/panel/${mine.id}/drive`, { redirect: 'manual' });
-- assert.strictEqual(out.status, 302);
-- assert.match(out.headers.get('location'), /^\/auth\/login\?next=/);
++ // A gated page answers here with noindex (SEO-7), not a redirect off the site.
++ assert.strictEqual(out.status, 401);
++ assert.match(await out.text(), /name="robots" content="noindex, nofollow"/);
assert.strictEqual((await fetch(`${t.base}/panel/${mine.id}/drive`, { redirect: 'manual', headers: { Cookie: cookie(bob) } })).status, 403);
assert.strictEqual((await fetch(`${t.base}/panel/drive.js`)).status, 200);
});
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 0d1a939..12ddc68 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -154,10 +154,18 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual((await get('/install')).status, 302);
assert.strictEqual((await get('/robots')).status, 200, 'the directory is public');
for (const p of ['/robots/new', '/panel/rob_x', '/pair/rob_x']) {
++ // A gated page answers here with noindex and a link to sign-in (SEO-7): a redirect there
++ // handed a crawler openvibe.network's canonical as this page's.
const r = await get(p);
-- assert.strictEqual(r.status, 302, p);
-- assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);
++ const body = await r.text();
++ assert.strictEqual(r.status, 401, p);
++ assert.match(body, /<meta name="robots" content="noindex, nofollow">/, p);
++ assert.ok(body.includes('href="/auth/login?next='), `${p} links to sign-in`);
}
++ // A public page still keeps its own canonical and says it may be indexed (SEO-7's other half).
++ const home = await (await get('/')).text();
++ assert.match(home, /<link rel="canonical" href="http:\/\/bot\.test\/">/);
++ assert.match(home, /<meta name="robots" content="index, follow">/);
const js = await get('/panel/panel.js');
assert.strictEqual(js.status, 200);
assert.match(js.headers.get('content-type'), /javascript/);
ddiff --git a/test/worker-machines.test.js b/test/worker-machines.test.js
iindex b2a45f3..78ea0d5 100644
---- a/test/worker-machines.test.js
++++ b/test/worker-machines.test.js
@@@ -80,7 +80,7 @@ const { boot, check, done } = require('./helpers/app');
await check('/machines lists them, adds one with a one-time --worker command, and removes one', async () => {
const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
-- assert.strictEqual(anon.status, 302, 'signed out: to the sign-in');
++ assert.strictEqual(anon.status, 401, 'signed out: a gate page here, noindex (SEO-7)');
const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);
assert.ok(!page.includes(bob.subject));