openvibers/openvibe.network Commits
Receipts on the account home, under Needs you (#681)
Changes
@@@ -91,6 +91,8 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100OV_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:4800OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810@@@ -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). */@@@ -17,6 +17,7 @@ const _signedIn = !!(getAuthToken() || getAnonToken());loadUser();if (_signedIn) {loadToday();++ loadReceipts();openInitialSection();refreshApprovalsBadge();}nnew file mode 100644@@@ -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++ }++}@@@ -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 inyour 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>@@@ -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: {@@@ -644,6 +644,9 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.blocapp.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.nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst 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');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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)'],
@@ -91,6 +91,8 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
@@ -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_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
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.
# The publication reviewer queue (/admin/review): each product's staff review-queue endpoint.
OV_WIKI_INTERNAL_URL=http://127.0.0.1:4800
OV_WIKI_INTERNAL_URL=http://127.0.0.1:4800
OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810
OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810
@@ -266,6 +266,30 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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). */
/* 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; } }
@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 { display: grid; gap: 22px; margin: -8px 0 36px; }
.td-sec { display: grid; gap: 10px; min-width: 0; }
.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). */
/* A block with nothing in it stays out (11-07: the empty "Live now" heading showed, and read as if you were live). */
@@ -17,6 +17,7 @@ const _signedIn = !!(getAuthToken() || getAnonToken());
@@ -17,6 +17,7 @@ const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
loadUser();
if (_signedIn) {
if (_signedIn) {
loadToday();
loadToday();
+ loadReceipts();
openInitialSection();
openInitialSection();
refreshApprovalsBadge();
refreshApprovalsBadge();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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
+ }
+}
@@ -66,6 +66,17 @@
@@ -66,6 +66,17 @@
<div class="ny-list" id="needs-you-list"></div>
<div class="ny-list" id="needs-you-list"></div>
</section>
</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
<!-- 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. -->
your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
<header class="me" id="page-header">
@@ -611,6 +622,7 @@
@@ -611,6 +622,7 @@
<script src="/js/my/security.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/apps.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="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
@@ -160,6 +160,11 @@ module.exports = {
@@ -160,6 +160,11 @@ module.exports = {
billing: {
billing: {
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
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
// 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.
// person, so Bot stays the authority on what a person may do with their own machines.
bot: {
bot: {
@@ -644,6 +644,9 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -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);
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).
// 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 }));
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).
// 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);
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.
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
@@ -36,7 +36,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
@@ -36,7 +36,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
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_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_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) {
if (child) {
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -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();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// 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');
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 tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -41,7 +41,8 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
@@ -41,7 +41,8 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');
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');
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.
// 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.
// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
{
{
const vm = require('vm');
const vm = require('vm');
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -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); });
@@ -122,6 +122,7 @@ const REVIEWED = {
@@ -122,6 +122,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'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/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/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/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/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)'],
'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)'],