openvibers/openvibe.bot Commits
SEO-7: a gated bot page answers on openvibe.bot with noindex, not by redirecting to Network sign-in (#219)
Changes
@@@ -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"}@@@ -63,7 +63,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" arconst 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 };@@@ -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, limiconst 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`);@@@ -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();@@@ -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');@@@ -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');@@@ -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);});@@@ -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/);@@@ -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));
@@ -63,5 +63,5 @@
@@ -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",
"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)"
"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"
}
}
@@ -63,7 +63,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
@@ -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_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' };
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>
return `<!doctype html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -71,7 +71,8 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
@@ -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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#11151c">
<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 name="description" content="${esc(description)}">
<meta property="og:title" content="${esc(title)} — OpenVibe.Bot">
<meta property="og:title" content="${esc(title)} — OpenVibe.Bot">
<meta property="og:description" content="${esc(description)}">` : ''}${canonical ? `
<meta property="og:description" content="${esc(description)}">` : ''}${canonical ? `
@@ -635,6 +636,21 @@ ${first}
@@ -635,6 +636,21 @@ ${first}
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
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.
* /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;
* renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;
@@ -1003,4 +1019,4 @@ function renderPhonePage() {
@@ -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' });
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 };
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
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 { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@ -258,7 +258,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -258,7 +258,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const page = (fn) => async (req, res, next) => {
const page = (fn) => async (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
try { await fn(req, res); } catch (e) {
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
// 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.
// 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`);
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`);
@@ -49,8 +49,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
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}`);
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);
const r = await get(`/link?code=${claim.user_code.toLowerCase().replace('-', ' ')}`, alex);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const html = await r.text();
const html = await r.text();
@@ -50,7 +50,8 @@ const PROFILE = {
@@ -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);
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 () => {
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();
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"'));
assert.ok(form.includes('name="profile"') && form.includes('community.my-robot') && form.includes('<option value="MIT"'));
const bad = await submit(mia, '{ not json');
const bad = await submit(mia, '{ not json');
@@ -95,8 +95,11 @@ const { loadProfiles } = require('../server/profiles');
@@ -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 () => {
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');
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();
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);
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');
assert.match(html, /value="sim\.rover" checked/, 'the simulated rover by default');
@@ -83,8 +83,9 @@ const { renderPanel } = require('../server/web/render');
@@ -83,8 +83,9 @@ const { renderPanel } = require('../server/web/render');
const html = await page.text();
const html = await page.text();
assert.ok(html.includes('data-drive-page') && html.includes(`data-robot-id="${mine.id}"`) && html.includes('/panel/drive.js'));
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' });
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/${mine.id}/drive`, { redirect: 'manual', headers: { Cookie: cookie(bob) } })).status, 403);
assert.strictEqual((await fetch(`${t.base}/panel/drive.js`)).status, 200);
assert.strictEqual((await fetch(`${t.base}/panel/drive.js`)).status, 200);
});
});
@@ -154,10 +154,18 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -154,10 +154,18 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual((await get('/install')).status, 302);
assert.strictEqual((await get('/install')).status, 302);
assert.strictEqual((await get('/robots')).status, 200, 'the directory is public');
assert.strictEqual((await get('/robots')).status, 200, 'the directory is public');
for (const p of ['/robots/new', '/panel/rob_x', '/pair/rob_x']) {
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);
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');
const js = await get('/panel/panel.js');
assert.strictEqual(js.status, 200);
assert.strictEqual(js.status, 200);
assert.match(js.headers.get('content-type'), /javascript/);
assert.match(js.headers.get('content-type'), /javascript/);
@@ -80,7 +80,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
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' });
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();
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('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);
assert.ok(!page.includes(bob.subject));
assert.ok(!page.includes(bob.subject));