openvibers/openvibe.mail Commits

openvibe.email's page: a ten-minute address that shows its code, and your addresses (#3)

OP OpenVibers committed 6 days ago · 056187a2ea · 9 files, +420 −13

The page replaces nginx's placeholder (Opus design, M1). Anyone can press 'Get a 10-minute address': the page makes
it (POST /api/v1/burners), shows it with Copy and the time left, keeps it in this browser until it expires, and
reads its code every four seconds (GET /api/v1/burners/:id/code with the burner's own token) until one arrives, then
shows the code large with Copy, the sender and the time. A signed-in person also sees their addresses (masks,
ten-minute addresses, agent inboxes) with state and message counts. It says only what M1 does: agent inboxes work;
masks and your own inbox say Later. No message body is ever shown.

Drawn by openvibe-shared/shell (navbar, footer, theme), with its own strict policy (frame-ancestors none, form-action
self and Network). Its CSS and JS go by content hash (immutable when the hash matches), Shared's files from /shared.
nginx proxies / (GET and HEAD) and /css/, /js/, /shared/; the rest stays as the front door had it.

Changes

ddiff --git a/deploy/nginx/openvibe.email.conf b/deploy/nginx/openvibe.email.conf
iindex df3c20d..e2a7063 100644
---- a/deploy/nginx/openvibe.email.conf
++++ b/deploy/nginx/openvibe.email.conf
@@@ -7,7 +7,7 @@
## The Mail Node service listens on 127.0.0.1:5250 and serves only JSON. This vhost terminates TLS and is the
## public door:
##
--## / a placeholder until the openvibe.email page lands (not proxied)
++## / the page, with /css/, /js/ and /shared/ for its files
## /api/v1/ the API; the app enforces Network auth, CORS and the write rules
## /api/health, /api/ready the machine answers
## /auth/ sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback,
@@@ -81,11 +81,14 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
-- # ── the placeholder until Opus's page lands ─────────────
-- # Deliberately not proxied: only the API and the sign-in routes reach the service.
++ # ── the page (server/http/pages.js) and its files ───────
location = / {
-- default_type text/html;
-- return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>OpenVibe.Mail</title><style>:root{color-scheme:dark light}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0a0f1c;color:#e6edf7;font:16px/1.6 system-ui,sans-serif}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}p{color:#96a7c2}</style></head><body><main><h1>OpenVibe.Mail</h1><p>The mailbox page is on its way. The API is live at <code>/api/v1/</code>.</p></main></body></html>';
++ limit_except GET HEAD { deny all; }
++ proxy_pass http://127.0.0.1:5250;
++ }
++ location ~ ^/(css|js|shared)/ {
++ limit_except GET HEAD { deny all; }
++ proxy_pass http://127.0.0.1:5250;
}
# ── the machine answers ─────────────────────────────────
ddiff --git a/public/css/mail.css b/public/css/mail.css
nnew file mode 100644
iindex 0000000..16aebd9
---- /dev/null
++++ b/public/css/mail.css
@@@ -0,0 +1,56 @@
++/* OpenVibe.Email (Opus, 11-07). The house's dark surfaces from Shared's theme; one accent, the burner as the hero. */
++/* The skip link: off screen until a keyboard reaches it. */
++.skip { position: absolute; left: -9999px; top: 8px; z-index: 1000; padding: 8px 14px; border-radius: 10px; background: var(--accent-strong, #3472d8); color: #fff; font-weight: 700; }
++.skip:focus { left: 12px; }
++.ml-wrap { max-width: 880px; margin: 0 auto; padding: 32px max(16px, env(safe-area-inset-left, 0px)) 56px max(16px, env(safe-area-inset-right, 0px)); display: grid; gap: 28px; }
++.ml-head { display: grid; gap: 10px; }
++.ml-kicker { margin: 0; font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--accent, #3b82f6); }
++.ml-head h1 { margin: 0; font-size: clamp(1.9rem, 5.5vw, 2.8rem); line-height: 1.08; letter-spacing: -.02em; text-wrap: balance; }
++.ml-lead { margin: 0; max-width: 60ch; font-size: 1.05rem; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
++
++/* The ten-minute address: one big panel. */
++.ml-burner { position: relative; display: grid; gap: 16px; padding: 22px; border-radius: 22px; overflow: hidden;
++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, #1f2d47));
++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent), transparent 60%), var(--bg-card, #0f1729); }
++.ml-burner h2 { margin: 0; font-size: 1.15rem; }
++.ml-burner p { margin: 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
++.ml-start { display: grid; gap: 12px; justify-items: start; }
++.ml-start[hidden], .ml-box[hidden], .ml-code[hidden], .ml-wait[hidden], .ml-problem[hidden] { display: none; }
++.ml-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 20px; border: 0; border-radius: 999px;
++ background: var(--accent-strong, #3472d8); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 800; cursor: pointer; }
++.ml-btn:disabled { opacity: .6; cursor: default; }
++.ml-btn-quiet { min-height: 40px; padding: 0 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-primary, #e6edf7); font-weight: 700; }
++.ml-box { display: grid; gap: 14px; }
++.ml-addr-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
++.ml-addr { flex: 1 1 260px; min-width: 0; padding: 14px 16px; border-radius: 14px; background: var(--bg-input, #0d1424); border: 1px solid var(--border, #1f2d47);
++ font: 600 clamp(.95rem, 3.6vw, 1.15rem)/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; color: var(--text-primary, #e6edf7); user-select: all; }
++.ml-left { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.ml-wait { display: flex; align-items: center; gap: 10px; color: var(--text-secondary, #96a7c2); }
++.ml-wait::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }
++.ml-code { display: grid; gap: 8px; padding: 16px; border-radius: 16px; background: color-mix(in srgb, #22c55e 12%, var(--bg-input, #0d1424)); border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); }
++.ml-code-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
++.ml-code-text { font: 800 clamp(1.6rem, 8vw, 2.4rem)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; color: var(--text-primary, #e6edf7); }
++.ml-code small { color: var(--text-secondary, #96a7c2); }
++.ml-problem { color: var(--danger-text, #f26262); font-weight: 600; }
++.ml-fine { font-size: .85rem; color: var(--text-muted, #7386a3); }
++
++/* What else it does: three plain cards, the house's even grid. */
++.ml-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; }
++.ml-card { display: grid; gap: 6px; padding: 18px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.ml-card h3 { margin: 0; font-size: 1rem; }
++.ml-card p { margin: 0; font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.ml-card .ml-soon { justify-self: start; padding: 2px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ml-rules { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.ml-rules li { display: flex; gap: 10px; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
++.ml-rules li::before { content: "✓"; color: #22c55e; font-weight: 800; }
++/* Your addresses (signed in): one row each, the kind, the address, its state and how many messages came. */
++.ml-mine[hidden] { display: none; }
++.ml-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
++.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.ml-row-addr { min-width: 0; font: 600 .95rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
++.ml-row-meta { grid-column: 1; font-size: .82rem; color: var(--text-secondary, #96a7c2); }
++.ml-row-state { grid-column: 2; grid-row: 1 / span 2; justify-self: end; padding: 2px 10px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-secondary, #96a7c2); }
++.ml-row-state.is-on { background: color-mix(in srgb, #22c55e 16%, transparent); color: #22c55e; }
++.ml-h2 { margin: 0 0 10px; font-size: 1.05rem; }
++.ml-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++@media (prefers-reduced-motion: reduce) { .ml-btn { transition: none; } }
ddiff --git a/public/js/mail.js b/public/js/mail.js
nnew file mode 100644
iindex 0000000..b06d881
---- /dev/null
++++ b/public/js/mail.js
@@@ -0,0 +1,134 @@
++/* OpenVibe.Email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's
++ * own addresses. Everything is real: the address is made by POST /api/v1/burners and its code read from
++ * GET /api/v1/burners/:id/code with the burner's own token, kept in this browser until the address expires. Nothing
++ * here sends mail, and no message body is ever shown: only the code, the sender and the time. */
++(function () {
++ 'use strict';
++ var KEY = 'ov_mail_burner';
++ var root = document.querySelector('[data-mail-burner]');
++ if (!root) return;
++ var start = root.querySelector('[data-mail-start]');
++ var box = root.querySelector('[data-mail-box]');
++ var addr = root.querySelector('[data-mail-address]');
++ var copy = root.querySelector('[data-mail-copy]');
++ var left = root.querySelector('[data-mail-left]');
++ var wait = root.querySelector('[data-mail-wait]');
++ var codeBox = root.querySelector('[data-mail-code]');
++ var codeText = root.querySelector('[data-mail-code-text]');
++ var codeFrom = root.querySelector('[data-mail-code-from]');
++ var codeCopy = root.querySelector('[data-mail-code-copy]');
++ var problem = root.querySelector('[data-mail-problem]');
++ var poll = null, tick = null, current = null;
++
++ function save(b) { try { localStorage.setItem(KEY, JSON.stringify(b)); } catch (e) { /* private mode: this tab only */ } }
++ function load() { try { var b = JSON.parse(localStorage.getItem(KEY) || 'null'); return b && Date.parse(b.expires_at) > Date.now() ? b : null; } catch (e) { return null; } }
++ function forget() { try { localStorage.removeItem(KEY); } catch (e) { /* */ } }
++ function say(text) { problem.textContent = text || ''; problem.hidden = !text; }
++ function copyText(btn, text) {
++ if (!navigator.clipboard) return;
++ navigator.clipboard.writeText(text).then(function () {
++ var was = btn.textContent; btn.textContent = 'Copied';
++ setTimeout(function () { btn.textContent = was; }, 1400);
++ }, function () {});
++ }
++ function stop() { clearInterval(poll); clearInterval(tick); poll = tick = null; }
++
++ function show(b) {
++ current = b;
++ start.hidden = true;
++ box.hidden = false;
++ addr.textContent = b.address;
++ codeBox.hidden = true;
++ wait.hidden = false;
++ stop();
++ var paint = function () {
++ var ms = Date.parse(b.expires_at) - Date.now();
++ if (ms <= 0) { expire(); return; }
++ var m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);
++ left.textContent = m + ':' + (s < 10 ? '0' : '') + s + ' left';
++ };
++ paint();
++ tick = setInterval(paint, 1000);
++ var ask = function () {
++ if (document.hidden || !current) return;
++ fetch('/api/v1/burners/' + encodeURIComponent(b.id) + '/code', { headers: { Accept: 'application/json', 'x-mail-burner-token': b.token }, credentials: 'same-origin' })
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
++ .then(function (res) {
++ if (res.ok && res.j && res.j.code) {
++ codeText.textContent = res.j.code;
++ codeFrom.textContent = (res.j.from ? 'From ' + res.j.from : 'Arrived') + (res.j.received_at ? ' at ' + new Date(res.j.received_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');
++ codeBox.hidden = false;
++ wait.hidden = true;
++ } else if (res.status === 404 && res.j && res.j.code !== 'mail.no_code') {
++ expire();
++ }
++ })
++ .catch(function () {});
++ };
++ ask();
++ poll = setInterval(ask, 4000);
++ }
++ function expire() {
++ stop();
++ forget();
++ current = null;
++ box.hidden = true;
++ start.hidden = false;
++ start.querySelector('button').textContent = 'Get another 10-minute address';
++ say('That address has expired, and its mail is deleted.');
++ }
++
++ start.querySelector('button').addEventListener('click', function () {
++ var btn = this;
++ btn.disabled = true;
++ say('');
++ fetch('/api/v1/burners', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: '{}' })
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
++ .then(function (res) {
++ btn.disabled = false;
++ if (!res.ok || !res.j.burner || !res.j.token) {
++ say(res.status === 429 ? 'You have made a lot of addresses from here this hour. Try again later, or sign in.'
++ : res.status === 503 ? 'Every free address is in use right now. Try again in a few minutes.'
++ : (res.j && res.j.detail) || 'No address was made. Try again.');
++ return;
++ }
++ var b = { id: res.j.burner.id, address: res.j.burner.address, expires_at: res.j.burner.expires_at, token: res.j.token };
++ save(b);
++ show(b);
++ })
++ .catch(function () { btn.disabled = false; say('The network did not answer. Try again.'); });
++ });
++ copy.addEventListener('click', function () { if (current) copyText(copy, current.address); });
++ codeCopy.addEventListener('click', function () { copyText(codeCopy, codeText.textContent); });
++ var kept = load();
++ if (kept) show(kept);
++
++ // A signed-in person's own addresses (GET /api/v1/me/mail): the kind, the address, its state and the count of
++ // messages. A visitor gets 401 and the section stays hidden. Nothing here reads a message.
++ var mine = document.querySelector('[data-mail-mine]');
++ if (!mine) return;
++ var list = mine.querySelector('[data-mail-mine-list]');
++ var empty = mine.querySelector('[data-mail-mine-empty]');
++ var KIND = { masks: 'Mask', burners: 'Ten-minute address', agent_inboxes: 'Agent inbox' };
++ var STATE = { active: 'Active', disabled: 'Off', expired: 'Expired', consumed: 'Used' };
++ fetch('/api/v1/me/mail', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (j) {
++ if (!j) return;
++ mine.hidden = false;
++ var rows = [];
++ ['agent_inboxes', 'burners', 'masks'].forEach(function (k) { (j[k] || []).forEach(function (x) { rows.push({ kind: k, x: x }); }); });
++ empty.hidden = rows.length > 0;
++ rows.forEach(function (r) {
++ var li = document.createElement('li'); li.className = 'ml-row';
++ var a = document.createElement('span'); a.className = 'ml-row-addr'; a.textContent = r.x.address || '';
++ var st = document.createElement('span'); st.className = 'ml-row-state' + (r.x.state === 'active' ? ' is-on' : ''); st.textContent = STATE[r.x.state] || r.x.state || '';
++ var meta = document.createElement('span'); meta.className = 'ml-row-meta';
++ var n = r.x.counts && typeof r.x.counts.messages === 'number' ? r.x.counts.messages : 0;
++ meta.textContent = KIND[r.kind] + (r.x.purpose ? ' · ' + r.x.purpose : '') + ' · ' + n + (n === 1 ? ' message' : ' messages');
++ li.appendChild(a); li.appendChild(st); li.appendChild(meta);
++ list.appendChild(li);
++ });
++ })
++ .catch(function () {});
++})();
ddiff --git a/server/app.js b/server/app.js
iindex c1741c1..7a11f95 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -114,6 +114,9 @@ async function createApp(opts = {}) {
app.use('/api/v1', principal.middleware);
app.use(createApi({ db: store.db, config, stalwart, events, principal, log }));
++ // openvibe.email's page, its files and Shared's (server/http/pages.js).
++ require('./http/pages').mountPages(app, { config });
++
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
detail: 'No such API route.',
ctx: req.ov,
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..e7f6b48
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * openvibe.email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's
++ * own addresses. It is drawn by openvibe-shared/shell (the navbar, the footer, the theme), complete without
++ * JavaScript except for the parts that need the API, and it says only what M1 does today: it receives, shows a code,
++ * and never shows a message body.
++ *
++ * GET / the page
++ * GET /css/*, /js/* its own files, by content hash (immutable when the hash matches)
++ * GET /shared/* openvibe-shared's files
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const express = require('express');
++const shell = require('openvibe-shared/shell');
++const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
++
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const SITE_NAME = 'OpenVibe.Email';
++const NETWORK_URL = 'https://openvibe.network';
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++/** The page's own policy: its scripts, Shared's, Network's sign-in, and nothing framed. */
++const CSP = [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",
++ "font-src 'self' data: https://fonts.gstatic.com",
++ "img-src 'self' data: https://openvibe.network https://openvibe.media",
++ "connect-src 'self' https://openvibe.network https://openvibe.chat https://openvibe.events https://cloudflareinsights.com",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'none'",
++ "object-src 'none'",
++ "base-uri 'self'",
++ "form-action 'self' https://openvibe.network",
++].join('; ');
++
++const BODY = fs.readFileSync(path.join(__dirname, '..', 'views', 'home.html'), 'utf8');
++
++function homePage(config) {
++ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
++ const nav = {
++ service: 'mail',
++ apiBase: NETWORK_URL,
++ links: [{ label: 'Home', href: '/', active: true }],
++ history: { type: 'page', title: SITE_NAME },
++ silentLogin: `${base}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ };
++ const footer = { service: 'mail', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: `${SITE_NAME}: an address for every sign-up`,
++ siteName: SITE_NAME,
++ description: 'Ten-minute addresses that show you the code, and inboxes your agents can read. Receive only: nothing here sends mail.',
++ canonical: `${base}/`,
++ robots: 'index, follow',
++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebApplication', name: SITE_NAME, url: `${base}/`, applicationCategory: 'CommunicationApplication', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } }],
++ home: '/',
++ navLinks: nav.links,
++ navbar: nav,
++ footer,
++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
++<link rel="stylesheet" href="${ovServe.url('kit.css')}">
++<link rel="stylesheet" href="${asset('css/mail.css')}">
++<script src="${asset('js/mail.js')}" defer></script>`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${BODY}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
++});
++</script>`,
++ });
++}
++
++/** Mount the page, its files and Shared's. Before the API's 404 handler. */
++function mountPages(app, { config }) {
++ app.use('/shared', ovServe.handler());
++ app.use(['/css', '/js'], (req, res, next) => {
++ const rel = `${req.baseUrl.slice(1)}${req.path}`;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!req.query.v && req.query.v === assetVersion(rel) }));
++ next();
++ });
++ app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));
++ app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));
++ app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));
++ let html = null;
++ app.get('/', (req, res) => {
++ html = html || homePage(config);
++ res.set('Content-Security-Policy', CSP);
++ res.set('Cache-Control', cache.htmlHeaders({ private: false }));
++ res.type('html').send(html);
++ });
++}
++
++module.exports = { mountPages, homePage, CSP, asset };
ddiff --git a/server/views/home.html b/server/views/home.html
nnew file mode 100644
iindex 0000000..7bc1370
---- /dev/null
++++ b/server/views/home.html
@@@ -0,0 +1,50 @@
++<main class="ml-wrap" id="main">
++<header class="ml-head">
++<p class="ml-kicker">OpenVibe.Email</p>
++<h1>An address for every sign-up</h1>
++<p class="ml-lead">Throwaway addresses that show you the code, and inboxes your agents can read. It only receives: nothing here sends mail.</p>
++</header>
++
++<section class="ml-burner" data-mail-burner aria-labelledby="ml-burner-h">
++<h2 id="ml-burner-h">A ten-minute address</h2>
++<div class="ml-start" data-mail-start>
++<p>Use it where a site wants an email before it lets you in. The code it sends appears right here.</p>
++<button class="ml-btn" type="button">Get a 10-minute address</button>
++</div>
++<div class="ml-box" data-mail-box hidden>
++<div class="ml-addr-row"><span class="ml-addr" data-mail-address></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-copy>Copy</button></div>
++<p><span class="ml-left" data-mail-left></span> · paste it into the site's form.</p>
++<p class="ml-wait" data-mail-wait role="status">Waiting for a code…</p>
++<div class="ml-code" data-mail-code hidden role="status">
++<div class="ml-code-row"><span class="ml-code-text" data-mail-code-text></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-code-copy>Copy the code</button></div>
++<small data-mail-code-from></small>
++</div>
++</div>
++<p class="ml-problem" data-mail-problem role="alert" hidden></p>
++<p class="ml-fine">No account needed. The address and its mail are deleted when the ten minutes end. Only the code, the sender and the time are shown; the rest of the message is never displayed.</p>
++</section>
++
++<section class="ml-mine" data-mail-mine aria-labelledby="ml-mine-h" hidden>
++<h2 id="ml-mine-h" class="ml-h2">Your addresses</h2>
++<p class="ml-fine" data-mail-mine-empty hidden>None yet. A ten-minute address made while you are signed in, or an inbox you give an agent, shows here.</p>
++<ul class="ml-list" data-mail-mine-list></ul>
++</section>
++
++<section aria-labelledby="ml-more-h">
++<h2 id="ml-more-h" class="ml-sr">What else it does</h2>
++<div class="ml-grid">
++<article class="ml-card"><h3>Inboxes for your agents</h3><p>Give an agent its own address for sign-ups and codes, so it never needs yours. It reads only what arrives there.</p></article>
++<article class="ml-card"><h3>Masks</h3><span class="ml-soon">Later</span><p>An address per site that forwards to you, so a leak or a spammer never learns your real one. One switch turns it off.</p></article>
++<article class="ml-card"><h3>Your own inbox</h3><span class="ml-soon">Later</span><p>Real mailboxes, sending and your own domain come after this. Today it only receives.</p></article>
++</div>
++</section>
++
++<section aria-labelledby="ml-rules-h">
++<h2 id="ml-rules-h" class="ml-h2">How it treats mail</h2>
++<ul class="ml-rules">
++<li>Receive only: there is no way to send from these addresses.</li>
++<li>A message body is read only to find a code, and it is never stored outside the mailbox or shown here.</li>
++<li>Expired addresses bounce, and their mail is deleted.</li>
++</ul>
++</section>
++</main>
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 48e487e..8471177 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -81,6 +81,7 @@ async function boot(opts = {}) {
}
return {
++ base,
network,
mail,
store,
ddiff --git a/test/nginx.test.js b/test/nginx.test.js
iindex b68cf45..f719150 100644
---- a/test/nginx.test.js
++++ b/test/nginx.test.js
@@@ -3,7 +3,7 @@
/**
* deploy/nginx/openvibe.email.conf is the public door. This test is the static guard: the three server_name lines,
* the TLS files for the name, and a proxy list limited to the API and the Network sign-in routes (plus the two
-- * machine answers). `/` is the placeholder and never reaches the service.
++ * machine answers). `/` is the page, and its files come from /css/, /js/ and /shared/.
*/
const test = require('node:test');
const assert = require('node:assert');
@@@ -16,7 +16,7 @@ const CONF = fs.readFileSync(FILE, 'utf8');
/** location → block body (the file has no nested braces inside a location). */
function locations() {
const out = new Map();
-- for (const match of CONF.matchAll(/location\s+(?:=\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {
++ for (const match of CONF.matchAll(/location\s+(?:(?:=|~\*?)\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {
out.set(match[1], match[2]);
}
return out;
@@@ -31,25 +31,26 @@ test('openvibe.email has the three house server_name lines and the existing cert
assert.match(CONF, /include snippets\/security-txt\.conf;/);
});
--test('only the API, the machine answers and the sign-in routes are proxied', () => {
++test('only the page and its files, the API, the machine answers and the sign-in routes are proxied', () => {
const blocks = locations();
const proxied = [...blocks.entries()]
.filter(([, body]) => /proxy_pass\s+http:\/\/127\.0\.0\.1:5250;/.test(body))
.map(([name]) => name)
.sort();
-- assert.deepStrictEqual(proxied, ['/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me'].sort());
++ assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me'].sort());
// A proxy anywhere else (a generic /api/ or / catch-all) would expose more than the door above.
assert.strictEqual((CONF.match(/proxy_pass\s+/g) || []).length, proxied.length,
-- 'every proxy_pass belongs to one of the five locations');
++ 'every proxy_pass belongs to one of the seven locations');
});
--test('/ is the placeholder and unknown paths are not proxied', () => {
++test('/ is the page (GET and HEAD only) and unknown paths are not proxied', () => {
const blocks = locations();
const root = blocks.get('/');
assert.ok(root, 'there is an exact / location');
-- assert.match(root, /return 200 /);
-- assert.doesNotMatch(root, /proxy_pass/);
++ assert.match(root, /proxy_pass http:\/\/127\.0\.0\.1:5250;/);
++ assert.match(root, /limit_except GET HEAD \{ deny all; \}/);
++ assert.match(blocks.get('^/(css|js|shared)/'), /limit_except GET HEAD \{ deny all; \}/);
assert.match(blocks.get('/api/'), /return 404;/);
assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);
assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);
ddiff --git a/test/page.test.js b/test/page.test.js
nnew file mode 100644
iindex 0000000..9167c88
---- /dev/null
++++ b/test/page.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * openvibe.email's page (Opus, 11-07): / is the page with the ten-minute address, its files are served by content
++ * hash, Shared's files come from /shared, and the page has its own strict policy.
++ * node --test test/page.test.js
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { boot } = require('./helpers/boot');
++const { asset } = require('../server/http/pages');
++
++test('/ is the page: the ten-minute address, the honest cards, the navbar, a strict policy', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const r = await s.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /text\/html/);
++ for (const marker of ['data-mail-burner', 'Get a 10-minute address', 'data-mail-mine', 'Inboxes for your agents', 'navbar-mount', '/shared/']) {
++ assert.ok(r.text.includes(marker), marker);
++ }
++ // Masks and own inboxes are not offered as if they worked today: each card says Later.
++ assert.strictEqual((r.text.match(/class="ml-soon">Later</g) || []).length, 2);
++ assert.doesNotMatch(r.text, /<form[^>]*action="\/api/, 'nothing posts to the API without the script');
++ const csp = r.headers.get('content-security-policy') || '';
++ assert.match(csp, /frame-ancestors 'none'/);
++ assert.match(csp, /default-src 'self'/);
++ assert.match(csp, /form-action 'self' https:\/\/openvibe\.network/);
++});
++
++test('the page\'s files are served by content hash, and Shared\'s from /shared', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const css = await s.get(asset('css/mail.css'));
++ assert.strictEqual(css.status, 200);
++ assert.match(css.headers.get('cache-control') || '', /immutable/);
++ const js = await s.get(asset('js/mail.js'));
++ assert.strictEqual(js.status, 200);
++ assert.match(js.text, /\/api\/v1\/burners/);
++ const stale = await s.get('/js/mail.js?v=0000000000');
++ assert.strictEqual(stale.status, 200);
++ assert.doesNotMatch(stale.headers.get('cache-control') || '', /immutable/, 'a wrong hash is not cached for a year');
++ const navbar = await s.get('/shared/navbar.js');
++ assert.strictEqual(navbar.status, 200);
++ const missing = await s.get('/js/nope.js');
++ assert.strictEqual(missing.status, 404);
++});