openvibers/openvibe.mail Commits
Add the agent files: llms.txt, llms-full.txt, agent.json and openapi.json (#32)
Changes
@@@ -89,6 +89,10 @@ async function createApp(opts = {}) {// the shared navbar's release-watch and pick up a new release without a hard refresh, as on every other site.require('openvibe-shared/release').createRelease({ service: 'mail', root: ROOT }).mount(app);++ // The machine-readable front door (SEO-2/SEO-3/SEO-33): /.well-known/agent.json, /openapi.json, /llms.txt and++ // /llms-full.txt. Public data only; no signed-in or admin page is named (server/http/discovery.js).++ app.use(require('./http/discovery').createDiscoveryRoutes({ config }));++// Sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback, /auth/me, /auth/logout. The// session lives in the SDK's host-only ov_token cookie on openvibe.email. A request that already carries a// Bearer token is judged by that token alone, so the cookie is not even read for it.nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * The machine-readable front door: /.well-known/agent.json, /openapi.json, /llms.txt and /llms-full.txt.++ *++ * Only public data goes out. The site has one public page (`/`); `/inbox` and `/admin/mail/providers` are one++ * person's or the owner's and never appear here. The OpenAPI document is the one openvibe-contracts publishes for++ * this service, without the operations marked internal-only and without the loopback `/internal/` routes.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const seo = require('openvibe-shared/seo');++++const SITE_NAME = 'OpenVibe.Mail';++const { mailCopy } = require('./mail-copy');++++/** The words the agent files use: the home page's own (server/http/mail-copy.js), so both follow MAIL_OUTBOUND. */++function words(config) {++ const c = mailCopy(config);++ return {++ summary: c.summary,++ details: `OpenVibe.Mail (openvibe.email) is the mail service of the OpenVibe network. Its public page is /, and its JSON API at /api/v1 needs a person's Network token. ${c.sendRule}`,++ page: `An address for every sign-up. ${c.summary} A ten-minute address shows the code a site sends and then deletes itself; it needs no account. `++ + 'Your address: a name at openvibe.email, aliases and your own domain, all into one inbox with trackers off until you say, '++ + `and another mailbox brought in over IMAP, read only. ${c.sendLine} Masks give a different address per site and deliver `++ + 'to your inbox, so a leak never learns your real one. Inboxes let an agent have its own address for sign-ups and codes. '++ + 'Inbox AI is off until you turn it on, and a draft is never sent until you press Send. A message body stays in its '++ + 'mailbox: never in a log, an event or a page.',++ };++}++++const METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options']);++++/** The public API document: contracts' mail document minus internal-only operations and the loopback routes. */++function publicDocument() {++ const doc = contracts.openapi.document('mail');++ if (!doc) return null;++ const paths = {};++ for (const [p, item] of Object.entries(doc.paths || {})) {++ // /internal/... are loopback-only routes; some contracts call them first-party rather than internal,++ // but they are not part of the public API this document describes.++ if (p === '/internal' || p.startsWith('/internal/')) continue;++ const kept = {};++ for (const [method, op] of Object.entries(item || {})) {++ if (!METHODS.has(method)) { kept[method] = op; continue; }++ const visibility = [].concat((op && op['x-openvibe-visibility']) || []);++ if (visibility.length && visibility.every((v) => v === 'internal')) continue;++ kept[method] = op;++ }++ if (Object.keys(kept).some((method) => METHODS.has(method))) paths[p] = kept;++ }++ return { ...doc, paths };++}++++function createDiscoveryRoutes({ config }) {++ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');++ const said = words(config);++ const r = express.Router();++ const CACHE = 'public, max-age=3600';++++ const manifest = {++ network: 'OpenVibe',++ site: 'openvibe.email',++ id: 'mail',++ name: SITE_NAME,++ purpose: said.summary,++ home: base,++ api: `${base}/api/v1`,++ openapi: `${base}/openapi.json`,++ docs: `${base}/openapi.json`,++ llms: `${base}/llms.txt`,++ llmsFull: `${base}/llms-full.txt`,++ policy: {++ robots: 'https://openvibe.network/bot.json',++ auth: ['none', 'Network OIDC'],++ rateLimit: 'The API is rate limited per address; a ten-minute address needs no account.',++ },++ };++++ r.get('/.well-known/agent.json', (_req, res) => res.set('Cache-Control', CACHE).json(manifest));++++ // GET /openapi.json (SEO-3): the service's own API as OpenAPI 3.1.++ r.get('/openapi.json', (_req, res) => {++ const doc = publicDocument();++ if (!doc) return res.status(404).type('text/plain').send('No API document.');++ res.set('Cache-Control', CACHE).set('Access-Control-Allow-Origin', '*').json(doc);++ });++++ // /llms.txt (llmstxt.org): the public page, the API document and the machine-readable files. The markdown mode++ // is off: this site has no Markdown twins.++ r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: said.summary,++ details: said.details,++ sections: [++ { title: 'Start here', links: [++ { title: SITE_NAME, url: `${base}/`, note: 'your address, masks, ten-minute addresses and agent inboxes' },++ { title: 'OpenAPI 3.1 document', url: `${base}/openapi.json`, note: 'every public API route, with its fields' },++ { title: 'Agent descriptor', url: `${base}/.well-known/agent.json` },++ { title: 'Health', url: `${base}/api/health` },++ { title: 'Release metadata', url: `${base}/release.json` },++ ] },++ ],++ optional: [++ { title: 'Full text for language models', url: `${base}/llms-full.txt` },++ ],++ })));++++ // /llms-full.txt (SEO-33): the same, with the public page's text.++ r.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsFull({++ site: SITE_NAME,++ summary: said.summary,++ base,++ maxBytes: 512 * 1024,++ sections: [{ title: 'Pages', pages: [++ { title: SITE_NAME, url: '/', text: said.page },++ { title: 'The API', url: '/openapi.json', text: 'The JSON API is at /api/v1 and needs a person\'s OpenVibe Network token: masks, burners, agent inboxes, their messages and the verification code. The OpenAPI document lists every public route and its fields. Uploads and the local mail server are not HTTP.' },++ ] }],++ })));++++ return r;++}++++module.exports = { createDiscoveryRoutes, publicDocument, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * The machine-readable front door (SEO-2/SEO-3/SEO-33): /.well-known/agent.json, /openapi.json, /llms.txt and++ * /llms-full.txt. They name the one public page and the public API only; /inbox and /admin/mail/providers are a++ * person's or the owner's and must not appear.++ */++const test = require('node:test');++const assert = require('node:assert');++const { boot } = require('./helpers/boot');++++test('the agent files describe only the public page and API', async (t) => {++ const s = await boot();++ t.after(() => s.close());++++ const llms = await s.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.match(llms.headers.get('content-type'), /^text\/plain/);++ assert.ok(llms.text.startsWith('# OpenVibe.Mail\n'), 'llms.txt starts with the site heading');++ assert.ok(llms.text.includes('> Your own address and domain, masks'), 'it says what the site is, in the home page\'s words');++ assert.doesNotMatch(llms.text, /Receive only|nothing here sends mail|M1 has/, 'no stale words from before person mail');++ for (const url of ['https://openvibe.email/', 'https://openvibe.email/openapi.json', 'https://openvibe.email/.well-known/agent.json', 'https://openvibe.email/llms-full.txt']) {++ assert.ok(llms.text.includes(url), `llms.txt links ${url}`);++ }++ for (const privatePath of ['/inbox', '/admin/']) assert.ok(!llms.text.includes(privatePath), `llms.txt does not name ${privatePath}`);++++ const full = await s.get('/llms-full.txt');++ assert.strictEqual(full.status, 200);++ assert.match(full.headers.get('content-type'), /^text\/plain/);++ assert.ok(full.text.startsWith('# OpenVibe.Mail\n'), 'llms-full.txt opens with the site heading');++ assert.ok(full.text.length > 500, `llms-full.txt carries the page (${full.text.length} characters)`);++ assert.ok(full.text.includes('## Pages'));++ assert.ok(full.text.includes('An address for every sign-up'), 'the home page text');++ assert.ok(full.text.includes('URL: https://openvibe.email/'));++ assert.ok(full.text.includes('URL: https://openvibe.email/openapi.json'));++++ const agent = await s.get('/.well-known/agent.json');++ assert.strictEqual(agent.status, 200);++ assert.match(agent.headers.get('content-type'), /^application\/json/);++ assert.strictEqual(agent.body.id, 'mail');++ assert.strictEqual(agent.body.home, 'https://openvibe.email');++ assert.strictEqual(agent.body.openapi, 'https://openvibe.email/openapi.json');++ assert.strictEqual(agent.body.llmsFull, 'https://openvibe.email/llms-full.txt');++ assert.ok(!JSON.stringify(agent.body).includes('/inbox'), 'the manifest names no private page');++++ const doc = await s.get('/openapi.json');++ assert.strictEqual(doc.status, 200);++ assert.match(doc.headers.get('content-type'), /^application\/json/);++ assert.strictEqual(doc.body.openapi, '3.1.0');++ assert.ok(doc.body.paths['/api/v1/masks'], 'the public mask routes are there');++ for (const [path, operations] of Object.entries(doc.body.paths)) {++ assert.ok(!path.startsWith('/internal/'), `${path} is not a loopback route`);++ for (const [method, operation] of Object.entries(operations)) {++ const visibility = [].concat(operation['x-openvibe-visibility'] || []);++ assert.ok(!(visibility.length && visibility.every((v) => v === 'internal')), `${method} ${path} is not internal-only`);++ }++ }++});
@@ -89,6 +89,10 @@ async function createApp(opts = {}) {
@@ -89,6 +89,10 @@ async function createApp(opts = {}) {
// the shared navbar's release-watch and pick up a new release without a hard refresh, as on every other site.
// the shared navbar's release-watch and pick up a new release without a hard refresh, as on every other site.
require('openvibe-shared/release').createRelease({ service: 'mail', root: ROOT }).mount(app);
require('openvibe-shared/release').createRelease({ service: 'mail', root: ROOT }).mount(app);
+ // The machine-readable front door (SEO-2/SEO-3/SEO-33): /.well-known/agent.json, /openapi.json, /llms.txt and
+ // /llms-full.txt. Public data only; no signed-in or admin page is named (server/http/discovery.js).
+ app.use(require('./http/discovery').createDiscoveryRoutes({ config }));
+
// Sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback, /auth/me, /auth/logout. The
// Sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback, /auth/me, /auth/logout. The
// session lives in the SDK's host-only ov_token cookie on openvibe.email. A request that already carries a
// session lives in the SDK's host-only ov_token cookie on openvibe.email. A request that already carries a
// Bearer token is judged by that token alone, so the cookie is not even read for it.
// Bearer token is judged by that token alone, so the cookie is not even read for it.
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * The machine-readable front door: /.well-known/agent.json, /openapi.json, /llms.txt and /llms-full.txt.
+ *
+ * Only public data goes out. The site has one public page (`/`); `/inbox` and `/admin/mail/providers` are one
+ * person's or the owner's and never appear here. The OpenAPI document is the one openvibe-contracts publishes for
+ * this service, without the operations marked internal-only and without the loopback `/internal/` routes.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const seo = require('openvibe-shared/seo');
+
+const SITE_NAME = 'OpenVibe.Mail';
+const { mailCopy } = require('./mail-copy');
+
+/** The words the agent files use: the home page's own (server/http/mail-copy.js), so both follow MAIL_OUTBOUND. */
+function words(config) {
+ const c = mailCopy(config);
+ return {
+ summary: c.summary,
+ details: `OpenVibe.Mail (openvibe.email) is the mail service of the OpenVibe network. Its public page is /, and its JSON API at /api/v1 needs a person's Network token. ${c.sendRule}`,
+ page: `An address for every sign-up. ${c.summary} A ten-minute address shows the code a site sends and then deletes itself; it needs no account. `
+ + 'Your address: a name at openvibe.email, aliases and your own domain, all into one inbox with trackers off until you say, '
+ + `and another mailbox brought in over IMAP, read only. ${c.sendLine} Masks give a different address per site and deliver `
+ + 'to your inbox, so a leak never learns your real one. Inboxes let an agent have its own address for sign-ups and codes. '
+ + 'Inbox AI is off until you turn it on, and a draft is never sent until you press Send. A message body stays in its '
+ + 'mailbox: never in a log, an event or a page.',
+ };
+}
+
+const METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options']);
+
+/** The public API document: contracts' mail document minus internal-only operations and the loopback routes. */
+function publicDocument() {
+ const doc = contracts.openapi.document('mail');
+ if (!doc) return null;
+ const paths = {};
+ for (const [p, item] of Object.entries(doc.paths || {})) {
+ // /internal/... are loopback-only routes; some contracts call them first-party rather than internal,
+ // but they are not part of the public API this document describes.
+ if (p === '/internal' || p.startsWith('/internal/')) continue;
+ const kept = {};
+ for (const [method, op] of Object.entries(item || {})) {
+ if (!METHODS.has(method)) { kept[method] = op; continue; }
+ const visibility = [].concat((op && op['x-openvibe-visibility']) || []);
+ if (visibility.length && visibility.every((v) => v === 'internal')) continue;
+ kept[method] = op;
+ }
+ if (Object.keys(kept).some((method) => METHODS.has(method))) paths[p] = kept;
+ }
+ return { ...doc, paths };
+}
+
+function createDiscoveryRoutes({ config }) {
+ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
+ const said = words(config);
+ const r = express.Router();
+ const CACHE = 'public, max-age=3600';
+
+ const manifest = {
+ network: 'OpenVibe',
+ site: 'openvibe.email',
+ id: 'mail',
+ name: SITE_NAME,
+ purpose: said.summary,
+ home: base,
+ api: `${base}/api/v1`,
+ openapi: `${base}/openapi.json`,
+ docs: `${base}/openapi.json`,
+ llms: `${base}/llms.txt`,
+ llmsFull: `${base}/llms-full.txt`,
+ policy: {
+ robots: 'https://openvibe.network/bot.json',
+ auth: ['none', 'Network OIDC'],
+ rateLimit: 'The API is rate limited per address; a ten-minute address needs no account.',
+ },
+ };
+
+ r.get('/.well-known/agent.json', (_req, res) => res.set('Cache-Control', CACHE).json(manifest));
+
+ // GET /openapi.json (SEO-3): the service's own API as OpenAPI 3.1.
+ r.get('/openapi.json', (_req, res) => {
+ const doc = publicDocument();
+ if (!doc) return res.status(404).type('text/plain').send('No API document.');
+ res.set('Cache-Control', CACHE).set('Access-Control-Allow-Origin', '*').json(doc);
+ });
+
+ // /llms.txt (llmstxt.org): the public page, the API document and the machine-readable files. The markdown mode
+ // is off: this site has no Markdown twins.
+ r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: said.summary,
+ details: said.details,
+ sections: [
+ { title: 'Start here', links: [
+ { title: SITE_NAME, url: `${base}/`, note: 'your address, masks, ten-minute addresses and agent inboxes' },
+ { title: 'OpenAPI 3.1 document', url: `${base}/openapi.json`, note: 'every public API route, with its fields' },
+ { title: 'Agent descriptor', url: `${base}/.well-known/agent.json` },
+ { title: 'Health', url: `${base}/api/health` },
+ { title: 'Release metadata', url: `${base}/release.json` },
+ ] },
+ ],
+ optional: [
+ { title: 'Full text for language models', url: `${base}/llms-full.txt` },
+ ],
+ })));
+
+ // /llms-full.txt (SEO-33): the same, with the public page's text.
+ r.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: said.summary,
+ base,
+ maxBytes: 512 * 1024,
+ sections: [{ title: 'Pages', pages: [
+ { title: SITE_NAME, url: '/', text: said.page },
+ { title: 'The API', url: '/openapi.json', text: 'The JSON API is at /api/v1 and needs a person\'s OpenVibe Network token: masks, burners, agent inboxes, their messages and the verification code. The OpenAPI document lists every public route and its fields. Uploads and the local mail server are not HTTP.' },
+ ] }],
+ })));
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, publicDocument, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * The machine-readable front door (SEO-2/SEO-3/SEO-33): /.well-known/agent.json, /openapi.json, /llms.txt and
+ * /llms-full.txt. They name the one public page and the public API only; /inbox and /admin/mail/providers are a
+ * person's or the owner's and must not appear.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const { boot } = require('./helpers/boot');
+
+test('the agent files describe only the public page and API', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+
+ const llms = await s.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.match(llms.headers.get('content-type'), /^text\/plain/);
+ assert.ok(llms.text.startsWith('# OpenVibe.Mail\n'), 'llms.txt starts with the site heading');
+ assert.ok(llms.text.includes('> Your own address and domain, masks'), 'it says what the site is, in the home page\'s words');
+ assert.doesNotMatch(llms.text, /Receive only|nothing here sends mail|M1 has/, 'no stale words from before person mail');
+ for (const url of ['https://openvibe.email/', 'https://openvibe.email/openapi.json', 'https://openvibe.email/.well-known/agent.json', 'https://openvibe.email/llms-full.txt']) {
+ assert.ok(llms.text.includes(url), `llms.txt links ${url}`);
+ }
+ for (const privatePath of ['/inbox', '/admin/']) assert.ok(!llms.text.includes(privatePath), `llms.txt does not name ${privatePath}`);
+
+ const full = await s.get('/llms-full.txt');
+ assert.strictEqual(full.status, 200);
+ assert.match(full.headers.get('content-type'), /^text\/plain/);
+ assert.ok(full.text.startsWith('# OpenVibe.Mail\n'), 'llms-full.txt opens with the site heading');
+ assert.ok(full.text.length > 500, `llms-full.txt carries the page (${full.text.length} characters)`);
+ assert.ok(full.text.includes('## Pages'));
+ assert.ok(full.text.includes('An address for every sign-up'), 'the home page text');
+ assert.ok(full.text.includes('URL: https://openvibe.email/'));
+ assert.ok(full.text.includes('URL: https://openvibe.email/openapi.json'));
+
+ const agent = await s.get('/.well-known/agent.json');
+ assert.strictEqual(agent.status, 200);
+ assert.match(agent.headers.get('content-type'), /^application\/json/);
+ assert.strictEqual(agent.body.id, 'mail');
+ assert.strictEqual(agent.body.home, 'https://openvibe.email');
+ assert.strictEqual(agent.body.openapi, 'https://openvibe.email/openapi.json');
+ assert.strictEqual(agent.body.llmsFull, 'https://openvibe.email/llms-full.txt');
+ assert.ok(!JSON.stringify(agent.body).includes('/inbox'), 'the manifest names no private page');
+
+ const doc = await s.get('/openapi.json');
+ assert.strictEqual(doc.status, 200);
+ assert.match(doc.headers.get('content-type'), /^application\/json/);
+ assert.strictEqual(doc.body.openapi, '3.1.0');
+ assert.ok(doc.body.paths['/api/v1/masks'], 'the public mask routes are there');
+ for (const [path, operations] of Object.entries(doc.body.paths)) {
+ assert.ok(!path.startsWith('/internal/'), `${path} is not a loopback route`);
+ for (const [method, operation] of Object.entries(operations)) {
+ const visibility = [].concat(operation['x-openvibe-visibility'] || []);
+ assert.ok(!(visibility.length && visibility.every((v) => v === 'internal')), `${method} ${path} is not internal-only`);
+ }
+ }
+});