openvibers/openvibe.cam Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#3)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openvibe.cam 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 /developers page that documents it,++// and the llms.txt/sitemap machine map. The API authenticates a person's cam_at session or Network user++// token and needs no app or service token; no OpenAPI document is published, so none is named.++module.exports = {++ id: 'cam',++ name: 'OpenVibe.Cam',++ purpose: 'The OpenVibe camera app: on-device filters plus a timer, burst and grid, private cameras with revocable viewer grants, stills from live streams, and the API behind Live\'s cam button.',++ home: 'https://openvibe.cam',++ site: 'openvibe.cam',++ api: '/api/v1',++ docs: '/developers',++ policy: {++ auth: ['none', 'Network OIDC'],++ },++};nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// The Markdown twins of openvibe.cam's public pages (openvibe-shared/agent-layer), rendered by the same++// view builders and page shell the routes use (views/index.js through render/layout.js's renderPage),++// signed out so a twin can never carry a signed-in person's photos or a private wall. Only the pages the++// site itself marks indexable are twinned: the home, the camera app's signed-out shell and /developers.++// /my, /w/:id, /s/:id and every per-person page are never rendered here.++const seo = require('openvibe-shared/seo');++const { renderPage } = require('./render/layout');++const { homeJsonLd } = require('./http/discovery');++const { raw } = require('./render/html');++const views = require('./views');++++function createAgentPages({ config }) {++ const twin = (path, o) => ({ html: renderPage({ ...o, path, config, viewer: { kind: 'anonymous' } }) });++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++++ function home() {++ return twin('/', {++ index: true, title: null,++ description: 'OpenVibe.Cam is the camera app: on-device filters, private cameras with revocable viewer grants, and a still from a Live stream for its chat.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ signedIn: false })),++ });++ }++++ function app() {++ return twin('/app', {++ index: true, title: 'The camera app',++ description: 'Take a photo with on-device colour, light and blur, a timer, a burst and a grid; save it to OpenVibe.Pics.',++ body: raw(views.app({ signedIn: false, photos: [] })),++ });++ }++++ function developers() {++ return twin('/developers', {++ index: true, title: 'The OpenVibe.Cam API',++ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',++ jsonLd: [seo.jsonLd.webPage({++ name: 'The OpenVibe.Cam API', url: `${base()}/developers`,++ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base(),++ })],++ body: raw(views.developers()),++ });++ }++++ async function renderPublicPage(path) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (p === '/') return home();++ if (p === '/app') return app();++ if (p === '/developers') return developers();++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -148,6 +148,20 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CAM_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the++ // indexable public pages (server/agent-pages.js). The pages keep their own routes — a signed-in person's++ // photos, an owner's camera and a private wall are theirs — so only the manifest and twin requests++ // reach this mount; the pages advertise the twin themselves (render/layout.js).++ const agentLayer = express();++ require('openvibe-shared/agent-layer').mount(agentLayer, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages({ config: ctx.config }).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(createPageRoutes(ctx));app.use((req, res) => send(res, 404, {viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',@@@ -47,6 +47,11 @@ function renderPage(o) {const pathNow = o.path || '/';const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ // An indexable page has a Markdown twin (server/agent-pages.js) and names it in the head; a page drawn++ // for a signed-in person, or a private wall/still, is noindex and advertises none.++ const agentHead = o.index++ ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`).head++ : '';const nav = {service: 'cam',apiBase: NETWORK_URL,@@@ -81,6 +86,7 @@ function renderPage(o) {navbar: nav,footer,head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${agentHead}${appIcon.headTags({ site: 'cam' })}<link rel="stylesheet" href="${ovServe.url('kit.css')}"><link rel="stylesheet" href="${ovServe.url('room.css')}">@@@ -112,6 +118,11 @@ 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');++ // An indexable page names its Markdown twin in the Link header, exactly as the agent layer's own responses do.++ if (o.index) {++ const canonical = `${String(o.config.baseUrl).replace(/\/+$/, '')}${String(o.path || '/').split('?')[0]}`;++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);++ }res.status(status).type('html').send(renderPage(o));}
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openvibe.cam 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 /developers page that documents it,
+// and the llms.txt/sitemap machine map. The API authenticates a person's cam_at session or Network user
+// token and needs no app or service token; no OpenAPI document is published, so none is named.
+module.exports = {
+ id: 'cam',
+ name: 'OpenVibe.Cam',
+ purpose: 'The OpenVibe camera app: on-device filters plus a timer, burst and grid, private cameras with revocable viewer grants, stills from live streams, and the API behind Live\'s cam button.',
+ home: 'https://openvibe.cam',
+ site: 'openvibe.cam',
+ api: '/api/v1',
+ docs: '/developers',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// The Markdown twins of openvibe.cam's public pages (openvibe-shared/agent-layer), rendered by the same
+// view builders and page shell the routes use (views/index.js through render/layout.js's renderPage),
+// signed out so a twin can never carry a signed-in person's photos or a private wall. Only the pages the
+// site itself marks indexable are twinned: the home, the camera app's signed-out shell and /developers.
+// /my, /w/:id, /s/:id and every per-person page are never rendered here.
+const seo = require('openvibe-shared/seo');
+const { renderPage } = require('./render/layout');
+const { homeJsonLd } = require('./http/discovery');
+const { raw } = require('./render/html');
+const views = require('./views');
+
+function createAgentPages({ config }) {
+ const twin = (path, o) => ({ html: renderPage({ ...o, path, config, viewer: { kind: 'anonymous' } }) });
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+
+ function home() {
+ return twin('/', {
+ index: true, title: null,
+ description: 'OpenVibe.Cam is the camera app: on-device filters, private cameras with revocable viewer grants, and a still from a Live stream for its chat.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ signedIn: false })),
+ });
+ }
+
+ function app() {
+ return twin('/app', {
+ index: true, title: 'The camera app',
+ description: 'Take a photo with on-device colour, light and blur, a timer, a burst and a grid; save it to OpenVibe.Pics.',
+ body: raw(views.app({ signedIn: false, photos: [] })),
+ });
+ }
+
+ function developers() {
+ return twin('/developers', {
+ index: true, title: 'The OpenVibe.Cam API',
+ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',
+ jsonLd: [seo.jsonLd.webPage({
+ name: 'The OpenVibe.Cam API', url: `${base()}/developers`,
+ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base(),
+ })],
+ body: raw(views.developers()),
+ });
+ }
+
+ async function renderPublicPage(path) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (p === '/') return home();
+ if (p === '/app') return app();
+ if (p === '/developers') return developers();
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -148,6 +148,20 @@ async function createApp(opts = {}) {
@@ -148,6 +148,20 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CAM_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CAM_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
+ // indexable public pages (server/agent-pages.js). The pages keep their own routes — a signed-in person's
+ // photos, an owner's camera and a private wall are theirs — so only the manifest and twin requests
+ // reach this mount; the pages advertise the twin themselves (render/layout.js).
+ const agentLayer = express();
+ require('openvibe-shared/agent-layer').mount(agentLayer, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages({ config: ctx.config }).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(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
@@ -47,6 +47,11 @@ function renderPage(o) {
@@ -47,6 +47,11 @@ function renderPage(o) {
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ // An indexable page has a Markdown twin (server/agent-pages.js) and names it in the head; a page drawn
+ // for a signed-in person, or a private wall/still, is noindex and advertises none.
+ const agentHead = o.index
+ ? require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`).head
+ : '';
const nav = {
const nav = {
service: 'cam',
service: 'cam',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -81,6 +86,7 @@ function renderPage(o) {
@@ -81,6 +86,7 @@ function renderPage(o) {
navbar: nav,
navbar: nav,
footer,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${agentHead}
${appIcon.headTags({ site: 'cam' })}
${appIcon.headTags({ site: 'cam' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
@@ -112,6 +118,11 @@ function send(res, status, o) {
@@ -112,6 +118,11 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
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 })));
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
+ // An indexable page names its Markdown twin in the Link header, exactly as the agent layer's own responses do.
+ if (o.index) {
+ const canonical = `${String(o.config.baseUrl).replace(/\/+$/, '')}${String(o.path || '/').split('?')[0]}`;
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(canonical, `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}