openvibers/openvibe.vip Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#115)
Changes
nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// What openvibe.vip tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves: the JSON API, the llms.txt/llms-full.txt/sitemap map,++// and the auth that API accepts (service client-credentials tokens and Network user tokens). No OpenAPI++// document or documentation page is published, so neither is named.++module.exports = {++ id: 'vip',++ name: 'OpenVibe.VIP',++ purpose: 'Memberships across the OpenVibe network: creators publish plans and versioned terms, members join through OpenVibe.Billing, and perks and gated resources follow the membership.',++ home: 'https://openvibe.vip',++ site: 'openvibe.vip',++ api: '/api/v1',++ policy: {++ auth: ['none', 'Network OIDC', 'service token'],++ },++};nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++// The Markdown twins of openvibe.vip' public pages (openvibe-shared/agent-layer), rendered by the same++// public data readers and page builders the routes use (web/pages.js through web/layout.js). They are++// signed out, so no member state, join form token or owner view can leak into a twin. Private pages++// (/me, /dashboard, auth, API) are never rendered here, and a creator page with no published plan is++// noindex on the site, so it has no twin either.++const pages = require('./web/pages');++++const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'llms.txt', 'llms-full.txt', 'release.json', 'metrics', 'favicon.ico', 'embed']);++++function createAgentPages({ domain, config, layout, liveNow }) {++ const { creators, plans, prices, billing } = domain;++++ async function home() {++ const list = (await creators.listPublic()).map(creators.present);++ const live = liveNow ? await liveNow.list() : [];++ return {++ html: layout.page({++ title: pages.HOME_TITLE, description: pages.HOME_DESCRIPTION, jsonLd: [pages.homeFaqJsonLd()],++ active: 'home', canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET], viewer: null,++ body: pages.home({ creators: list, live, networkUrl: config.network.url }),++ }),++ };++ }++++ function updates() {++ return {++ html: layout.page({++ canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', viewer: null,++ body: require('openvibe-shared/frame').updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${require('openvibe-shared/serve').url('shipped.js')}" defer></script>`,++ }),++ };++ }++++ async function creator(username) {++ if (RESERVED.has(String(username).toLowerCase())) return null;++ const c = await creators.byUsername(username);++ if (!c || c.status !== 'active') return null;++ const list = await prices.withPrices(c.subject, await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));++ if (!list.length) return null; // the site keeps a planless creator page noindex++ // The price line reads Billing's published charge; a Billing outage leaves the page's honest fallback.++ let chargeRates = null;++ if (list.some((p) => p.purchasable)) { try { chargeRates = await billing.rates(); } catch { chargeRates = null; } }++ const name = c.display_name || c.username;++ return {++ html: layout.page({++ title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, robots: 'index,follow',++ description: c.bio || `Membership plans from ${name} on OpenVibe.`, viewer: null,++ body: pages.creatorPage({++ creator: creators.present(c), plans: list, viewer: null, member: null, rates: chargeRates,++ providers: config.billing.providers, token: '', query: {}, isOwner: false,++ }),++ }),++ };++ }++++ async function renderPublicPage(path) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (p === '/') return home();++ if (p === '/updates') return updates();++ const m = /^\/([^/]+)$/.exec(p);++ if (!m) return null;++ return creator(decodeURIComponent(m[1]));++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -136,7 +136,22 @@ async function createApp(opts = {}) {},}));const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });-- app.use(createWebRoutes({ domain, config, layout, userAuth, liveNow }));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the++ // public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's member++ // state, join form and anti-forgery token are theirs — so only the manifest and twin requests reach++ // this mount; the pages advertise the twin themselves (web/layout.js and web/routes.js).++ const webRoutes = createWebRoutes({ domain, config, layout, userAuth, liveNow });++ const agentLayer = express();++ require('openvibe-shared/agent-layer').mount(agentLayer, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages({ domain, config, layout, liveNow }).renderPublicPage,++ });++ app.use((req, res, next) => {++ const twin = req.path.endsWith('.md') || (req.query && req.query.format === 'markdown') || String(req.headers.accept || '').includes('text/markdown');++ if (req.path !== '/.well-known/agent.json' && !twin) return next();++ return agentLayer(req, res, next);++ });++ app.use(webRoutes);app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -63,6 +63,12 @@ function createLayout({ config, release }) {notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)};const footer = { service: 'vip', variant: 'auto', path: o.canonicalPath, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)++ const robots = o.robots || 'index,follow';++ // Every indexable page has a Markdown twin (server/agent-pages.js) and names it in the head. A page++ // rendered for a signed-in person, or a planless creator page, is noindex and advertises none.++ const agentHead = o.canonicalPath && !String(robots).includes('noindex')++ ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).head++ : '';const who = o.viewer? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`: `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;@@@ -73,12 +79,13 @@ function createLayout({ config, release }) {name: SITE_NAME, service: 'vip', lang: 'en',title: o.title || `${SITE_NAME} — memberships across OpenVibe`,titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,-- siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',++ siteName: SITE_NAME, description, canonical, robots,type: o.ogType || 'website', image: o.ogImage, jsonLd: o.jsonLd,summary: description, url: canonical,navbar: nav, footer, home: '/', navLinks: NAV_LINKS,head: ['<meta name="referrer" content="strict-origin-when-cross-origin">',++ agentHead,appIcon.headTags({ site: 'vip' }),release ? release.metaTag() : '',`<link rel="stylesheet" href="${asset('css/vip.css')}">`,@@@ -51,7 +51,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {return ratesCache.value;}-- const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));++ const send = (res, status, o) => {++ // A public page names its Markdown twin in the Link header, exactly as the agent layer's own++ // responses do; a page rendered for a signed-in person (noindex) has no twin and no header.++ if (o && o.canonicalPath && !String(o.robots || 'index,follow').includes('noindex')) {++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(layout.abs(o.canonicalPath), `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);++ }++ return res.status(status).type('html').send(layout.page(o));++ };const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;/** A price typed in a form ("4.99", "$5"): cents, or null when left blank; anything else is refused. */function priceInput(v, { required = false } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// What openvibe.vip tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves: the JSON API, the llms.txt/llms-full.txt/sitemap map,
+// and the auth that API accepts (service client-credentials tokens and Network user tokens). No OpenAPI
+// document or documentation page is published, so neither is named.
+module.exports = {
+ id: 'vip',
+ name: 'OpenVibe.VIP',
+ purpose: 'Memberships across the OpenVibe network: creators publish plans and versioned terms, members join through OpenVibe.Billing, and perks and gated resources follow the membership.',
+ home: 'https://openvibe.vip',
+ site: 'openvibe.vip',
+ api: '/api/v1',
+ policy: {
+ auth: ['none', 'Network OIDC', 'service token'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+// The Markdown twins of openvibe.vip' public pages (openvibe-shared/agent-layer), rendered by the same
+// public data readers and page builders the routes use (web/pages.js through web/layout.js). They are
+// signed out, so no member state, join form token or owner view can leak into a twin. Private pages
+// (/me, /dashboard, auth, API) are never rendered here, and a creator page with no published plan is
+// noindex on the site, so it has no twin either.
+const pages = require('./web/pages');
+
+const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'llms.txt', 'llms-full.txt', 'release.json', 'metrics', 'favicon.ico', 'embed']);
+
+function createAgentPages({ domain, config, layout, liveNow }) {
+ const { creators, plans, prices, billing } = domain;
+
+ async function home() {
+ const list = (await creators.listPublic()).map(creators.present);
+ const live = liveNow ? await liveNow.list() : [];
+ return {
+ html: layout.page({
+ title: pages.HOME_TITLE, description: pages.HOME_DESCRIPTION, jsonLd: [pages.homeFaqJsonLd()],
+ active: 'home', canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET], viewer: null,
+ body: pages.home({ creators: list, live, networkUrl: config.network.url }),
+ }),
+ };
+ }
+
+ function updates() {
+ return {
+ html: layout.page({
+ canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', viewer: null,
+ body: require('openvibe-shared/frame').updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${require('openvibe-shared/serve').url('shipped.js')}" defer></script>`,
+ }),
+ };
+ }
+
+ async function creator(username) {
+ if (RESERVED.has(String(username).toLowerCase())) return null;
+ const c = await creators.byUsername(username);
+ if (!c || c.status !== 'active') return null;
+ const list = await prices.withPrices(c.subject, await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));
+ if (!list.length) return null; // the site keeps a planless creator page noindex
+ // The price line reads Billing's published charge; a Billing outage leaves the page's honest fallback.
+ let chargeRates = null;
+ if (list.some((p) => p.purchasable)) { try { chargeRates = await billing.rates(); } catch { chargeRates = null; } }
+ const name = c.display_name || c.username;
+ return {
+ html: layout.page({
+ title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, robots: 'index,follow',
+ description: c.bio || `Membership plans from ${name} on OpenVibe.`, viewer: null,
+ body: pages.creatorPage({
+ creator: creators.present(c), plans: list, viewer: null, member: null, rates: chargeRates,
+ providers: config.billing.providers, token: '', query: {}, isOwner: false,
+ }),
+ }),
+ };
+ }
+
+ async function renderPublicPage(path) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (p === '/') return home();
+ if (p === '/updates') return updates();
+ const m = /^\/([^/]+)$/.exec(p);
+ if (!m) return null;
+ return creator(decodeURIComponent(m[1]));
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -136,7 +136,22 @@ async function createApp(opts = {}) {
@@ -136,7 +136,22 @@ async function createApp(opts = {}) {
},
},
}));
}));
const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });
const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });
- app.use(createWebRoutes({ domain, config, layout, userAuth, liveNow }));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
+ // public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's member
+ // state, join form and anti-forgery token are theirs — so only the manifest and twin requests reach
+ // this mount; the pages advertise the twin themselves (web/layout.js and web/routes.js).
+ const webRoutes = createWebRoutes({ domain, config, layout, userAuth, liveNow });
+ const agentLayer = express();
+ require('openvibe-shared/agent-layer').mount(agentLayer, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages({ domain, config, layout, liveNow }).renderPublicPage,
+ });
+ app.use((req, res, next) => {
+ const twin = req.path.endsWith('.md') || (req.query && req.query.format === 'markdown') || String(req.headers.accept || '').includes('text/markdown');
+ if (req.path !== '/.well-known/agent.json' && !twin) return next();
+ return agentLayer(req, res, next);
+ });
+ app.use(webRoutes);
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -63,6 +63,12 @@ function createLayout({ config, release }) {
@@ -63,6 +63,12 @@ function createLayout({ config, release }) {
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
};
const footer = { service: 'vip', variant: 'auto', path: o.canonicalPath, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)
const footer = { service: 'vip', variant: 'auto', path: o.canonicalPath, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)
+ const robots = o.robots || 'index,follow';
+ // Every indexable page has a Markdown twin (server/agent-pages.js) and names it in the head. A page
+ // rendered for a signed-in person, or a planless creator page, is noindex and advertises none.
+ const agentHead = o.canonicalPath && !String(robots).includes('noindex')
+ ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).head
+ : '';
const who = o.viewer
const who = o.viewer
? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`
? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`
: `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;
: `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;
@@ -73,12 +79,13 @@ function createLayout({ config, release }) {
@@ -73,12 +79,13 @@ function createLayout({ config, release }) {
name: SITE_NAME, service: 'vip', lang: 'en',
name: SITE_NAME, service: 'vip', lang: 'en',
title: o.title || `${SITE_NAME} — memberships across OpenVibe`,
title: o.title || `${SITE_NAME} — memberships across OpenVibe`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
- siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
+ siteName: SITE_NAME, description, canonical, robots,
type: o.ogType || 'website', image: o.ogImage, jsonLd: o.jsonLd,
type: o.ogType || 'website', image: o.ogImage, jsonLd: o.jsonLd,
summary: description, url: canonical,
summary: description, url: canonical,
navbar: nav, footer, home: '/', navLinks: NAV_LINKS,
navbar: nav, footer, home: '/', navLinks: NAV_LINKS,
head: [
head: [
'<meta name="referrer" content="strict-origin-when-cross-origin">',
'<meta name="referrer" content="strict-origin-when-cross-origin">',
+ agentHead,
appIcon.headTags({ site: 'vip' }),
appIcon.headTags({ site: 'vip' }),
release ? release.metaTag() : '',
release ? release.metaTag() : '',
`<link rel="stylesheet" href="${asset('css/vip.css')}">`,
`<link rel="stylesheet" href="${asset('css/vip.css')}">`,
@@ -51,7 +51,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -51,7 +51,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
return ratesCache.value;
return ratesCache.value;
}
}
- const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));
+ const send = (res, status, o) => {
+ // A public page names its Markdown twin in the Link header, exactly as the agent layer's own
+ // responses do; a page rendered for a signed-in person (noindex) has no twin and no header.
+ if (o && o.canonicalPath && !String(o.robots || 'index,follow').includes('noindex')) {
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(layout.abs(o.canonicalPath), `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);
+ }
+ return res.status(status).type('html').send(layout.page(o));
+ };
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
/** A price typed in a form ("4.99", "$5"): cents, or null when left blank; anything else is refused. */
/** A price typed in a form ("4.99", "$5"): cents, or null when left blank; anything else is refused. */
function priceInput(v, { required = false } = {}) {
function priceInput(v, { required = false } = {}) {