openvibers/openvibe.chat Commits
SEO-7: a gated chat page answers here, noindex, not by bouncing to Network sign-in (#241)
Changes
@@@ -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;};@@@ -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 });@@@ -115,7 +115,7 @@ t('Call rings the other person (a direct row) and opens the call page with the rassert.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 () => {@@@ -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) };@@@ -157,7 +157,10 @@ t('the API: list, add, delete, clear; someone else\'s note is not there; signedt('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>/);@@@ -80,8 +80,26 @@ t('the composer posts without JavaScript, through the API\'s checks; other origiassert.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'));
@@ -195,7 +195,23 @@ function createWebRoutes({ config }) {
@@ -195,7 +195,23 @@ function createWebRoutes({ config }) {
const needUser = async (req, res) => {
const needUser = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
if (actor.kind === 'user') return actor;
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;
return null;
};
};
@@ -289,8 +289,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
@@ -289,8 +289,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
}
}
router.get('/vibe/memory', async (req, res) => {
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);
await renderMemory(req, res, actor);
});
});
@@ -321,8 +320,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
@@ -321,8 +320,7 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
});
});
router.get('/vibe/:slug', async (req, res) => {
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);
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.' });
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 });
await render(req, res, { room });
@@ -115,7 +115,7 @@ t('Call rings the other person (a direct row) and opens the call page with the r
@@ -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/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');
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)}`);
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 () => {
t('ring.js: a ring shows Answer and Decline; answering tells the caller and opens the call page', async () => {
@@ -36,6 +36,19 @@ const FIX = path.join(__dirname, 'fixtures', 'n-1');
@@ -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 client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.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');
if (worker.engine !== 'postgresql') throw new Error('N-1 fixture must use PostgreSQL');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
@@ -157,7 +157,10 @@ t('the API: list, add, delete, clear; someone else\'s note is not there; signed
@@ -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 () => {
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);
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);
const page = await web('GET', '/vibe/memory', ann);
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
@@ -80,8 +80,26 @@ t('the composer posts without JavaScript, through the API\'s checks; other origi
@@ -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');
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 () => {
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' }));
let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));
const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));