openvibers/openvibe.network Commits

Receipts on the account home, under Needs you (#681)

OP OpenVibers committed 5 days ago · c5a3faa9b5 · 13 files, +432 −3

* Receipts on the account home, under Needs you

The My home shows the latest five receipt-category summaries Inbox AI
keeps on openvibe.email, each linking to its thread, with a counted
More (N) fold for the rest. Network reads them server-side with the
person's own token through the new GET /api/v1/me/receipts, as My
already reads Live and Billing, so Mail needs no CORS change.

With Inbox AI off, or no mailbox, one quiet line links to the switch
on openvibe.email; with it on and nothing triaged yet the card says
so. No sample receipt is drawn, and Mail being away hides the card.

Tests cover both states, the thread links and the fold, plus the real
mounted route and the token it forwards. Screenshots at 390 and 1280
in both states have no sideways scroll.

* Tests: register the Receipts module and its Mail call site

my-modules now expects receipts.js in the module map, boot starts it,
and the SSRF ratchet records the one new outbound call (Mail's internal
URL from config, the person's token, a fixed path).

Changes

ddiff --git a/.env.example b/.env.example
iindex 292a03bd..f12d4701 100644
---- a/.env.example
++++ b/.env.example
@@@ -91,6 +91,8 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
++# OpenVibe.Mail: the account home's Receipts card reads the person's own receipt summaries here.
++OV_MAIL_INTERNAL_URL=http://127.0.0.1:5250
# The publication reviewer queue (/admin/review): each product's staff review-queue endpoint.
OV_WIKI_INTERNAL_URL=http://127.0.0.1:4800
OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810
ddiff --git a/public/css/my.css b/public/css/my.css
iindex 6798ef6d..322849b9 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -266,6 +266,30 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
/* A phone: the text takes the row and the button goes under it, lined up with the text (it covered the title). */
@media (max-width: 600px) { .ny { padding: 16px; } .ny-item { align-items: flex-start; flex-wrap: wrap; } .ny-body { flex: 1 1 calc(100% - 46px); } .ny-btn { margin-left: 46px; min-height: 44px; } }
++/* Receipts (Seven Domains R2 item 13): the latest five under Needs you, each merchant, summary and date, with a
++ counted fold for the rest. Quiet by design: one card, no tabs, and with Inbox AI off one line links to the switch. */
++.rc { display: grid; gap: 8px; margin: 0 0 20px; padding: 16px 18px; min-width: 0; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.rc[hidden] { display: none !important; }
++.rc-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
++.rc-h h2 { display: flex; align-items: center; gap: 9px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
++.rc-ic { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); font-size: 13px; }
++.rc-body { display: grid; gap: 2px; min-width: 0; }
++.rc-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "mer date" "sum sum"; gap: 1px 14px; min-width: 0; padding: 10px 8px; border-radius: 10px; color: var(--text); text-decoration: none; }
++.rc-row + .rc-row { border-top: 1px solid var(--border); }
++.rc-row:hover, .rc-row:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); outline: none; }
++.rc-row:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent); }
++.rc-mer { grid-area: mer; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 750; }
++.rc-sum { grid-area: sum; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; }
++.rc-date { grid-area: date; white-space: nowrap; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
++.rc-rest { display: grid; gap: 2px; min-width: 0; }
++.rc-rest[hidden] { display: none !important; }
++.rc-more { justify-self: start; min-height: 40px; margin-top: 6px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
++.rc-more:hover, .rc-more:focus-visible { border-color: var(--accent); color: var(--accent-light); }
++.rc-quiet { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; color: var(--accent-light); text-decoration: none; }
++a.rc-quiet:hover, a.rc-quiet:focus-visible { text-decoration: underline; }
++p.rc-quiet { margin: 0; color: var(--text-muted); }
++@media (max-width: 600px) { .rc { padding: 14px; } .rc-more { min-height: 44px; } }
++
.td { display: grid; gap: 22px; margin: -8px 0 36px; }
.td-sec { display: grid; gap: 10px; min-width: 0; }
/* A block with nothing in it stays out (11-07: the empty "Live now" heading showed, and read as if you were live). */
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
iindex 007c4774..f055ed7f 100644
---- a/public/js/my/boot.js
++++ b/public/js/my/boot.js
@@@ -17,6 +17,7 @@ const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
if (_signedIn) {
loadToday();
++ loadReceipts();
openInitialSection();
refreshApprovalsBadge();
}
ddiff --git a/public/js/my/receipts.js b/public/js/my/receipts.js
nnew file mode 100644
iindex 00000000..6aa1f5a9
---- /dev/null
++++ b/public/js/my/receipts.js
@@@ -0,0 +1,108 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): the Receipts card (plan OpenVibe_Seven_Domains_Plan R2 item 13).
++ The latest five receipt-category summaries Inbox AI keeps on openvibe.email, each a link to its thread,
++ then a counted fold for the rest (never a tab). With Inbox AI off, or no mailbox, one quiet line links to
++ the switch on openvibe.email; with it on and nothing triaged yet, a quiet "No receipts yet." No sample
++ receipt is ever drawn. Classic script, global scope: my/core.js's apiFetch and my/boot.js starts it.
++ ═══════════════════════════════════════════════════════════════ */
++const RC_MAIL = 'https://openvibe.email';
++const RC_SETTING = `${RC_MAIL}/inbox#inbox-ai`;
++const RC_ID = /^[A-Za-z0-9_-]{1,128}$/;
++const RC_SHOWN = 5;
++
++function rcEl(tag, cls, kids, attrs) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
++ (kids || []).forEach((c) => { if (c != null) n.append(c); });
++ return n;
++}
++
++function rcText(value, max) {
++ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
++ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
++}
++
++/** The date a receipt arrived, as "Nov 8", or "" when it has none. */
++function rcDate(at) {
++ if (!at) return '';
++ let t = Date.parse(at);
++ if (!Number.isFinite(t)) t = Date.parse(`${String(at).replace(' ', 'T')}Z`);
++ if (!Number.isFinite(t)) return '';
++ const d = new Date(t);
++ const opts = { month: 'short', day: 'numeric' };
++ if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
++ return d.toLocaleDateString(undefined, opts);
++}
++
++/** The thread on openvibe.email. Mail's inbox opens a message by its message id; thread_id is the fallback. */
++function rcHref(item) {
++ const id = String((item && (item.message_id || item.thread_id)) || '');
++ return RC_ID.test(id) ? `${RC_MAIL}/inbox/${encodeURIComponent(id)}` : null;
++}
++
++function rcRow(item) {
++ const merchant = rcText(item && item.merchant, 160);
++ const summary = rcText(item && item.summary, 240) || rcText(item && item.subject, 200);
++ if (!merchant && !summary) return null;
++ const href = rcHref(item);
++ const row = rcEl(href ? 'a' : 'div', 'rc-row', [
++ rcEl('span', 'rc-mer', [merchant || 'Receipt']),
++ rcEl('span', 'rc-sum', [summary || 'Receipt']),
++ rcEl('span', 'rc-date', [rcDate(item && item.received_at)]),
++ ], href ? { href } : {});
++ if (href) {
++ row.setAttribute('target', '_blank');
++ row.setAttribute('rel', 'noopener');
++ }
++ row.setAttribute('title', summary ? `${merchant || 'Receipt'}: ${summary}` : (merchant || 'Receipt'));
++ return row;
++}
++
++function renderReceipts(doc) {
++ const box = document.getElementById('receipts');
++ const body = document.getElementById('receipts-body');
++ if (!box || !body) return;
++ const enabled = !!(doc && doc.enabled === true);
++ const rows = (doc && Array.isArray(doc.receipts) ? doc.receipts : []).map(rcRow).filter(Boolean);
++ body.replaceChildren();
++ if (!enabled) {
++ body.append(rcEl('a', 'rc-quiet', ['Turn on Inbox AI on openvibe.email to see receipts here'], { href: RC_SETTING }));
++ box.hidden = false;
++ return;
++ }
++ if (!rows.length) {
++ body.append(rcEl('p', 'rc-quiet', ['No receipts yet.']));
++ box.hidden = false;
++ return;
++ }
++ body.append(...rows.slice(0, RC_SHOWN));
++ const rest = rows.slice(RC_SHOWN);
++ if (rest.length) {
++ const hidden = rcEl('div', 'rc-rest', rest, { role: 'group' });
++ hidden.id = 'receipts-rest';
++ hidden.hidden = true;
++ const more = rcEl('button', 'rc-more', [`More (${rest.length})`], {
++ type: 'button', 'aria-expanded': 'false', 'aria-controls': 'receipts-rest',
++ });
++ more.addEventListener('click', () => {
++ const opening = hidden.hidden;
++ hidden.hidden = !opening;
++ more.textContent = opening ? 'Show less' : `More (${rest.length})`;
++ more.setAttribute('aria-expanded', String(opening));
++ });
++ body.append(hidden, more);
++ }
++ box.hidden = false;
++}
++
++async function loadReceipts() {
++ if (!document.getElementById('receipts')) return;
++ if (typeof isAnonSession === 'function' && isAnonSession()) return;
++ try {
++ renderReceipts(await apiFetch('/api/v1/me/receipts'));
++ } catch {
++ const box = document.getElementById('receipts');
++ if (box) box.hidden = true; // Mail away: the card stays out rather than inventing a receipt
++ }
++}
ddiff --git a/public/my.html b/public/my.html
iindex 9cfa0a67..57819d91 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -66,6 +66,17 @@
<div class="ny-list" id="needs-you-list"></div>
</section>
++ <!-- Receipts (plan OpenVibe_Seven_Domains_Plan R2 item 13): the latest five receipt-category summaries Inbox AI
++ keeps on openvibe.email, each a link to its thread, with a counted fold for the rest and never a tab.
++ my/receipts.js fills it from GET /api/v1/me/receipts; with Inbox AI off (or no mailbox) the one quiet line
++ links to the switch, and no sample receipt is ever drawn. The card stays out until it has an answer. -->
++ <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">
++ <div class="rc-h">
++ <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>
++ </div>
++ <div class="rc-body" id="receipts-body"></div>
++ </section>
++
<!-- You (11-07 redesign; the owner: "front-face the best stuff we have to offer … awesome design"): who you are in
your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
@@@ -611,6 +622,7 @@
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/apps.js" defer></script>
++<script src="/js/my/receipts.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
ddiff --git a/server/config.js b/server/config.js
iindex 089a5ded..142ee98c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -160,6 +160,11 @@ module.exports = {
billing: {
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
},
++ // OpenVibe.Mail: the account home's Receipts card (server/today/receipts.js) reads the person's own receipt
++ // summaries with their token. Mail is the authority on the mailbox and the Inbox AI opt-in.
++ mail: {
++ internalUrl: process.env.OV_MAIL_INTERNAL_URL || 'http://127.0.0.1:5250',
++ },
// OpenVibe.Bot: the registry of the machines. The hub (server/registry/machines.js) calls it as the
// person, so Bot stays the authority on what a person may do with their own machines.
bot: {
ddiff --git a/server/index.js b/server/index.js
iindex b84ee29c..f8084c48 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -644,6 +644,9 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
// Your account home's "Now" (L43): the people you follow who are live, from Live's public list (held 10 s).
app.use('/api/v1/me/live-follows', rateLimit({ windowMs: 60_000, max: 60 }), require('./today/live-follows').router({ requireAuth, liveUrl: config.services.live.internalUrl }));
++ // Your account home's Receipts card (Seven Domains R2 item 13): Mail's receipt summaries, read with the person's
++ // own token. Mail is the authority on the mailbox and on the Inbox AI opt-in.
++ app.use('/api/v1/me/receipts', rateLimit({ windowMs: 60_000, max: 60 }), require('./today/receipts').router({ requireAuth, mailUrl: config.services.mail.internalUrl }));
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
ddiff --git a/server/today/receipts.js b/server/today/receipts.js
nnew file mode 100644
iindex 00000000..b8345279
---- /dev/null
++++ b/server/today/receipts.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * Receipts on the account home (/my, plan OpenVibe_Seven_Domains_Plan R2 item 13): the receipt-category summaries
++ * Inbox AI keeps for the person's mailbox, as the "Receipts" card under Needs you.
++ *
++ * GET /api/v1/me/receipts → { enabled, receipts: [{ thread_id, message_id, merchant, subject, summary,
++ * received_at }] }
++ *
++ * Mail owns the mailbox, the opt-in and the rows: this route only hands the person's own token to Mail's
++ * GET /api/v1/me/mail/receipts, the way My reads Billing and the other services the person already uses. With Inbox
++ * AI off (or no mailbox) Mail answers `enabled: false` and no receipts, and the card asks to turn it on. If Mail is
++ * away the route answers 502 and the card stays out; it never invents a receipt.
++ */
++const express = require('express');
++
++const TIMEOUT_MS = 2_500;
++const MAX = 200;
++const ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
++
++const text = (value, max) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, max);
++
++/** One receipt Mail may hand over, or null. Everything is bounded: this crosses two services and a page. */
++function receiptOf(row) {
++ if (!row || typeof row !== 'object') return null;
++ const threadId = text(row.thread_id, 128);
++ if (!ID_RE.test(threadId)) return null;
++ const messageId = text(row.message_id, 128);
++ return {
++ thread_id: threadId,
++ message_id: ID_RE.test(messageId) ? messageId : null,
++ merchant: text(row.merchant, 160) || 'Receipt',
++ subject: text(row.subject, 200),
++ summary: text(row.summary, 240),
++ received_at: text(row.received_at, 40) || null,
++ };
++}
++
++function router({ requireAuth, mailUrl, fetchImpl = fetch, timeoutMs = TIMEOUT_MS } = {}) {
++ const r = express.Router();
++ r.get('/', requireAuth, async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ if (!req.user || req.user.is_anon) return res.json({ enabled: false, receipts: [] });
++ const token = req.token || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
++ if (!mailUrl || !token) return res.status(502).json({ error: 'mail.unavailable' });
++ try {
++ const answer = await fetchImpl(`${String(mailUrl).replace(/\/+$/, '')}/api/v1/me/mail/receipts`, {
++ headers: { accept: 'application/json', authorization: `Bearer ${token}` },
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ const body = answer.ok ? await answer.json().catch(() => null) : null;
++ if (!body) return res.status(502).json({ error: 'mail.unavailable' });
++ const rows = Array.isArray(body.receipts) ? body.receipts : [];
++ // An older Mail has no `enabled` yet: its rows can still be shown, but an empty answer is not a setting.
++ const enabled = typeof body.enabled === 'boolean' ? body.enabled : rows.length > 0;
++ const receipts = enabled ? rows.map(receiptOf).filter(Boolean).slice(0, MAX) : [];
++ return res.json({ enabled, receipts });
++ } catch {
++ return res.status(502).json({ error: 'mail.unavailable' });
++ }
++ });
++ return r;
++}
++
++module.exports = { router, receiptOf };
ddiff --git a/test/helpers/boot-server.js b/test/helpers/boot-server.js
iindex b5afe7a8..f0d1f499 100644
---- a/test/helpers/boot-server.js
++++ b/test/helpers/boot-server.js
@@@ -36,7 +36,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9', OV_CHAT_INTERNAL_URL: 'http://127.0.0.1:9',
-- OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
++ OV_AI_INTERNAL_URL: 'http://127.0.0.1:9', OV_MAIL_INTERNAL_URL: 'http://127.0.0.1:9',
};
if (child) {
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex a56a9f19..b4f7a02a 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
@@@ -41,7 +41,8 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');
assert.ok(bootSrc.includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
// Signed out (loadUser() redirects to /login), no section loads and no badge is asked for: each would answer 401.
--assert.match(bootSrc, /if \(_signedIn\) \{\s*loadToday\(\);\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API (You, today included)');
++assert.match(bootSrc, /if \(_signedIn\) \{\s*loadToday\(\);\s*loadReceipts\(\);\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API (You, today and Receipts included)');
++assert.strictEqual(owner.get('loadReceipts'), 'receipts.js', 'the Receipts card lives in receipts.js');
// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
{
const vm = require('vm');
ddiff --git a/test/my-receipts-route.test.js b/test/my-receipts-route.test.js
nnew file mode 100644
iindex 00000000..896bcfdc
---- /dev/null
++++ b/test/my-receipts-route.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// GET /api/v1/me/receipts, mounted in the real app: the person's own token reaches Mail, Mail's answer is passed on,
++// and Inbox AI off stays empty. The card's rendering is in test/my-receipts.test.js.
++// node --import ./test/helpers/pg-preload.mjs test/my-receipts-route.test.js
++const assert = require('assert');
++const http = require('http');
++const { bootServer } = require('./helpers/boot-server');
++const { signToken } = require('../server/auth/routes');
++
++(async () => {
++ const asked = [];
++ let mode = 'on';
++ const mail = http.createServer((req, res) => {
++ asked.push({ url: req.url, auth: req.headers.authorization });
++ res.setHeader('content-type', 'application/json');
++ res.setHeader('cache-control', 'no-store');
++ if (mode === 'down') { res.statusCode = 503; return res.end('{}'); }
++ if (mode === 'off') return res.end(JSON.stringify({ enabled: false, receipts: [] }));
++ return res.end(JSON.stringify({ enabled: true, receipts: [{
++ thread_id: 'thr_1', message_id: 'msg_1', merchant: 'Shop', subject: 'Your order',
++ summary: 'Order delivered', received_at: '2026-11-08T09:30:00Z',
++ }] }));
++ });
++ await new Promise((r) => mail.listen(0, '127.0.0.1', r));
++
++ const srv = await bootServer({ env: { OV_MAIL_INTERNAL_URL: `http://127.0.0.1:${mail.address().port}` } });
++ try {
++ const db = srv.app.locals.db;
++ const config = srv.app.locals.config;
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (92, 'receipts', '[email protected]', 'x', 1)").run();
++ const user = await db.prepare('SELECT * FROM users WHERE id = 92').get();
++ const token = signToken(user, srv.app.locals.privateKey, config);
++ const get = (headers = {}) => fetch(`${srv.base}/api/v1/me/receipts`, { headers });
++
++ let r = await get({ authorization: `Bearer ${token}` });
++ let d = await r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.enabled, true);
++ assert.strictEqual(d.receipts.length, 1);
++ assert.strictEqual(d.receipts[0].message_id, 'msg_1');
++ assert.strictEqual(asked[0].url, '/api/v1/me/mail/receipts', 'the real app reached Mail');
++ assert.strictEqual(asked[0].auth, `Bearer ${token}`, "with the person's own token");
++
++ mode = 'off';
++ r = await get({ authorization: `Bearer ${token}` });
++ d = await r.json();
++ assert.deepStrictEqual(d, { enabled: false, receipts: [] }, 'Inbox AI off: no receipts and the flag to say so');
++
++ mode = 'down';
++ r = await get({ authorization: `Bearer ${token}` });
++ assert.strictEqual(r.status, 502, 'Mail away: the card stays out');
++
++ r = await get();
++ assert.strictEqual(r.status, 401, 'no person, no receipts');
++ } finally {
++ await srv.stop();
++ mail.close();
++ console.log('my-receipts-route: the real route forwards the person token and passes Mail states through ok');
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/my-receipts.test.js b/test/my-receipts.test.js
nnew file mode 100644
iindex 00000000..f2fcac82
---- /dev/null
++++ b/test/my-receipts.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++// The Receipts card on openvibe.my (plan OpenVibe_Seven_Domains_Plan R2 item 13): GET /api/v1/me/receipts hands the
++// person's own token to Mail, and my/receipts.js draws the latest five under Needs you, a counted fold for the rest,
++// and one quiet line that links to the Inbox AI switch when it is off (or there is no mailbox). No sample receipts.
++// node test/my-receipts.test.js
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const express = require('express');
++const vm = require('vm');
++const { router, receiptOf } = require('../server/today/receipts');
++
++const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
++const JS = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'receipts.js'), 'utf8');
++const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'my.css'), 'utf8');
++
++// ── The page: the card sits under Needs you, above the hero, and starts out hidden ──
++assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="receipts"'), 'Receipts follows Needs you');
++assert.ok(HTML.indexOf('id="receipts"') < HTML.indexOf('id="page-header"'), 'and is above the hero');
++assert.match(HTML, /<section class="rc" id="receipts" hidden/);
++assert.match(HTML, /<script src="\/js\/my\/receipts\.js" defer><\/script>/, 'the card has its own script');
++assert.match(CSS, /\.rc-row \{/, 'the rows have a style');
++
++class Node {
++ constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this.hidden = false; this.listeners = {}; this._text = ''; this.style = {}; }
++ setAttribute(k, v) { this.attrs[k] = String(v); }
++ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
++ append(...kids) { for (const k of kids) this.children.push(typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k); }
++ replaceChildren(...kids) { this.children = []; this.append(...kids); }
++ remove() {}
++ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
++ click() { (this.listeners.click || []).forEach((fn) => fn({})); }
++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
++ set textContent(v) { this._text = String(v); this.children = []; }
++ get href() { return this.attrs.href; }
++ all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
++}
++
++function page() {
++ const ids = {};
++ for (const [, id] of HTML.matchAll(/id="([^"]+)"/g)) ids[id] = new Node('div');
++ const sandbox = {
++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t) },
++ console, Date, Number, String, Array, Map, Set, Math, JSON, Promise, encodeURIComponent,
++ isAnonSession: () => false,
++ };
++ vm.runInNewContext(JS, sandbox);
++ return { ids, sandbox };
++}
++
++const receipt = (i) => ({
++ thread_id: `thr_${i}`, message_id: `msg_${i}`, merchant: `Shop ${i}`,
++ subject: `Order ${i}`, summary: `Order ${i} delivered`, received_at: '2026-11-08T09:30:00Z',
++});
++
++(async () => {
++ // ── Inbox AI off (or no mailbox): one quiet line, a link to the switch, and no sample receipt ──
++ let p = page();
++ p.sandbox.renderReceipts({ enabled: false, receipts: [] });
++ assert.strictEqual(p.ids.receipts.hidden, false, 'the card says why it is empty');
++ const quiet = p.ids['receipts-body'].children;
++ assert.strictEqual(quiet.length, 1, 'one line only');
++ assert.strictEqual(quiet[0].tagName, 'A');
++ assert.strictEqual(quiet[0].textContent, 'Turn on Inbox AI on openvibe.email to see receipts here');
++ assert.strictEqual(quiet[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai', 'it links to the switch');
++ assert.ok(!/Shop|Order|sample|example/i.test(p.ids['receipts-body'].textContent), 'no sample or fake receipt');
++
++ // ── On with receipts: the latest five, then a counted fold for the rest, each a thread on openvibe.email ──
++ p = page();
++ p.sandbox.renderReceipts({ enabled: true, receipts: [0, 1, 2, 3, 4, 5, 6].map(receipt) });
++ assert.strictEqual(p.ids.receipts.hidden, false);
++ const body = p.ids['receipts-body'];
++ assert.strictEqual(body.children.filter((c) => c.className === 'rc-row').length, 5, 'five rows are shown');
++ const rest = body.children.find((c) => c.className === 'rc-rest');
++ const more = body.children.find((c) => c.className === 'rc-more');
++ assert.ok(rest && more, 'the rest are a fold, not a tab');
++ assert.strictEqual(rest.hidden, true);
++ assert.strictEqual(rest.children.length, 2);
++ assert.strictEqual(more.textContent, 'More (2)', 'the fold is counted');
++ assert.strictEqual(more.getAttribute('aria-expanded'), 'false');
++ const rows = body.all((c) => c.className === 'rc-row');
++ assert.strictEqual(rows[0].href, 'https://openvibe.email/inbox/msg_0', 'each row links its thread');
++ assert.match(rows[0].textContent, /^Shop 0Order 0 delivered.+$/, 'merchant, one-line summary, date');
++ assert.strictEqual(rows[0].getAttribute('target'), '_blank');
++ more.click();
++ assert.strictEqual(rest.hidden, false, 'More unfolds');
++ assert.strictEqual(more.textContent, 'Show less');
++ assert.strictEqual(more.getAttribute('aria-expanded'), 'true');
++ assert.strictEqual(body.all((c) => c.className === 'rc-row').length, 7);
++ more.click();
++ assert.strictEqual(rest.hidden, true, 'and folds again');
++ assert.strictEqual(more.textContent, 'More (2)');
++
++ // A thread with no message id falls back to its thread id; nothing else is turned into a link.
++ assert.strictEqual(receiptOf({ thread_id: 'thread 1', summary: 'x' }), null, 'a bad id is not passed');
++ p = page();
++ p.sandbox.renderReceipts({ enabled: true, receipts: [{ ...receipt(0), message_id: null, thread_id: 'thr_ok' }] });
++ assert.strictEqual(p.ids['receipts-body'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.email/inbox/thr_ok');
++
++ // ── On with nothing triaged yet: a quiet line, not the turn-it-on line ──
++ p = page();
++ p.sandbox.renderReceipts({ enabled: true, receipts: [] });
++ assert.match(p.ids['receipts-body'].textContent, /^No receipts yet\.$/);
++ assert.ok(!/Turn on Inbox AI/.test(p.ids['receipts-body'].textContent));
++
++ // ── The route: Mail is asked with the person's own token; off hides rows, on passes them, down stays out ──
++ const seen = [];
++ let mode = 'off';
++ const mail = http.createServer((req, res) => {
++ seen.push({ url: req.url, auth: req.headers.authorization });
++ if (mode === 'down') { res.statusCode = 503; return res.end('{}'); }
++ res.setHeader('content-type', 'application/json');
++ if (mode === 'off') return res.end(JSON.stringify({ enabled: false, receipts: [receipt(0)] }));
++ return res.end(JSON.stringify({ enabled: true, receipts: [receipt(0), receipt(1), { thread_id: 'bad id', summary: 'x' }] }));
++ });
++ await new Promise((r) => mail.listen(0, '127.0.0.1', r));
++ const app = express();
++ const requireAuth = (req, _res, next) => { req.user = { id: 7, is_anon: false }; req.token = 'person-token'; next(); };
++ app.use('/api/v1/me/receipts', router({ requireAuth, mailUrl: `http://127.0.0.1:${mail.address().port}/` }));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/v1/me/receipts`;
++ const get = async () => { const r = await fetch(base); return { r, d: await r.json() }; };
++ try {
++ let { r, d } = await get();
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(d, { enabled: false, receipts: [] }, 'off: no rows, even if an old Mail kept some');
++ assert.strictEqual(seen[0].url, '/api/v1/me/mail/receipts');
++ assert.strictEqual(seen[0].auth, 'Bearer person-token', "Mail gets the person's own token");
++
++ mode = 'on';
++ ({ r, d } = await get());
++ assert.strictEqual(d.enabled, true);
++ assert.strictEqual(d.receipts.length, 2, 'a bad id is dropped');
++ assert.deepStrictEqual(Object.keys(d.receipts[0]), ['thread_id', 'message_id', 'merchant', 'subject', 'summary', 'received_at']);
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++
++ mode = 'down';
++ ({ r, d } = await get());
++ assert.strictEqual(r.status, 502);
++ assert.strictEqual(d.error, 'mail.unavailable');
++ } finally {
++ server.close();
++ mail.close();
++ console.log('my-receipts: off line, five rows and counted fold, thread links, and the route ok');
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 6455bb67..bbcd4784 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -122,6 +122,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
++ 'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],