openvibers/openvibe.space Commits

Forum links follow Community's move to /forum (#222)

OP OpenVibers committed 6 days ago · 3afb26341f · 9 files, +57 −30

Community's forum moved from /s to /forum and a thread's /t/ segment went with it
(design 2026-11-07). Space's forum redirect now maps /s and /s/* straight to
/forum and /forum/<space>/<thread>, so a bookmark lands in one hop instead of a
second redirect; /s/new-space becomes /forum/new. The home page, both navbars,
llms.txt and the docs link https://openvibe.community/forum. The forum API paths
still answer 308 unchanged.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 275f6d0..c691eeb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,10 @@
## Unreleased
++- **The forum's links follow Community's move to `/forum`** (design 2026-11-07): Space's forum redirect now
++ maps `/s` and `/s/*` straight to `/forum` and `/forum/<space>/<thread>` (the `/t/` segment is gone) instead
++ of passing the old path on, so a bookmark lands in one hop. The home page, both navbars and `llms.txt` link
++ `https://openvibe.community/forum`; the forum API paths still answer 308 unchanged.
- **Official example spaces** (plan §4 Space: "Next: official example spaces"): three published examples owned by the official OpenVibe account — a link page (`ov-links`), a portfolio (`ov-portfolio`) and a monthly community night with a live RSVP list (`ov-night`) — adapted from the Space kit's `site-links`, `site-portfolio` and `site-event` templates and kept in `seeds/official-spaces/`. `node scripts/seed-official-spaces.js` creates and publishes them through the service's own modules (`server/spaces/official.js`); it is idempotent, `--revise` repairs drift, and a slug somebody else holds is reported and never taken. `spaces.create` now accepts an explicit `slug` (same checks as a generated one). `test/official-spaces.test.js`.
- **Share:** a space's page carries the network's one Share button (`data-ov-share-sheet`) in its actions; it shares the published space's own address (or the page's address for a draft), with its name and description. `test/spaces.test.js`.
- **Guest drafts** (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor who is not signed in makes a draft on openvibe.space (`GET/POST /spaces/guest`) from a website template or a sentence, and edits it with a secret link (`/spaces/<id>/edit?key=…`; the cookie only remembers it, the link alone works). The draft has no owner and of the secret only its sha256 is kept. It is static: never published, no visitor's data, no server functions; in the preview the SDK answers "sign in to use this" where a visitor's account would be needed. A signed-in person who holds the secret claims it (`POST /spaces/<id>/claim`): it becomes theirs, the secret is cleared, and `space.app.claimed` is logged. The limits (per address, per draft, per service) and the seven-day expiry are in `server/hosting-config.js`; the abuse screen (`server/spaces/content-check.js`) refuses a phishing page and any form in a guest draft that posts to another origin.
ddiff --git a/README.md b/README.md
iindex e1e3e1b..4807287 100644
---- a/README.md
++++ b/README.md
@@@ -55,16 +55,16 @@ The forum lived here from 3 October 2026 (it moved off Community then). On **202
moved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,
per-space moderators, the Discord relay) again, and Space became the network's hosting (static sites now; dynamic pages and spaces next).
--So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path and
--query on https://openvibe.community**:
++So Space keeps no forum code and **every forum URL answers a permanent redirect to the forum's
++address on https://openvibe.community** (which moved from `/s` to `/forum` on 2026-11-07):
--| Path | Methods | Status |
--| --- | --- | --- |
--| `/s`, `/s/*` (pages, feeds) | GET, HEAD | `301` |
--| `/s`, `/s/*` | anything else | `308` (the method and body are preserved) |
--| `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*`, `/api/v1/relay*` | all | `308` |
++| Path | Methods | Status | Goes to |
++| --- | --- | --- | --- |
++| `/s`, `/s/*` (pages, feeds) | GET, HEAD | `301` | `/forum/<space>/<thread>` (the `/t/` segment is gone) |
++| `/s`, `/s/*` | anything else | `308` (the method and body are preserved) | the same `/forum` path |
++| `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*`, `/api/v1/relay*` | all | `308` | the same API path |
--A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.
++A redirected bookmark, crawler or API client therefore lands on Community's copy in one hop.
Nothing that is not a forum path redirects.
## Routes
ddiff --git a/server/app.js b/server/app.js
iindex 134d041..fd77194 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -12,9 +12,10 @@
* GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)
* GET /api/health, /api/ready, /release.json, /metrics (loopback)
*
-- * The forum is OpenVibe.Community's again (owner decision, 2026-10-08): every forum URL here answers
-- * a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308
-- * otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).
++ * The forum is OpenVibe.Community's again (owner decision, 2026-10-08), and it lives at /forum there
++ * (2026-11-07): every forum URL here answers a permanent redirect to it — /s and /s/* (301 for GET and
++ * HEAD, 308 otherwise), mapped to /forum/<space>/<thread>, and /api/v1/spaces*, /api/v1/posts/*,
++ * /api/v1/space-groups* and /api/v1/relay* (308, path unchanged).
*
* Space holds hosted content and emits hosting lifecycle events. The historical forum rows belong
* to Community; the forum redirects below stay intact. See README.md.
@@@ -44,16 +45,29 @@ const FORUM_PAGE = /^\/s(\/|$)/;
const FORUM_API = /^\/api\/v1\/(spaces|posts|space-groups|relay)(\/|$)/;
/**
-- * Permanent redirect to the same path and query on Community. A GET or HEAD of a page keeps its
-- * method with a 301; anything else (a form post, a vote) must not be replayed as a GET, and the
-- * APIs are called with bodies, so they always answer 308.
++ * Permanent redirect to the forum's address on Community. The forum moved from /s (spaces) to /forum
++ * and dropped a thread's /t/ segment (2026-11-07), so /s/general/t/123 becomes /forum/general/123 —
++ * one hop, never a second redirect. A GET or HEAD of a page keeps its method with a 301; anything else
++ * (a form post, a vote) must not be replayed as a GET, and the APIs are called with bodies, so they
++ * always answer 308.
*/
++function forumTarget(url) {
++ const qi = url.indexOf('?');
++ const pathname = qi === -1 ? url : url.slice(0, qi);
++ const query = qi === -1 ? '' : url.slice(qi);
++ if (pathname === '/s') return `/forum${query}`;
++ let rest = pathname.replace(/\/+$/, '');
++ if (rest === '/s/new-space') return `/forum/new${query}`;
++ rest = rest.replace(/^\/s\/([^/]+)\/t(?=\/|$)/, '/forum/$1').replace(/^\/s(?=\/|$)/, '/forum');
++ return `${rest}${query}`;
++}
++
function forumRedirect(req, res, next) {
const isPage = FORUM_PAGE.test(req.path);
if (!isPage && !FORUM_API.test(req.path)) return next();
const method = String(req.method || 'GET').toUpperCase();
const status = isPage && (method === 'GET' || method === 'HEAD') ? 301 : 308;
-- res.redirect(status, `${config.communityUrl}${req.originalUrl}`);
++ res.redirect(status, `${config.communityUrl}${isPage ? forumTarget(req.originalUrl) : req.originalUrl}`);
}
async function createApp(opts = {}) {
ddiff --git a/server/discovery.js b/server/discovery.js
iindex ca55e66..a347250 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -28,7 +28,7 @@ function llmsSections() {
{ title: 'Make a space', url: `${u}/spaces/new`, html: true, note: 'describe it to the AI builder, or start from a template (sign-in required)' },
] },
{ title: 'The network', links: [
-- { title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },
++ { title: 'The forum', url: `${config.communityUrl}/forum`, note: 'OpenVibe.Community hosts the forum' },
{ title: 'OpenVibe.Network', url: config.networkUrl, note: 'identity, accounts and sign-in' },
] },
];
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a5fc61b..420b9e8 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -70,7 +70,7 @@ function navbarInit(opts) {
{ label: 'Home', href: '/', active: opts.active === 'home' },
{ label: 'Spaces', href: '/spaces', active: opts.active === 'spaces' },
{ label: 'Hosting', href: '/dashboard' },
-- { label: 'The forum', href: `${config.communityUrl}/s` },
++ { label: 'The forum', href: `${config.communityUrl}/forum` },
],
menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
@@@ -96,7 +96,7 @@ function footerInit(opts) {
heading: 'Space',
items: [
{ name: 'Home', url: '/' },
-- { name: 'The forum', url: `${config.communityUrl}/s` },
++ { name: 'The forum', url: `${config.communityUrl}/forum` },
{ name: 'OpenVibe.Community', url: config.communityUrl },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Space' },
],
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 2af2ac9..5c3910f 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -12,7 +12,7 @@ const sc = require('openvibe-shared/showcase');
const { renderPage, escapeHtml: esc } = require('./layout');
const { pickBlock, foldBlock } = require('./short');
--const FORUM_URL = `${config.communityUrl}/s`;
++const FORUM_URL = `${config.communityUrl}/forum`;
const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
const SERVICES_URL = 'https://openvibe.services/';
const TEMPLATES = require('../spaces/templates').list();
ddiff --git a/test/boot.test.js b/test/boot.test.js
iindex c53622c..cd7da0b 100644
---- a/test/boot.test.js
++++ b/test/boot.test.js
@@@ -70,16 +70,16 @@ async function check(name, fn) {
assert.match(html, /<title>OpenVibe\.Space — spaces and static hosting<\/title>/);
assert.match(html, /<h1>Make a space\.<span class="sc-accent"> An app on your OpenVibe account\.<\/span><\/h1>/);
assert.match(html, /The forum moved to OpenVibe\.Community/);
-- assert.match(html, /href="https:\/\/openvibe\.community\/s"/);
++ assert.match(html, /href="https:\/\/openvibe\.community\/forum"/);
});
await check('every forum URL is a permanent redirect to OpenVibe.Community', async () => {
const s = await fetch(`${base}/s`, { redirect: 'manual' });
assert.strictEqual(s.status, 301);
-- assert.strictEqual(s.headers.get('location'), 'https://openvibe.community/s');
++ assert.strictEqual(s.headers.get('location'), 'https://openvibe.community/forum');
const nested = await fetch(`${base}/s/general/t/1?x=2`, { redirect: 'manual' });
assert.strictEqual(nested.status, 301);
-- assert.strictEqual(nested.headers.get('location'), 'https://openvibe.community/s/general/t/1?x=2');
++ assert.strictEqual(nested.headers.get('location'), 'https://openvibe.community/forum/general/1?x=2');
const api = await fetch(`${base}/api/v1/spaces/general/threads`, { redirect: 'manual' });
assert.strictEqual(api.status, 308);
assert.strictEqual(api.headers.get('location'), 'https://openvibe.community/api/v1/spaces/general/threads');
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 168ed77..96b059c 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(r.text, /What works now/);
assert.match(r.text, /showcase\.css/, 'built from the network\'s showcase sections');
assert.match(r.text, /The forum moved to OpenVibe\.Community/);
-- assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');
++ assert.match(r.text, /href="https:\/\/openvibe\.community\/forum"/, 'the forum link points at Community');
assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);
assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');
});
@@@ -74,7 +74,7 @@ const { boot, check, done } = require('./helpers/app');
const r = await t.get('/llms.txt');
assert.strictEqual(r.status, 200);
assert.match(r.text, /OpenVibe\.Space/);
-- assert.match(r.text, /https:\/\/openvibe\.community\/s/);
++ assert.match(r.text, /https:\/\/openvibe\.community\/forum/);
assert.doesNotMatch(r.text, /\/s\/feed\.xml/, 'no forum feed is advertised');
});
ddiff --git a/test/redirects.test.js b/test/redirects.test.js
iindex bcc94e1..293846b 100644
---- a/test/redirects.test.js
++++ b/test/redirects.test.js
@@@ -13,24 +13,33 @@ const COMMUNITY = 'https://openvibe.community';
(async () => {
const t = await boot();
-- await check('GET and HEAD of /s and its nested paths answer 301 to Community, path and query intact', async () => {
-- for (const path of ['/s', '/s?sort=new', '/s/general', '/s/general/t/123?x=1&y=two', '/s/feed.xml', '/s/general/feed.xml']) {
++ await check('GET and HEAD of /s and its nested paths answer 301 to the forum on Community, mapped to /forum', async () => {
++ const map = [
++ ['/s', '/forum'],
++ ['/s?sort=new', '/forum?sort=new'],
++ ['/s/general', '/forum/general'],
++ ['/s/general/t/123?x=1&y=two', '/forum/general/123?x=1&y=two'],
++ ['/s/feed.xml', '/forum/feed.xml'],
++ ['/s/general/feed.xml', '/forum/general/feed.xml'],
++ ['/s/new-space', '/forum/new'],
++ ];
++ for (const [path, to] of map) {
const r = await t.get(path);
assert.strictEqual(r.status, 301, path);
-- assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${path}`, path);
++ assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${to}`, path);
}
const head = await t.get('/s?sort=top', { method: 'HEAD' });
assert.strictEqual(head.status, 301);
-- assert.strictEqual(head.headers.get('location'), `${COMMUNITY}/s?sort=top`);
++ assert.strictEqual(head.headers.get('location'), `${COMMUNITY}/forum?sort=top`);
});
await check('other methods of /s and /s/* answer 308 (the method and body are preserved)', async () => {
const post = await t.get('/s/general/threads?q=1', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });
assert.strictEqual(post.status, 308);
-- assert.strictEqual(post.headers.get('location'), `${COMMUNITY}/s/general/threads?q=1`);
++ assert.strictEqual(post.headers.get('location'), `${COMMUNITY}/forum/general/threads?q=1`);
const del = await t.get('/s/general/t/123/posts/9', { method: 'DELETE' });
assert.strictEqual(del.status, 308);
-- assert.strictEqual(del.headers.get('location'), `${COMMUNITY}/s/general/t/123/posts/9`);
++ assert.strictEqual(del.headers.get('location'), `${COMMUNITY}/forum/general/123/posts/9`);
});
await check('the forum APIs answer 308 to Community, path and query intact', async () => {