openvibers/openvibe.meme Commits

Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#2)

OP OpenVibers committed 11 days ago · 36d8d0d96e · 7 files, +205 −5

* Agent layer: /.well-known/agent.json and Markdown twins of the public pages

openvibe-shared v4.34.0 (was v4.31.0). The descriptor is what openvibe.meme really serves: API /api/v1, docs
/developers, and the app's own limits; no OpenAPI document or MCP endpoint is published. server/agent-pages.js
twins the home, /week, /make, public entries /m/:slug, public captions /c/:id and /developers through the same
views and layout the HTML routes use, always signed out; a hidden entry or caption, the report form, the week
vote and the owner controls have no twin. The mount is gated to twin requests only, so a browser and a page drawn
for a signed-in person still get the site's own route. HTML pages name their twin in the head and the Link
header; robots.txt names the manifest.

* Merge remote-tracking branch 'origin/main' into refresh-2

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* Merge remote-tracking branch 'origin/main' into refresh-2

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..e3e5b83
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.meme tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the JSON API under /api/v1, the developers page that
++// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.
++module.exports = {
++ id: 'meme',
++ name: 'OpenVibe.Meme',
++ purpose: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
++ home: 'https://openvibe.meme',
++ site: 'openvibe.meme',
++ api: '/api/v1',
++ docs: '/developers',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..3ee93f0
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,104 @@
++'use strict';
++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer), rendered through the
++// same views and layout as the HTML routes and always signed out: the entry report form, the week vote and a
++// caption's owner controls never appear in a twin, and a hidden entry or caption has no twin at all.
++const seo = require('openvibe-shared/seo');
++const views = require('./views');
++const { raw } = require('./render/html');
++const { renderPage } = require('./render/layout');
++const { homeJsonLd } = require('./http/discovery');
++const { currentWeek } = require('./week');
++
++function createAgentPages(ctx) {
++ const { config, s, entries } = ctx;
++ const base = () => String(config.baseUrl).replace(/\/+$/, '');
++ const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
++
++ async function counts() {
++ const rows = await s.db.many("SELECT COUNT(*)::int AS entries, (SELECT COUNT(*)::int FROM meme_citations c JOIN meme_entries e ON e.id = c.entry_id WHERE e.visibility = 'public') AS citations FROM meme_entries WHERE visibility = 'public'");
++ return rows[0] || { entries: 0, citations: 0 };
++ }
++
++ async function home() {
++ const { week } = currentWeek(s.now());
++ const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
++ return page('/', {
++ index: true, title: null,
++ description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
++ jsonLd: homeJsonLd(config),
++ body: raw(views.home({ trending, week: weekOut, counts: c })),
++ });
++ }
++
++ async function make(query) {
++ const templates = await entries.templates();
++ const values = { template_id: query.get('template_id') || '', visibility: query.get('visibility') || 'public' };
++ for (const i of [1, 2, 3, 4]) values[`line${i}`] = String(query.get(`line${i}`) || '').slice(0, 120);
++ const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;
++ const previewLines = [1, 2, 3, 4].map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
++ const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
++ return page('/make', {
++ index: true, title: 'Make a caption',
++ description: 'Pick a template, type up to four lines and preview them over the image before saving.',
++ body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),
++ });
++ }
++
++ async function renderPublicPage(rawPath, { query } = {}) {
++ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
++ const q = query instanceof URLSearchParams ? query : new URLSearchParams();
++ if (p === '/') return home();
++ if (p === '/week') {
++ const { week } = currentWeek(s.now());
++ const out = await entries.week({ week });
++ return page(p, {
++ index: true, title: 'Meme of the week',
++ description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
++ body: raw(views.week({ out, signedIn: false, voted: false, error: false })),
++ });
++ }
++ if (p === '/make') return make(q);
++ if (p === '/developers') {
++ return page(p, {
++ index: true, title: 'The OpenVibe.Meme API',
++ description: 'The JSON API, the Search document and the public sources the importer reads.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
++ body: raw(views.developers()),
++ });
++ }
++ const entryMatch = p.match(/^\/m\/([^/]+)$/);
++ if (entryMatch) {
++ let slug;
++ try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
++ const row = await entries.bySlug(slug);
++ if (!row || row.visibility !== 'public') return null;
++ const citations = await entries.citations(row.id);
++ const e = entries.wireEntry(row);
++ return page(p, {
++ index: true, title: e.title,
++ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),
++ jsonLd: [seo.jsonLd.webPage({ name: e.title, url: `${base()}/m/${e.slug}`, description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160), siteUrl: base() })],
++ body: raw(views.entry({ e, citations, signedIn: false, staff: false, reported: false, problem: null })),
++ });
++ }
++ const captionMatch = p.match(/^\/c\/([^/]+)$/);
++ if (captionMatch) {
++ let id;
++ try { id = decodeURIComponent(captionMatch[1]); } catch { return null; }
++ const c = await entries.captionById(id);
++ if (!c || c.visibility !== 'public') return null;
++ const template = await entries.templateById(c.template_id);
++ return page(p, {
++ index: true,
++ title: template ? `A caption on ${template.name}` : 'A saved caption',
++ description: c.lines.join(' / ').slice(0, 160),
++ body: raw(views.caption({ caption: c, template, mine: false })),
++ });
++ }
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 5672364..2322a43 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -196,6 +196,22 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ // ── Agent layer (openvibe-shared/agent-layer) ───────────
++ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser and a
++ // page drawn for a signed-in person always reach the site's own routes; the twin renderer is signed out.
++ {
++ const agentLayer = require('openvibe-shared/agent-layer');
++ const agent = express.Router();
++ agent.use((req, _res, next) => {
++ const twinPath = req.path.endsWith('.md');
++ const twinFormat = req.query.format === 'markdown';
++ const twinAccept = !!(req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
++ if (req.path === '/.well-known/agent.json' || twinPath || twinFormat || twinAccept) return next();
++ return next('router');
++ });
++ agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
++ app.use(agent);
++ }
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a31b8fa..fad0981 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'
-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
r.get('/llms.txt', (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a4ac96c..decd037 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -47,7 +47,7 @@ function createPageRoutes(ctx) {
const { week } = currentWeek(s.now());
const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
jsonLd: homeJsonLd(config),
@@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
});
return page(req, res, {
index: e.visibility === 'public',
++ agentLayer: e.visibility === 'public',
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
title: e.title,
description,
@@@ -116,7 +117,7 @@ function createPageRoutes(ctx) {
const subject = signedIn(req) ? req.viewer.subject : null;
const out = await entries.week({ week, subject });
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Meme of the week',
description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),
@@@ -146,7 +147,7 @@ function createPageRoutes(ctx) {
const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Make a caption',
description: 'Pick a template, type up to four lines and preview them over the image before saving.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
@@@ -172,6 +173,7 @@ function createPageRoutes(ctx) {
const template = await entries.templateById(c.template_id);
return page(req, res, {
index: c.visibility === 'public',
++ agentLayer: c.visibility === 'public',
cache: c.visibility === 'public' ? PUBLIC_CACHE : null,
title: template ? `A caption on ${template.name}` : 'A saved caption',
description: c.lines.join(' / ').slice(0, 160),
@@@ -181,7 +183,7 @@ function createPageRoutes(ctx) {
// ── Developers ──────────────────────────────────────────
r.get('/developers', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'The OpenVibe.Meme API',
description: 'The JSON API, the Search document and the public sources the importer reads.',
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 458c08e..1acb809 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -74,6 +74,7 @@ function renderPage(o) {
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
++ agentLayer: Boolean(o.agentLayer),
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
@@@ -112,6 +113,10 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
res.set('Vary', 'Cookie');
++ if (o.agentLayer) {
++ const at = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;
++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(at).header);
++ }
res.status(status).type('html').send(renderPage(o));
}
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
nnew file mode 100644
iindex 0000000..661554a
---- /dev/null
++++ b/test/agent-layer.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++/** The agent layer: the manifest and the Markdown twins of the public pages, rendered through the same views. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('manifest is JSON with only the endpoints Meme really serves', async () => {
++ const r = await t.get('/.well-known/agent.json');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /application\/json/);
++ const m = r.json();
++ assert.strictEqual(m.id, 'meme');
++ assert.strictEqual(m.home, 'https://openvibe.meme');
++ assert.strictEqual(m.api, 'https://openvibe.meme/api/v1');
++ assert.strictEqual(m.docs, 'https://openvibe.meme/developers');
++ assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
++ assert.strictEqual(m.openapi, undefined);
++ assert.strictEqual(m.mcp, undefined);
++ });
++
++ await check('the home names its twin in the head and the Link header', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<link rel="alternate" type="text/markdown" href="https://openvibe.meme/index.md">'));
++ assert.match(r.headers.get('link'), /https:\/\/openvibe\.meme\/index\.md>; rel="alternate"; type="text\/markdown"/);
++ });
++
++ await check('/index.md and content twins are Markdown of the same pages; unknown twins are 404', async () => {
++ const home = await t.get('/index.md');
++ assert.strictEqual(home.status, 200);
++ assert.match(home.headers.get('content-type'), /text\/markdown/);
++ assert.ok(home.text.startsWith('---\ntitle:'));
++ for (const [path, heading] of [['/developers.md', '# The OpenVibe.Meme API'], ['/week.md', '# Meme of the week'], ['/make.md', '# Make a caption']]) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, path);
++ assert.ok(r.text.includes(heading), `${path} missing ${heading}`);
++ }
++ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
++ });
++
++ await check('/.well-known/security.txt is not swallowed by the layer, and robots names the manifest', async () => {
++ const sec = await t.get('/.well-known/security.txt');
++ assert.strictEqual(sec.status, 404);
++ assert.doesNotMatch(sec.headers.get('content-type') || '', /application\/json/);
++ const robots = await t.get('/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ assert.ok(robots.text.includes('# Agent manifest: https://openvibe.meme/.well-known/agent.json'));
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})();