openvibers/openvibe.space Commits

Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#38)

OP OpenVibers committed 12 Oct 2026 · 5db3eeb031 · 6 files, +35 −5

Plan T11's SEO kit calls for "sitemaps with lastmod". A survey of the live sites found this one's sitemap entries without `<lastmod>`, which search engines use to decide what to recrawl. Every entry now carries an honest one:

- **Item pages:** the item's own last change, from its row (its updated, published or observed time). Never "now", and never invented.
- **Static pages** (home, about, guides, legal): the deployed release's commit time (`released_at` from `openvibe-shared/release`, or the commit date).
- **Sitemap index entries:** the newest lastmod of their map. An empty map gets none, rather than an invented date.

The README says where lastmod comes from. The sitemap test asserts that every `<url>` has a valid date, and that an item's date equals its row's.

Part of a sweep over twelve sites, built by a Codex job and reviewed. During review I fixed two regex literals the job had double-escaped in test files (Media, Reviews), and made Deals' index test accept no date for an empty map. Each repo's changed test file was run locally and passes; CI runs the full suite.

Changes

ddiff --git a/README.md b/README.md
iindex d7c183a..c6ee3f7 100644
---- a/README.md
++++ b/README.md
@@@ -42,6 +42,8 @@ Nothing that is not a forum path redirects.
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |
++Sitemap entries carry lastmod from the release commit or, for hosted pages, the active deploy.
++
Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.
## Capabilities and events
ddiff --git a/server/app.js b/server/app.js
iindex 958a413..dd44435 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -157,7 +157,7 @@ async function createApp(opts = {}) {
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
app.get('/sitemap.xml', async (_req, res) => {
let rows;
-- try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
++ try { rows = await discovery.sitemapRows(release.full().released_at === release.full().booted_at ? undefined : release.full().released_at); } catch { return res.status(503).end(); }
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
});
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 7462318..9e7dc67 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -39,8 +39,8 @@ async function llmsFullSections() {
// ── sitemap.xml rows ─────────────────────────────────────────
/** Space's only page is the home page. */
--async function sitemapRows() {
-- return [{ loc: abs('/'), lastmod: null, changefreq: 'weekly', priority: '1.0' }];
++async function sitemapRows(releaseLastmod) {
++ return [{ loc: abs('/'), lastmod: releaseLastmod, changefreq: 'weekly', priority: '1.0' }];
}
module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex 10a0b4f..20aa2ad 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -132,6 +132,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_id
WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
++ deployTime: db.prepare('SELECT created_at FROM space_deploys WHERE id = ?'),
deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),
siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),
@@@ -256,6 +257,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const origin = originOf(req);
if (!origin) return await notFound(req, res, site, 'invalid');
const locs = [];
++ const deploy = await q.deployTime.get(site.active_deploy_id);
++ const lastmod = deploy && deploy.created_at ? new Date(deploy.created_at).toISOString() : undefined;
const seen = new Set();
for (const row of await q.htmlFiles.all(site.active_deploy_id)) {
const p = pagePath(row.path);
@@@ -263,7 +266,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const loc = `${origin}${p}`;
if (seen.has(loc)) continue;
seen.add(loc);
-- locs.push({ loc });
++ locs.push({ loc, lastmod });
}
const body = sitemapXml(locs);
res.statusCode = 200;
ddiff --git a/test/home.test.js b/test/home.test.js
iindex a54b68e..9a6f497 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -5,6 +5,17 @@
* files describe the same site and the sitemap holds the home page alone; health and readiness answer.
*/
const assert = require('assert');
++
++function assertSitemapLastmods(xml, tag = 'url') {
++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
++ for (const [, body] of entries) {
++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
++ assert.ok(match, body);
++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
++ }
++ return entries;
++}
++
const { boot, check, done } = require('./helpers/app');
(async () => {
@@@ -45,6 +56,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
assert.deepStrictEqual(locs, ['https://openvibe.space/']);
++ assert.strictEqual(assertSitemapLastmods(r.text).length, 1);
});
await check('health and readiness answer, and readiness names the store', async () => {
ddiff --git a/test/hosting-sitemap.test.js b/test/hosting-sitemap.test.js
iindex f725a22..67941cf 100644
---- a/test/hosting-sitemap.test.js
++++ b/test/hosting-sitemap.test.js
@@@ -7,6 +7,17 @@
* either path always wins. Neither generated body carries a secret, and both say how to cache them.
*/
const assert = require('assert');
++
++function assertSitemapLastmods(xml, tag = 'url') {
++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
++ for (const [, body] of entries) {
++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
++ assert.ok(match, body);
++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
++ }
++ return entries;
++}
++
const { boot, check, done } = require('./stageb/boot');
const HOST = 'widgets.openvibe.website';
@@@ -19,7 +30,7 @@ const CUSTOM = 'www.widgets-example.org';
const site = await t.site(alice, project.id, 'widgets');
const origin = `https://${HOST}`;
-- await t.deploy(alice, site.id, {
++ const firstDeploy = await t.deploy(alice, site.id, {
'index.html': '<h1>Home</h1>',
'about.html': '<h1>About</h1>',
'docs/index.html': '<h1>Docs</h1>',
@@@ -34,6 +45,8 @@ const CUSTOM = 'www.widgets-example.org';
assert.match(r.headers['content-type'], /application\/xml/);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
++ assert.strictEqual(assertSitemapLastmods(r.text).length, 3);
++ assert.ok(r.text.includes(`<lastmod>${new Date(firstDeploy.created_at).toISOString().slice(0, 10)}</lastmod>`));
});
await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {