openvibers/openvibe.mail Commits

Add the agent files: llms.txt, llms-full.txt, agent.json and openapi.json (#32)

OP OpenVibers committed 4 days ago · 3da7581f07 · 3 files, +184 −0

* Add llms.txt, llms-full.txt, agent.json and openapi.json

The agent-file sweep found all four answering 404 on openvibe.email.
Add one discovery module: llms.txt names the public page and the API
document, llms-full.txt carries the page text, and agent.json describes
the service it really serves.

openapi.json is openvibe-contracts' mail document with internal-only
operations and the loopback /internal/ routes removed. /inbox and the
provider admin never appear in any of them.

* Agent files say what the home page says (server/http/mail-copy.js)

The first version copied the old home words ("Receive only: nothing here sends mail", "M1 has one public page"),
stale since person mail went live. llms.txt, llms-full.txt and agent.json now take the summary, the sending line and
the sending rule from mail-copy.js, so they follow MAIL_OUTBOUND like the home page.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 90de77e..1f30f7f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..96b4d01
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/test/agent-files.test.js b/test/agent-files.test.js
nnew file mode 100644
iindex 0000000..99b7478
---- /dev/null
++++ b/test/agent-files.test.js
@@@ -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`);
++ }
++ }
++});