openvibers/openvibe.tips Commits

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

OP OpenVibers committed 11 days ago · 0860a8588b · 5 files, +137 −2

* Agent layer: /.well-known/agent.json and Markdown twins of the home, updates and creator pages

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

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

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

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..5e50fc1
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -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'],
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..e069324
---- /dev/null
++++ b/server/agent-pages.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex c100186..9124969 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 89f6bb9..bdde95f 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -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')}">`,
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex c7046ea..20ef1a9 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -60,7 +60,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
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 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.