openvibers/openvibe.chat Commits

SEO-7: a gated chat page answers here, noindex, not by bouncing to Network sign-in (#241)

OP OpenVibers committed 16 days ago · 7f4a53f2ab · 6 files, +56 −8

needUser redirected every signed-out GET to /auth/login, which redirects to
openvibe.network/oauth/authorize, whose page carries
<link rel="canonical" href="https://openvibe.network/login">. tools/seo-audit.js read that as this
page's canonical, and a crawler never saw a page of ours.

A signed-out GET now renders the page's own 401 with robots noindex,nofollow and a "Sign in with
OpenVibe" link. The link carries the path only: an anonymous response must never echo a query
string, because a private page's URL can hold its words — the security suite reads every 401 body
for them, and that is how this was caught. A form POST still redirects: its session ended, and
sign-in brings the person back to the page.

/vibe/memory and /vibe/:slug go through the same needUser, so they cannot drift.

Tests: web.test.js, dm-calls, vibe-memory, n-1 (the N-1 fixture is untouched — the two deliberate
answer changes are declared in INTENTIONAL, and a declaration that no longer names a recorded call
fails the run), security-private (7 checks).

Changes

ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 4c4a97f..7bb2918 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -195,7 +195,23 @@ function createWebRoutes({ config }) {
const needUser = async (req, res) => {
const actor = await viewerOf(req);
if (actor.kind === 'user') return actor;
-- res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ // A gated page answers here, noindex, instead of redirecting to Network sign-in: a crawler that
++ // followed the redirect read openvibe.network's canonical as this page's (audit SEO-7). The page
++ // links to sign-in, one click from where the redirect went. A form still gets a redirect: a
++ // person submitting one has a session that ended, and sign-in brings them back to the page.
++ if (req.method !== 'GET') {
++ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ return null;
++ }
++ // The visible link goes back to the path only. A query string on a private page holds words that must
++ // never come back in an anonymous response — the security suite reads every 401 body for the private
++ // room's words, and echoing ?q= here put them there (SEO-7 review, 2026-10-29).
++ await page(req, res, 401, {
++ actor, title: 'Sign in to continue', path: req.path, active: null, footer: 'compact', robots: 'noindex, nofollow',
++ body: `<h1>Sign in to continue</h1>
++<p class="oc-muted">This page is for OpenVibe accounts. Sign in and you come straight back here.</p>
++<p><a class="oc-btn" href="/auth/login?next=${encodeURIComponent(req.path)}">Sign in with OpenVibe</a></p>`,
++ });
return null;
};
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex d85676e..46786cb 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -289,8 +289,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
}
router.get('/vibe/memory', async (req, res) => {
-- const actor = await viewerOf(req);
-- if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent('/vibe/memory')}`);
++ const actor = await needUser(req, res); if (!actor) return;
await renderMemory(req, res, actor);
});
@@@ -321,8 +320,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
});
router.get('/vibe/:slug', async (req, res) => {
-- const actor = await viewerOf(req);
-- if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ const actor = await needUser(req, res); if (!actor) return;
const room = await vibe.ownChat(req.params.slug, actor.user);
if (!room) return render(req, res, { status: 404, error: 'That chat is not here: it may have been deleted. Start a new one below.' });
await render(req, res, { room });
ddiff --git a/test/dm-calls.test.js b/test/dm-calls.test.js
iindex 2dae83c..f792099 100644
---- a/test/dm-calls.test.js
++++ b/test/dm-calls.test.js
@@@ -115,7 +115,7 @@ t('Call rings the other person (a direct row) and opens the call page with the r
assert.strictEqual((await req('GET', '/call/user-1-nothing', { headers: cookie(alice) })).status, 404);
assert.strictEqual((await req('GET', '/call/public', { headers: cookie(alice) })).status, 404, 'the public lobby is not a 1:1 call page');
const anon = await req('GET', `/call/${encodeURIComponent(channelId)}`);
-- assert.strictEqual(anon.status, 303, 'sign in first');
++ assert.strictEqual(anon.status, 401, 'sign in first (served here with noindex, not bounced to Network sign-in)');
});
t('ring.js: a ring shows Answer and Decline; answering tells the caller and opens the call page', async () => {
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex 4d3358b..57bbd09 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -36,6 +36,19 @@ const FIX = path.join(__dirname, 'fixtures', 'n-1');
const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
++// Calls whose answer this release changes on purpose, and why (SEO-7). The fixture is N-1's own answer
++// recorded from production, so it is not edited: a deliberate change is declared here instead, and a
++// declaration that no longer names a recorded call fails the run rather than sitting there unread.
++const INTENTIONAL = [
++ { auth: 'anon', method: 'GET', path: '/messages', status: 401, location: null, why: 'SEO-7: a gated page answers on this origin, noindex, instead of a redirect to Network sign-in' },
++ { auth: 'anon', method: 'GET', path: '/settings', status: 401, location: null, why: 'SEO-7: a gated page answers on this origin, noindex, instead of a redirect to Network sign-in' },
++];
++for (const change of INTENTIONAL) {
++ const rec = client.calls.find((c) => c.auth === change.auth && c.method === change.method && c.path === change.path);
++ if (!rec) throw new Error(`intentional change names a call N-1 does not make: ${change.auth} ${change.method} ${change.path}`);
++ Object.assign(rec, { status: change.status, location: change.location, intentional: change.why });
++}
++
if (worker.engine !== 'postgresql') throw new Error('N-1 fixture must use PostgreSQL');
const { createDb } = require('openvibe-sdk/db');
const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
ddiff --git a/test/vibe-memory.test.js b/test/vibe-memory.test.js
iindex 7a85df9..7188d80 100644
---- a/test/vibe-memory.test.js
++++ b/test/vibe-memory.test.js
@@@ -157,7 +157,10 @@ t('the API: list, add, delete, clear; someone else\'s note is not there; signed
t('the page: the notes with Delete, add, switch, forget everything; signed out asks to sign in', async () => {
const out = await web('GET', '/vibe/memory', null);
-- assert.deepStrictEqual([out.status, out.location], [303, '/auth/login?next=%2Fvibe%2Fmemory']);
++ // A gated page is served here with noindex, not redirected to Network sign-in (SEO-7).
++ assert.strictEqual(out.status, 401);
++ assert.match(out.text, /<meta name="robots" content="noindex/);
++ assert.match(out.text, /href="\/auth\/login\?next=%2Fvibe%2Fmemory"/);
const page = await web('GET', '/vibe/memory', ann);
assert.strictEqual(page.status, 200);
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 7d9513b..4013763 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -80,8 +80,26 @@ t('the composer posts without JavaScript, through the API\'s checks; other origi
assert.match(r.headers.get('location'), /^\/auth\/login\?next=/, 'signed out: sign in first');
});
++t('SEO-7: a gated page answers on this origin with noindex, a public page keeps its own canonical', async () => {
++ const gated = await req('GET', '/inbox');
++ assert.strictEqual(gated.status, 401);
++ assert.match(gated.text, /<meta name="robots" content="noindex, nofollow">/);
++ assert.match(gated.text, /href="\/auth\/login\?next=%2Finbox"/, 'one click from signing in');
++ // The rule: a gated page never names another origin as its canonical. It used to be a redirect whose
++ // destination carried openvibe.network/login as the canonical, so a crawler read that as this page's.
++ const canon = (/<link rel="canonical" href="([^"]+)"/.exec(gated.text) || [])[1];
++ assert.ok(!canon || new URL(canon).origin !== 'https://openvibe.network', `canonical ${canon}`);
++ const pub = await req('GET', '/');
++ assert.strictEqual(pub.status, 200);
++ assert.match(pub.text, /<meta name="robots" content="index, follow">/);
++ const pubCanon = (/<link rel="canonical" href="([^"]+)"/.exec(pub.text) || [])[1];
++ assert.ok(pubCanon && pubCanon.endsWith('/') && !pubCanon.includes('openvibe.network'), `public canonical ${pubCanon}`);
++});
++
t('messages: start a conversation by username, reply without JavaScript, participants only', async () => {
-- assert.match((await req('GET', '/messages')).headers.get('location'), /^\/auth\/login\?next=%2Fmessages/);
++ const gate = await req('GET', '/messages');
++ assert.strictEqual(gate.status, 401, 'signed out: a gate page here, noindex (SEO-7)');
++ assert.match(gate.text, /href="\/auth\/login\?next=%2Fmessages"/);
let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
assert.strictEqual(r.status, 303);
const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));