openvibers/openvibe.tips Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#119)
Changes
nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// What openvibe.tips 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/sitemap machine 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: 'tips',++ name: 'OpenVibe.Tips',++ purpose: 'Creator support on the OpenVibe network: tips, goals, paid messages, text-to-speech and media requests, sounds and stream overlays, with money moved by OpenVibe.Billing.',++ home: 'https://openvibe.tips',++ site: 'openvibe.tips',++ api: '/api/v1',++ policy: {++ auth: ['none', 'Network OIDC', 'service token'],++ },++};nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// The Markdown twins of openvibe.tips' public pages (openvibe-shared/agent-layer), rendered by the same++// data readers and page builders the routes use (web/pages.js through web/layout.js), signed out so a++// twin can never carry a viewer's tip form or a creator's private preview. Private pages (dashboard,++// receipts, moderation, overlays, auth, API) are never rendered here; a creator page that is switched++// off has no twin.++const crypto = require('crypto');++const { sql } = require('openvibe-sdk/db');++const frame = require('openvibe-shared/frame');++const ovServe = require('openvibe-shared/serve');++const pages = require('./web/pages');++++const HOME_DESCRIPTION = 'Tip the creators you watch: paid messages, text-to-speech, media requests and sounds, with goals and alert overlays for OBS on any streaming app.';++++function createAgentPages({ domain, config, layout, liveNow = { list: async () => [] } }) {++ const { profiles, goals, interactions, sounds, db } = domain;++ const twin = (o) => ({ html: layout.page({ ...o, viewer: null }) });++++ async function home() {++ const [rows, live] = await Promise.all([++ db.many(sql`SELECT creator_subject, handle, display_name, avatar_url, headline FROM creator_tip_profiles WHERE page_enabled ORDER BY updated_at DESC LIMIT 24`),++ liveNow.list(),++ ]);++ const creators = await Promise.all(rows.map(async (p) => {++ const [goal] = await goals.presentMany(db, (await goals.list(db, p.creator_subject, { status: 'active' })).slice(0, 1));++ return { handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url, headline: p.headline, goal: goal ? { title: goal.title, percent: goal.percent } : null };++ }));++ return twin({++ active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION,++ styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }),++ });++ }++++ function updates() {++ return twin({ canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` });++ }++++ async function creator(handle) {++ const p = await profiles.byHandle(db, handle);++ if (!p || !p.page_enabled) return null;++ const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);++ const publicSounds = p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [];++ return twin({++ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,++ description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,++ robots: 'index,follow', ogImage: p.avatar_url || undefined,++ body: pages.creatorPage({++ profile: p, goals: list, viewer: null, csrf: '', idem: `agent-${crypto.randomBytes(12).toString('base64url')}`,++ values: {}, providers: config.billing.providers, error: null, ownerPreview: false, sounds: publicSounds,++ }),++ });++ }++++ async function creatorGoals(handle) {++ const p = await profiles.byHandle(db, handle);++ if (!p || !p.page_enabled) return null;++ return twin({++ title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: 'index,follow',++ body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),++ });++ }++++ async function creatorSupporters(handle) {++ const p = await profiles.byHandle(db, handle);++ if (!p || !p.page_enabled || !p.page.supporters_page) return null;++ const [top, recentRows] = await Promise.all([interactions.leaderboard(db, p), p.page.supporters_messages ? interactions.recentPublic(db, p) : null]);++ const leaderboard = top.map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));++ const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;++ return twin({++ title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: 'index,follow',++ body: pages.supportersPage({ profile: p, leaderboard, recent }),++ });++ }++++ async function renderPublicPage(path) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (p === '/') return home();++ if (p === '/updates') return updates();++ const m = /^\/([^/]+)(\/goals|\/supporters)?$/.exec(p);++ if (!m) return null;++ const handle = profiles.normalizeHandle(m[1]);++ if (!handle) return null;++ if (m[2] === '/goals') return creatorGoals(handle);++ if (m[2] === '/supporters') return creatorSupporters(handle);++ return creator(handle);++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -139,7 +139,22 @@ function createApp(opts = {}) {},}));const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.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 tip form,++ // account bar 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 });@@@ -65,6 +65,11 @@ function createLayout({ config, release }) {notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)};const footer = { service: 'tips', variant: 'auto', path: o.canonicalPath, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)++ // 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 (noindex) never advertises one.++ 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>`;@@@ -79,6 +84,7 @@ function createLayout({ config, release }) {navbar: nav, footer, home: '/', navLinks: NAV_LINKS,head: ['<meta name="referrer" content="strict-origin-when-cross-origin">',++ agentHead,appIcon.headTags({ site: 'tips' }),release ? release.metaTag() : '',`<link rel="stylesheet" href="${asset('css/tips.css')}">`,@@@ -60,7 +60,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: aconst secret = config.formSecret || crypto.randomBytes(32).toString('hex');const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);-- const pageFor = (req, o) => layout.page({ viewer: req.viewer, ...o });++ const pageFor = (req, 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')) {++ req.res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(layout.abs(o.canonicalPath), `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);++ }++ return layout.page({ viewer: req.viewer, ...o });++ };const notFound = (req, res) => html(res, pageFor(req, { title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist, or its creator has not switched it on.' }) }), 404);// Anti-forgery for signed-in forms: HMAC(subject, day), valid today and yesterday.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// What openvibe.tips 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/sitemap machine 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: 'tips',
+ name: 'OpenVibe.Tips',
+ purpose: 'Creator support on the OpenVibe network: tips, goals, paid messages, text-to-speech and media requests, sounds and stream overlays, with money moved by OpenVibe.Billing.',
+ home: 'https://openvibe.tips',
+ site: 'openvibe.tips',
+ api: '/api/v1',
+ policy: {
+ auth: ['none', 'Network OIDC', 'service token'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// The Markdown twins of openvibe.tips' public pages (openvibe-shared/agent-layer), rendered by the same
+// data readers and page builders the routes use (web/pages.js through web/layout.js), signed out so a
+// twin can never carry a viewer's tip form or a creator's private preview. Private pages (dashboard,
+// receipts, moderation, overlays, auth, API) are never rendered here; a creator page that is switched
+// off has no twin.
+const crypto = require('crypto');
+const { sql } = require('openvibe-sdk/db');
+const frame = require('openvibe-shared/frame');
+const ovServe = require('openvibe-shared/serve');
+const pages = require('./web/pages');
+
+const HOME_DESCRIPTION = 'Tip the creators you watch: paid messages, text-to-speech, media requests and sounds, with goals and alert overlays for OBS on any streaming app.';
+
+function createAgentPages({ domain, config, layout, liveNow = { list: async () => [] } }) {
+ const { profiles, goals, interactions, sounds, db } = domain;
+ const twin = (o) => ({ html: layout.page({ ...o, viewer: null }) });
+
+ async function home() {
+ const [rows, live] = await Promise.all([
+ db.many(sql`SELECT creator_subject, handle, display_name, avatar_url, headline FROM creator_tip_profiles WHERE page_enabled ORDER BY updated_at DESC LIMIT 24`),
+ liveNow.list(),
+ ]);
+ const creators = await Promise.all(rows.map(async (p) => {
+ const [goal] = await goals.presentMany(db, (await goals.list(db, p.creator_subject, { status: 'active' })).slice(0, 1));
+ return { handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url, headline: p.headline, goal: goal ? { title: goal.title, percent: goal.percent } : null };
+ }));
+ return twin({
+ active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION,
+ styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }),
+ });
+ }
+
+ function updates() {
+ return twin({ canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` });
+ }
+
+ async function creator(handle) {
+ const p = await profiles.byHandle(db, handle);
+ if (!p || !p.page_enabled) return null;
+ const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
+ const publicSounds = p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [];
+ return twin({
+ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
+ description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
+ robots: 'index,follow', ogImage: p.avatar_url || undefined,
+ body: pages.creatorPage({
+ profile: p, goals: list, viewer: null, csrf: '', idem: `agent-${crypto.randomBytes(12).toString('base64url')}`,
+ values: {}, providers: config.billing.providers, error: null, ownerPreview: false, sounds: publicSounds,
+ }),
+ });
+ }
+
+ async function creatorGoals(handle) {
+ const p = await profiles.byHandle(db, handle);
+ if (!p || !p.page_enabled) return null;
+ return twin({
+ title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: 'index,follow',
+ body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),
+ });
+ }
+
+ async function creatorSupporters(handle) {
+ const p = await profiles.byHandle(db, handle);
+ if (!p || !p.page_enabled || !p.page.supporters_page) return null;
+ const [top, recentRows] = await Promise.all([interactions.leaderboard(db, p), p.page.supporters_messages ? interactions.recentPublic(db, p) : null]);
+ const leaderboard = top.map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));
+ const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
+ return twin({
+ title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: 'index,follow',
+ body: pages.supportersPage({ profile: p, leaderboard, recent }),
+ });
+ }
+
+ async function renderPublicPage(path) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (p === '/') return home();
+ if (p === '/updates') return updates();
+ const m = /^\/([^/]+)(\/goals|\/supporters)?$/.exec(p);
+ if (!m) return null;
+ const handle = profiles.normalizeHandle(m[1]);
+ if (!handle) return null;
+ if (m[2] === '/goals') return creatorGoals(handle);
+ if (m[2] === '/supporters') return creatorSupporters(handle);
+ return creator(handle);
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -139,7 +139,22 @@ function createApp(opts = {}) {
@@ -139,7 +139,22 @@ function createApp(opts = {}) {
},
},
}));
}));
const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.fetchImpl, log });
const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.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 tip form,
+ // account bar 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 });
@@ -65,6 +65,11 @@ function createLayout({ config, release }) {
@@ -65,6 +65,11 @@ 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: 'tips', 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: 'tips', variant: 'auto', path: o.canonicalPath, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)
+ // 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 (noindex) never advertises one.
+ 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>`;
@@ -79,6 +84,7 @@ function createLayout({ config, release }) {
@@ -79,6 +84,7 @@ function createLayout({ config, release }) {
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: 'tips' }),
appIcon.headTags({ site: 'tips' }),
release ? release.metaTag() : '',
release ? release.metaTag() : '',
`<link rel="stylesheet" href="${asset('css/tips.css')}">`,
`<link rel="stylesheet" href="${asset('css/tips.css')}">`,
@@ -60,7 +60,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -60,7 +60,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);
const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);
- const pageFor = (req, o) => layout.page({ viewer: req.viewer, ...o });
+ const pageFor = (req, 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')) {
+ req.res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(layout.abs(o.canonicalPath), `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);
+ }
+ return layout.page({ viewer: req.viewer, ...o });
+ };
const notFound = (req, res) => html(res, pageFor(req, { title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist, or its creator has not switched it on.' }) }), 404);
const notFound = (req, res) => html(res, pageFor(req, { title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist, or its creator has not switched it on.' }) }), 404);
// Anti-forgery for signed-in forms: HMAC(subject, day), valid today and yesterday.
// Anti-forgery for signed-in forms: HMAC(subject, day), valid today and yesterday.