openvibers/openvibe.tips Commits

Privacy controls: supporters choose what the public sees, creators what their pages show, supporters export and erase their tips

OP OpenVibers committed 23 Sept 2026 · 2e79f741a9 · 19 files, +872 −67

- A supporter chooses per tip (privacy { anonymous, hide_amount, private_message } on the API, three boxes on the tip form): anonymous reads "Anonymous" with no subject to everyone but the supporter (creator, moderators, overlays, chat jobs, public pages, services, events); a hidden amount is left out of overlays, chat lines and public pages and off the leaderboard (the creator and internal events keep it; the goal update of such a tip names nobody); a private message (tips only) reaches the creator only. server/domain/privacy.js publicView() is the one shape that leaves Tips for the public; every API answer carries privacy and public.
- The creator chooses what the public goal and supporters pages show (profile page settings: goal amounts or percentage only, each goal's latest supporters, a /<handle>/supporters page with top supporters, their totals and recent public messages); the API answers goals and supporters in that public shape to anyone but the creator and granted services.
- Export (GET /api/v1/me/export, /receipts/export) and erasure (POST /api/v1/me/erase, /receipts/erase with a confirmation): the person goes (subject, name, message, TTS text, media link, checkout reference, stored API answers, overlay payloads, unsent events), the money record stays so Billing and the creators' totals still reconcile; pending payments are kept. Each erased interaction emits tips.interaction.erased with redacts (schema proposed in docs/events-proposal/, a contracts addition). Stored API answers are pruned after a week.
- Schema v2 adds the columns on boot (ALTER TABLE, idempotent). test/privacy.test.js.

Changes

ddiff --git a/README.md b/README.md
iindex 89225d3..44e070d 100644
---- a/README.md
++++ b/README.md
@@@ -111,14 +111,16 @@ token as a Bearer and act on their own things only. The API never reads cookies.
| `GET /profiles/:creator` | `tips.profile.get` (switched-off pages) | anyone when the page is on; the owner |
| `PATCH /profiles/:creator` (`me` creates it) | `tips.profile.update` | owner |
| `GET /profiles/:creator/totals` | `tips.interaction.list` | owner |
--| `POST /checkout` `{creator, amount, message?, goal_id?, pay_with: credit\|checkout, provider?, supporter (services), target?}` | `tips.checkout.create` | the supporter |
++| `POST /checkout` `{creator, amount, message?, goal_id?, pay_with: credit\|checkout, provider?, supporter (services), target?, privacy?}` | `tips.checkout.create` | the supporter |
| `POST /paid-messages` | `tips.superchat.create` | the supporter |
| `POST /tts-requests` `{…, tts: {text, voice}}` | `tips.tts.request` | the supporter |
| `POST /media-requests` `{…, media: {url}}` | `tips.media_request.create` | the supporter |
| `GET /interactions/:id` | `tips.interaction.get` | its creator or supporter |
| `GET /interactions?creator=\|supporter=&cursor=` | `tips.interaction.list` | own receipts; `?as=creator` own tips |
| `POST /interactions/external` `{creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?}` | `tips.interaction.record` | — |
--| `GET /goals?creator=`, `GET /goals/:id` | `tips.goal.update` (private) | public when the page is on; owner sees contributions |
++| `GET /goals?creator=`, `GET /goals/:id` | `tips.goal.update` (private; full view) | public shape when the page is on (the creator's page settings); owner sees contributions |
++| `GET /profiles/:creator/supporters` | `tips.profile.get` (while not public) | public when the creator shows the supporters page; the owner |
++| `GET /me/export`, `POST /me/erase` | — (people only) | the supporter's own tips: download; erase their data from them |
| `POST /goals`, `PATCH /goals/:id`, `POST /goals/:id/close` | `tips.goal.create` / `.update` / `.close` | owner |
| `GET\|POST /overlay-tokens`, `POST /overlay-tokens/:id/revoke` | `tips.overlay.token.create` / `.revoke` | owner |
| `GET /overlay-configs[/:id]`, `POST /overlay-configs`, `PATCH /overlay-configs/:id` | `tips.overlay.config.get` / `.update` | owner |
@@@ -133,12 +135,47 @@ contracts' `capabilities.grants()` (exact id or a `.*` family). Tips pins openvi
which also carries the payload schemas of the six `tips.*` events below; `test/contracts.test.js`
validates every envelope and payload Tips produces against them.
++## Privacy
++
++What a supporter lets the public see is their choice, per tip (`privacy: { anonymous, hide_amount,
++private_message }` on the API, three boxes on the tip form), stored on the interaction
++([server/domain/privacy.js](server/domain/privacy.js)):
++
++| Choice | Overlays, chat lines, public pages, other products | The creator | Events (internal) |
++|---|---|---|---|
++| `anonymous` | "Anonymous", no subject | "Anonymous", no subject | `supporter: null`, `supporter_name: "Anonymous"` |
++| `hide_amount` | no amount (`amount: null`, the line reads "sent a tip"); off the leaderboard | the amount (their money) | the amount (goals and reconciliation need it) |
++| `private_message` (tips only) | no message | the message | no message is ever in an event |
++
++Only the supporter (their receipts, `GET /me/export`) and Billing (which moved the money) can link an
++anonymous tip to them. `publicView()` is the one shape that leaves Tips for the public: overlay alert
++payloads, chat jobs (which also carry `privacy`), the supporters and goals pages, and the `public` block
++of every API answer. A goal bar still moves by a hidden amount, so the goal update of such a tip names
++nobody.
++
++**What the creator's public pages show** is theirs to choose (`page` on the profile, the dashboard's
++*Public pages*): goal amounts or the percentage only, each goal's latest supporters, and a supporters
++page (`/<handle>/supporters`, off by default) with the top supporters, their totals and recent public
++messages. The leaderboard counts only tips whose supporter kept both name and amount public.
++
++**Export and erasure.** `GET /me/export` (and `/receipts/export`) downloads everything Tips holds about a
++supporter's tips. `POST /me/erase` (and `/receipts/erase`, with a confirmation) removes the person from
++every settled, failed or reversed tip — subject, name, message, TTS text, media link, checkout reference,
++stored API answers, overlay payloads, unsent events — and keeps the money record (amount, creator, date,
++Billing transaction, goal contribution), so Billing's books and the creators' totals still reconcile.
++Tips still waiting for their payment are kept until it settles or fails. Each erased interaction emits
++`tips.interaction.erased`, whose `redacts` has OpenVibe.Events tombstone Tips' earlier events about it.
++Stored API answers (Idempotency-Key replays) are pruned after a week.
++
## Events
Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, same transaction as the
change; relayed when `EVENTS_URL` and the client secret are set): `tips.interaction.ready`,
`tips.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,
--`tips.overlay.failed`. Simulations and Billing test money produce none. Consumed:
++`tips.overlay.failed`, and `tips.interaction.erased` (a supporter's erasure; payload
++`{ interaction_id, creator, erased_at, redacts }`, schema proposed in
++[docs/events-proposal/](docs/events-proposal/) — not yet in openvibe-contracts). Simulations and Billing
++test money produce none. An anonymous supporter is never named in an event. Consumed:
`billing.transaction.settled`, `billing.transaction.reversed`, `billing.receipt.external` (only from source
`billing`). `billing.receipt.external` (payload: `streamer` SubjectRef, `amount_cents`, `value_bits`,
`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
@@@ -165,7 +202,11 @@ retries). `live-chat` posts to Live's `/internal/tips/deliveries` with Tips' ser
`openvibe.live`, `live.tips_delivery.write`), `Idempotency-Key = <interaction>:<effect>`; Live does what
`POST /api/funds/donate` does after the money moved (channel + global broadcast, a `donation` chat
message, the alert sound), TTS through `synthesizeAndBroadcastTTS`, media requests into its queue at no
--charge. The route came from [docs/live-patch.diff](docs/live-patch.diff) and is deployed in Live since
++charge. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject for an
++anonymous supporter, `interaction.amount: null` and a line without the amount when it is hidden, no
++private message. (Live's route turns a null amount into a `donation` event of 0 Vibes, which its chat
++renders as "donated 0 Vibes"; it should render an amount-less line when `privacy.hide_amount` is set.)
++The route came from [docs/live-patch.diff](docs/live-patch.diff) and is deployed in Live since
`f11f809` (its idempotency record is in memory only); nothing calls it yet. `test`
records jobs in memory (development; simulations always use it). `none` (the production default) creates
no chat effects, and it is what production runs. When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces
@@@ -175,7 +216,8 @@ no chat effects, and it is what production runs. When OpenVibe.Chat publishes a
`/` · `/<handle>` (goals + tip form posting to checkout; `index,follow` only when the creator switched the
page on, otherwise 404 for everyone else and a `noindex` preview for the creator) · `/<handle>/goals` ·
--`/receipts`, `/receipts/:id` (payment and delivery shown separately) · `/dashboard` (page settings,
++`/<handle>/supporters` (when the creator shows it) · `/receipts`, `/receipts/:id` (payment and delivery
++shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/dashboard` (page settings, public pages,
goals, overlay links shown once, alert settings, simulation, totals, recent tips, overlay deliveries) ·
`/robots.txt`, `/sitemap.xml` (switched-on pages only) · `/release.json` · `/terms`, `/privacy`, `/dmca`.
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. Shared chrome: Network
@@@ -207,6 +249,7 @@ a copy of Billing's database for the links and totals:
| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `test/api.test.js` (totals = the stub Billing payable) |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |
++| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
## What waits
ddiff --git a/STATUS.json b/STATUS.json
iindex 50b05ef..4af3e4d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,6 +7,6 @@
"adr": "ADR-012",
"authoritative": false,
"deployment": "Deployed internally, not launched: openvibe-ovh /opt/openvibe.tips (release 435f32b), unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscription billing.transaction.* -> /internal/events; TIPS_CHAT_ADAPTER=none. Pins openvibe-contracts v0.15.0, openvibe-sdk v0.4.0, openvibe-shared v1.2.1.",
-- "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Not done: privacy/moderation controls on the pages; an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
++ "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Built since (not deployed yet): supporter privacy (anonymous, hidden amount, private message) across API, overlays, chat jobs, pages and events; the creator's public goal and supporters page settings; a supporter's export and erasure (tips.interaction.erased, schema proposed in docs/events-proposal/, not yet in openvibe-contracts). Not done: moderation controls; an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"updated": "2026-09-23"
}
ddiff --git a/docs/events-proposal/tips.interaction.erased.v1.json b/docs/events-proposal/tips.interaction.erased.v1.json
nnew file mode 100644
iindex 0000000..d372631
---- /dev/null
++++ b/docs/events-proposal/tips.interaction.erased.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.erased.v1.json",
++ "title": "TipsInteractionErasedPayload",
++ "description": "tips.interaction.erased v1 (OpenVibe.Tips server/domain/interactions.js erase). The supporter erased their data from this interaction: Tips no longer holds their subject, name, message, text-to-speech text, media link or checkout reference; the money record (amount, creator, Billing transaction, goal contribution) stays so Billing's books and the creator's totals still reconcile. payload.redacts names every earlier Tips event about the interaction, so OpenVibe.Events tombstones them (ADR-026). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter data. Never emitted for simulations.",
++ "type": "object",
++ "required": ["interaction_id", "creator", "erased_at", "redacts"],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": { "type": "string", "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "creator": { "$ref": "../../identity/subject-ref.v1.json" },
++ "erased_at": { "type": "string", "format": "date-time" },
++ "redacts": {
++ "$ref": "../redaction-directive.v1.json",
++ "description": "{ subject_type: interaction, subject_ids: [interaction_id] }."
++ }
++ }
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex e1d3ecf..1084ac0 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -38,7 +38,8 @@
"tips.interaction.cancelled",
"tips.goal.updated",
"tips.overlay.delivered",
-- "tips.overlay.failed"
++ "tips.overlay.failed",
++ "tips.interaction.erased"
],
"eventsConsumed": [
"billing.transaction.settled",
ddiff --git a/public/css/tips.css b/public/css/tips.css
iindex d0a5b67..578f896 100644
---- a/public/css/tips.css
++++ b/public/css/tips.css
@@@ -61,4 +61,11 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
.steps li { margin: 8px 0; }
.js .tip-form .when-tts, .js .tip-form .when-media { display: none; }
.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media { display: block; }
++.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
++ol.leaderboard { padding-left: 1.6em; }
++ol.leaderboard li, ul.supporters li { margin: 4px 0; }
++ul.supporters { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: .92rem; }
++ul.wall { list-style: none; padding: 0; }
++ul.wall li { border-bottom: 1px solid var(--tips-border); padding: 8px 0; }
++ul.wall p { margin: 4px 0; overflow-wrap: anywhere; }
@media (max-width: 560px) { dl.receipt { grid-template-columns: 1fr; } .creator-head { flex-direction: column; align-items: flex-start; } table.list { font-size: .85rem; } }
ddiff --git a/public/js/overlay.js b/public/js/overlay.js
iindex 4a64455..f467d3e 100644
---- a/public/js/overlay.js
++++ b/public/js/overlay.js
@@@ -11,6 +11,7 @@
var queue = [];
var busy = false;
var shown = {};
++ var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media' };
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }
@@@ -38,7 +39,8 @@
if (s.image_url) { var img = el('img'); img.src = s.image_url; img.alt = ''; box.appendChild(img); }
if (a.test) box.appendChild(el('div', 'test', 'test alert'));
var title = el('div', 'title');
-- var tpl = String(s.template || '{name} tipped {amount} Vibes');
++ // A supporter who hid the amount: the server sends none, and the line does not pretend one.
++ var tpl = a.amount == null ? '{name} ' + (HIDDEN[a.kind] || HIDDEN.tip) : String(s.template || '{name} tipped {amount} Vibes');
var parts = tpl.split(/(\{name\}|\{amount\})/);
parts.forEach(function (p) {
if (p === '{name}') title.appendChild(el('b', null, a.supporter_name || 'Someone'));
ddiff --git a/server/api/idempotency.js b/server/api/idempotency.js
iindex 91a1ca7..b2d1162 100644
---- a/server/api/idempotency.js
++++ b/server/api/idempotency.js
@@@ -48,4 +48,13 @@ function idempotent(db, now) {
};
}
--module.exports = { idempotent, stable, principalKey };
++/**
++ * Stored answers are kept for a week: long enough for any client retry, and no longer, since they
++ * carry messages and subjects (a supporter's erasure removes theirs at once).
++ */
++const RETAIN_MS = 7 * 24 * 3600 * 1000;
++function pruneAnswers(db, now = Date.now(), retainMs = RETAIN_MS) {
++ return db.prepare('DELETE FROM api_idempotency WHERE created_at < ?').run(new Date(now - retainMs).toISOString()).changes;
++}
++
++module.exports = { idempotent, stable, principalKey, pruneAnswers, RETAIN_MS };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 9f2bc34..a192430 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -27,6 +27,12 @@
* | GET /overlay-configs[/:id] | tips.overlay.config.get | owner |
* | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |
* | POST /simulate | tips.simulation.run | owner |
++ * | GET /profiles/:creator/supporters | tips.profile.get (page off) | anyone when the page shows it; owner|
++ * | GET /me/export, POST /me/erase | — | the supporter's own tips |
++ *
++ * Paid requests take `privacy: { anonymous, hide_amount, private_message }` (server/domain/privacy.js).
++ * Goals and supporters are answered in their public shape (the creator's page settings) to anyone
++ * but the creator and granted services.
*/
const express = require('express');
const { http } = require('openvibe-contracts');
@@@ -87,9 +93,32 @@ function v1Router({ domain, apiAuth }) {
// A page that is switched off does not exist for anyone but its creator and granted services.
if (!p.page_enabled && !mine && !svc) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
const owner = mine || svc;
-- res.json({ profile: profiles.present(p, { owner }), goals: goals.list(p.creator_subject, { status: 'active' }).map((g) => goals.present(g)) });
++ const active = goals.list(p.creator_subject, { status: 'active' });
++ res.json({ profile: profiles.present(p, { owner }), goals: active.map((g) => (owner ? goals.present(g) : goals.publicGoal(g, p.page))) });
++ }));
++
++ /** The supporters page: top supporters and recent public messages, as the creator chose to show them. */
++ r.get('/profiles/:creator/supporters', wrap((req, res) => {
++ const p = creatorOf(req, req.params.creator);
++ const who = req.principal;
++ const owner = (who.kind === 'user' && who.subject === p.creator_subject) || (who.kind === 'service' && apiAuth.granted(who, CAP.profileGet));
++ if (!owner && (!p.page_enabled || !p.page.supporters_page)) fail(404, 'tips.supporters_not_public', 'this creator does not show their supporters');
++ const page = p.page;
++ res.json({
++ creator: { type: 'user', id: p.creator_subject }, page,
++ leaderboard: interactions.leaderboard(p.creator_subject).map((row) => (page.supporters_amounts ? row : { ...row, total: null })),
++ recent: page.supporters_messages ? interactions.recentPublic(p.creator_subject).map((v) => (page.supporters_amounts ? v : { ...v, amount: null })) : null,
++ });
}));
++ // ── The supporter's own data ─────────────────────────────
++ const person = (req) => {
++ if (req.principal.kind !== 'user') fail(req.principal.kind === 'anonymous' ? 401 : 403, req.principal.kind === 'anonymous' ? 'token.missing' : 'tips.people_only', 'only the person themselves (a Network user token) can do this');
++ return req.principal.subject;
++ };
++ r.get('/me/export', wrap((req, res) => res.json(interactions.exportFor(person(req)))));
++ r.post('/me/erase', ...write((req, res) => res.json(interactions.erase(person(req)))));
++
r.patch('/profiles/:creator', ...write((req, res) => {
const b = req.body || {};
let p;
@@@ -210,18 +239,20 @@ function v1Router({ domain, apiAuth }) {
const p = req.principal;
return (p.kind === 'user' && p.subject === g.creator_subject) || (p.kind === 'service' && apiAuth.granted(p, CAP.goalUpdate));
}
++ const goalOwner = (req, creator) => (req.principal.kind === 'user' && req.principal.subject === creator) || (req.principal.kind === 'service' && apiAuth.granted(req.principal, CAP.goalUpdate));
r.get('/goals', wrap((req, res) => {
const profile = creatorOf(req, req.query.creator);
const sample = { creator_subject: profile.creator_subject };
if (!goalVisible(req, sample)) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
const status = ['active', 'closed'].includes(req.query.status) ? req.query.status : undefined;
-- res.json({ goals: goals.list(profile.creator_subject, { status }).map((g) => goals.present(g)) });
++ const full = goalOwner(req, profile.creator_subject);
++ res.json({ goals: goals.list(profile.creator_subject, { status }).map((g) => (full ? goals.present(g) : goals.publicGoal(g, profile.page))) });
}));
r.get('/goals/:id', wrap((req, res) => {
const g = goals.get(req.params.id);
if (!g || !goalVisible(req, g)) fail(404, 'tips.goal_not_found', 'no such goal');
-- const mine = (req.principal.kind === 'user' && req.principal.subject === g.creator_subject) || req.principal.kind === 'service';
-- res.json({ goal: goals.present(g, { contributions: mine }) });
++ if (!goalOwner(req, g.creator_subject)) return res.json({ goal: goals.publicGoal(g, profiles.bySubject(g.creator_subject).page) });
++ res.json({ goal: goals.present(g, { contributions: true }) });
}));
r.post('/goals', ...write((req, res) => {
const b = req.body || {};
ddiff --git a/server/db.js b/server/db.js
iindex 82040b3..e762bca 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -20,7 +20,8 @@
* migration_maps legacy (Live) row → Tips entity, with held/excluded reasons
*
* Supporting: overlay_tokens (hashed, scoped, revocable), api_idempotency, import_runs,
-- * event_outbox + idempotency_receipts (openvibe-sdk outbox and inbox), settings.
++ * event_outbox + idempotency_receipts (openvibe-sdk outbox and inbox), settings. Columns added after
++ * v1 are in COLUMNS below.
*/
const fs = require('fs');
const path = require('path');
@@@ -257,7 +258,30 @@ CREATE TABLE IF NOT EXISTS api_idempotency (
);
`;
--const SCHEMA_VERSION = 1;
++/**
++ * Columns added after schema v1, for new and existing databases alike (ALTER TABLE … ADD COLUMN when
++ * missing; idempotent, never rewrites a row).
++ *
++ * v2 privacy: what the supporter lets the public see of an interaction (server/domain/privacy.js),
++ * their erasure, and what the creator's public goal and supporters pages show
++ */
++const COLUMNS = [
++ ['tip_interactions', 'anonymous', 'INTEGER NOT NULL DEFAULT 0'], // name shown as "Anonymous" to everyone but the supporter
++ ['tip_interactions', 'hide_amount', 'INTEGER NOT NULL DEFAULT 0'], // amount left out of overlays, chat lines, public pages
++ ['tip_interactions', 'private_message', 'INTEGER NOT NULL DEFAULT 0'], // a tip's message is for the creator only
++ ['tip_interactions', 'erased_at', 'TEXT'], // the supporter erased their data from it
++ ['creator_tip_profiles', 'page_settings', "TEXT NOT NULL DEFAULT '{}'"], // JSON: what the public goal / supporters pages show
++];
++
++const SCHEMA_VERSION = 2;
++
++function addColumns(db) {
++ const have = new Map();
++ for (const [table, name, def] of COLUMNS) {
++ if (!have.has(table)) have.set(table, new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
++ if (!have.get(table).has(name)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`); have.get(table).add(name); }
++ }
++}
function openDb(file) {
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
@@@ -266,8 +290,10 @@ function openDb(file) {
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
++ db.transaction(() => addColumns(db))();
db.prepare('INSERT OR IGNORE INTO settings (id, schema_version, created_at) VALUES (1, ?, ?)').run(SCHEMA_VERSION, new Date().toISOString());
++ db.prepare('UPDATE settings SET schema_version = ? WHERE id = 1 AND schema_version < ?').run(SCHEMA_VERSION, SCHEMA_VERSION);
return db;
}
--module.exports = { openDb, SCHEMA_VERSION };
++module.exports = { openDb, SCHEMA_VERSION, COLUMNS };
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
iindex 75a0b1e..f4feaeb 100644
---- a/server/domain/effects.js
++++ b/server/domain/effects.js
@@@ -8,8 +8,13 @@
* A failure is retried with backoff (TIPS_DELIVERY_BACKOFF_MS) up to TIPS_DELIVERY_MAX_ATTEMPTS, or
* stops at once when the adapter says it is permanent. The interaction's delivery_state is then
* recomputed (failed → tips.interaction.failed). payment_state is never touched here.
++ *
++ * A job carries the interaction's public view only (privacy.js): an anonymous supporter is
++ * "Anonymous" with no subject, a hidden amount is null and left out of the line, a private message
++ * is not sent. `privacy` tells the receiving product what was withheld.
*/
const { iso, json } = require('../util');
++const { publicView, privacyOf, isAnonymous } = require('./privacy');
function createEffects(ctx) {
const { db, config } = ctx;
@@@ -18,22 +23,25 @@ function createEffects(ctx) {
function jobFor(effect, i, profile) {
const req = json(i.request, {});
-- const name = i.supporter_name || 'Someone';
-- const amount = i.amount.toLocaleString('en-US');
++ const pv = publicView(i);
++ const name = pv.supporter_name;
++ const amount = pv.amount != null ? `${pv.amount.toLocaleString('en-US')} Vibes` : null;
++ const msg = pv.message ? `: ${pv.message}` : '';
const job = {
delivery_id: `${i.id}:${effect.effect}`,
effect: effect.effect,
test: !!i.test,
creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },
-- supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },
-- interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },
++ supporter: { name, subject: i.supporter_subject && !isAnonymous(i) ? { type: 'user', id: i.supporter_subject } : null },
++ interaction: { id: i.id, kind: i.kind, amount: pv.amount, currency: i.currency, message: pv.message },
++ privacy: privacyOf(i),
// A tip on the creator's own PowerChat reads as Live's webhook wrote it: "… (PowerChat)".
-- text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}${i.settlement === 'external' && i.provider === 'powerchat' ? ' (PowerChat)' : ''}`,
++ text: `${name} ${amount ? `tipped ${amount}` : 'sent a tip'}${msg}${i.settlement === 'external' && i.provider === 'powerchat' ? ' (PowerChat)' : ''}`,
target: json(i.target, null),
};
if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
-- if (effect.effect === 'tts') job.tts = req.tts;
-- if (effect.effect === 'media_request') { job.media = req.media; job.text = `${name} requested media for ${amount} Vibes`; }
++ if (effect.effect === 'tts') job.tts = pv.tts;
++ if (effect.effect === 'media_request') { job.media = req.media; job.text = amount ? `${name} requested media for ${amount}` : `${name} requested media`; }
return job;
}
ddiff --git a/server/domain/goals.js b/server/domain/goals.js
iindex 15e2a09..4f5748f 100644
---- a/server/domain/goals.js
++++ b/server/domain/goals.js
@@@ -10,9 +10,15 @@
* no pick, none.
*
* Every change bumps the goal's revision and emits tips.goal.updated plus an overlay delivery.
++ *
++ * What the public sees of a goal is the creator's choice (profile page settings): publicGoal()
++ * drops the amounts when goal_amounts is off (the percentage stays) and lists the latest supporters
++ * when goal_supporters is on, each supporter's own privacy applying (an anonymous one reads
++ * "Anonymous", a hidden amount is left out).
*/
const { fail, iso, prefixedId, text, positiveInt } = require('../util');
const { safeUrl } = require('./profiles');
++const { publicName } = require('./privacy');
function createGoals(ctx) {
const { db } = ctx;
@@@ -44,8 +50,24 @@ function createGoals(ctx) {
created_at: g.created_at, updated_at: g.updated_at, closed_at: g.closed_at || null,
};
if (contributions) {
-- out.contributions = db.prepare(`SELECT c.interaction_id, c.amount, c.reversed_amount, c.created_at, i.supporter_name
-- FROM tip_goal_contributions c JOIN tip_interactions i ON i.id = c.interaction_id WHERE c.goal_id = ? ORDER BY c.id DESC LIMIT 200`).all(g.id);
++ // The creator's view: amounts always (their money), names as the supporter allowed.
++ out.contributions = db.prepare(`SELECT c.interaction_id, c.amount, c.reversed_amount, c.created_at, i.supporter_name, i.anonymous, i.erased_at
++ FROM tip_goal_contributions c JOIN tip_interactions i ON i.id = c.interaction_id WHERE c.goal_id = ? ORDER BY c.id DESC LIMIT 200`).all(g.id)
++ .map(({ anonymous, erased_at: erasedAt, ...c }) => ({ ...c, supporter_name: publicName({ ...c, anonymous, erased_at: erasedAt }) }));
++ }
++ return out;
++ }
++
++ /** The public shape of a goal under the creator's page settings (`page`, profiles.js). */
++ function publicGoal(g, page) {
++ const out = present(g);
++ if (!out) return null;
++ if (!page.goal_amounts) Object.assign(out, { target_amount: null, current_amount: null, carried_over_amount: null, amounts_hidden: true });
++ if (page.goal_supporters) {
++ out.supporters = db.prepare(`SELECT c.amount - c.reversed_amount AS amount, c.created_at, i.supporter_name, i.anonymous, i.hide_amount, i.erased_at
++ FROM tip_goal_contributions c JOIN tip_interactions i ON i.id = c.interaction_id WHERE c.goal_id = ? AND c.amount > c.reversed_amount
++ ORDER BY c.id DESC LIMIT 10`).all(g.id)
++ .map((c) => ({ name: publicName(c), amount: page.goal_amounts && !c.hide_amount ? c.amount : null, at: c.created_at }));
}
return out;
}
@@@ -146,7 +168,9 @@ function createGoals(ctx) {
.run(g.id, interaction.id, interaction.amount, at, at);
if (!r.changes) return null;
const reachedNow = markReached(g.id);
-- return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by: interaction.supporter_name });
++ // The overlay's goal update names the supporter as the alert does; not when the amount is hidden
++ // (the bar's jump would otherwise pin the amount on them).
++ return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by: interaction.hide_amount ? null : publicName(interaction) });
}
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
@@@ -163,7 +187,7 @@ function createGoals(ctx) {
return out;
}
-- return { get, list, present, create, update, close, contribute, reverse, pick, totalOf };
++ return { get, list, present, publicGoal, create, update, close, contribute, reverse, pick, totalOf };
}
module.exports = { createGoals };
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
iindex 3916bfa..05e9f14 100644
---- a/server/domain/interactions.js
++++ b/server/domain/interactions.js
@@@ -31,9 +31,16 @@
* Settlement creates, in one transaction: the paid message / TTS / media request row, the goal
* contribution, the overlay alert, the delivery effects and tips.interaction.ready. Delivery then
* runs in the effects worker; its failure never touches payment_state.
++ *
++ * Privacy (privacy.js): the supporter's choices are stored with the interaction (anonymous,
++ * hide_amount, private_message); everything that leaves Tips for the public is publicView(), and
++ * events and answers to anyone but the supporter never name an anonymous supporter. A supporter can
++ * export their tips and erase their data from them (erase(): the money record stays, the person
++ * goes; tips.interaction.erased redacts the earlier events in OpenVibe.Events).
*/
const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');
const { VOICES } = require('./profiles');
++const { parsePrivacy, publicName, publicView, privacyOf, isAnonymous } = require('./privacy');
const { BillingCallError } = require('../billing-client');
const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
@@@ -117,15 +124,15 @@ function createInteractions(ctx) {
const r = {
supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: 0, billing_txn_id: null,
billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,
-- target: null, origin: 'tips', created_at: at, ...row,
++ target: null, origin: 'tips', anonymous: 0, hide_amount: 0, private_message: 0, created_at: at, ...row,
};
db.prepare(`INSERT INTO tip_interactions (id, creator_subject, supporter_subject, supporter_name, kind, amount, amount_cents, message, request,
funding, settlement, payment_state, delivery_state, test, billing_txn_id, billing_intent_id, funding_txn_id, provider, provider_ref,
-- legacy_source, idempotency_key, origin, target, created_at, updated_at)
++ legacy_source, idempotency_key, origin, target, anonymous, hide_amount, private_message, created_at, updated_at)
VALUES (@id, @creator_subject, @supporter_subject, @supporter_name, @kind, @amount, @amount_cents, @message, @request,
@funding, @settlement, @payment_state, @delivery_state, @test, @billing_txn_id, @billing_intent_id, @funding_txn_id, @provider, @provider_ref,
-- @legacy_source, @idempotency_key, @origin, @target, @created_at, @created_at)`)
-- .run({ ...r, request: JSON.stringify(r.request || {}), target: r.target ? JSON.stringify(r.target) : null });
++ @legacy_source, @idempotency_key, @origin, @target, @anonymous, @hide_amount, @private_message, @created_at, @created_at)`)
++ .run({ ...r, request: JSON.stringify(r.request || {}), target: r.target ? JSON.stringify(r.target) : null, anonymous: r.anonymous ? 1 : 0, hide_amount: r.hide_amount ? 1 : 0, private_message: r.private_message ? 1 : 0 });
return get(r.id);
}
@@@ -142,6 +149,7 @@ function createInteractions(ctx) {
fail(422, 'tips.amount_too_small', `paying by checkout starts at ${config.billing.minPurchaseBits} Vibes; smaller tips use your Vibes balance`);
}
const target = entityRef(input.target);
++ const privacy = parsePrivacy(input.privacy, v.kind);
return ctx.tx(() => {
if (idempotencyKey) {
const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);
@@@ -151,6 +159,7 @@ function createInteractions(ctx) {
id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',
kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',
delivery_state: 'awaiting_payment', idempotency_key: idempotencyKey || null, target,
++ ...privacy, private_message: privacy.private_message && !!v.message,
});
return { interaction: i, replay: false };
});
@@@ -252,9 +261,8 @@ function createInteractions(ctx) {
}
if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);
-- const view = { message: i.message, tts: req.tts || null, media: req.media ? { url: req.media.url } : null };
if (i.origin !== 'import') {
-- ctx.overlays.addAlert(i, view);
++ ctx.overlays.addAlert(i);
addEffect(i, 'overlay_alert', 'overlay', 'delivered');
}
// Chat effects: never for imports (history), never twice for a donation another product already
@@@ -310,7 +318,7 @@ function createInteractions(ctx) {
if (amount <= 0) return 'ignored:no_amount';
const i = insert({
id: prefixedId('tint', ctx.now()), creator_subject: p.to_subject, supporter_subject: p.from_subject || null,
-- supporter_name: displayName(meta.donor_name) || null, kind: meta.kind === 'paid_interaction' ? 'media_request' : 'tip', amount, amount_cents: meta.paid_cents || null,
++ supporter_name: displayName(meta.donor_name) || null, anonymous: meta.anonymous === true, kind: meta.kind === 'paid_interaction' ? 'media_request' : 'tip', amount, amount_cents: meta.paid_cents || null,
message: meta.message ? String(meta.message).slice(0, config.limits.messageChars) : null,
funding: p.provider ? 'provider' : 'credit', settlement: 'billing', payment_state: 'pending', delivery_state: 'awaiting_payment',
test: txn.test ? 1 : 0, billing_txn_id: txn.id, provider: p.provider || null, origin: 'billing',
@@@ -429,7 +437,7 @@ function createInteractions(ctx) {
const goalId = goalFromPurpose(p.app_purpose, p.app_ref);
const i = insert({
id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,
-- supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), kind: 'tip', amount, amount_cents: cents,
++ supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), anonymous: !!p.anonymous, kind: 'tip', amount, amount_cents: cents,
message, request: goalId ? { goal_id: goalId } : {}, funding: 'external', settlement: 'external', payment_state: 'pending',
delivery_state: 'awaiting_payment', provider, provider_ref: ref, origin: 'billing-external', test: p.test ? 1 : 0,
});
@@@ -446,6 +454,7 @@ function createInteractions(ctx) {
const ref = String(input.provider_ref || '').trim();
if (!ref || ref.length > 200) fail(422, 'tips.invalid_input', 'provider_ref (the provider event id) is required');
const cents = positiveInt(input.amount_cents, 'amount_cents', 100_000_000);
++ const privacy = parsePrivacy(input.privacy, 'tip');
return ctx.tx(() => {
const prev = db.prepare('SELECT * FROM tip_interactions WHERE provider = ? AND provider_ref = ?').get(provider, ref);
if (prev) return { interaction: prev, replay: true };
@@@ -457,6 +466,7 @@ function createInteractions(ctx) {
// origin external: the provider (or Live's webhook) already announced it in chat; with
// announce: true Tips delivers the chat line itself.
provider, provider_ref: ref, origin: input.announce === true ? 'tips' : 'external', test: input.test ? 1 : 0,
++ ...privacy, private_message: privacy.private_message && !!input.message,
});
return { interaction: settle(i), replay: false };
});
@@@ -465,12 +475,13 @@ function createInteractions(ctx) {
/** The full effect path with test = 1 and no Billing call. */
function simulate(profile, input, { by }) {
const v = validate(profile, { ...input, amount: input.amount || Math.max(profile.min_amount, 100) }, { simulation: true });
++ const privacy = parsePrivacy(input.privacy, v.kind);
return ctx.tx(() => {
const i = insert({
id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: null,
supporter_name: displayName(input.supporter_name) || 'Test supporter', kind: v.kind, amount: v.amount, message: v.message,
request: { ...v.request, simulated_by: by }, funding: 'none', settlement: 'simulated', payment_state: 'pending',
-- delivery_state: 'awaiting_payment', test: 1,
++ delivery_state: 'awaiting_payment', test: 1, ...privacy, private_message: privacy.private_message && !!v.message,
});
const out = settle(i);
// Show the goal widget moving without counting anything: a test goal delivery with the
@@@ -479,29 +490,39 @@ function createInteractions(ctx) {
if (g) {
const view = ctx.goals.present(g);
const would = { ...view, current_amount: view.current_amount + v.amount, percent: Math.min(100, Math.floor(((view.current_amount + v.amount) * 100) / view.target_amount)) };
-- ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: i.supporter_name, dedupe: `goal:${g.id}:sim:${i.id}`, test: true });
++ ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: i.hide_amount ? null : publicName(i), dedupe: `goal:${g.id}:sim:${i.id}`, test: true });
}
return out;
});
}
// ── Reads ────────────────────────────────────────────────
++ /** The event payload (tips.interaction.*): an anonymous supporter is never named, not even by subject. */
function summary(i) {
++ const anon = isAnonymous(i);
return {
-- interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,
-- supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, settlement: i.settlement,
++ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject && !anon ? { type: 'user', id: i.supporter_subject } : null,
++ supporter_name: anon ? publicName(i) : (i.supporter_name || null), kind: i.kind, amount: i.amount, currency: i.currency, settlement: i.settlement,
payment_state: i.payment_state, delivery_state: i.delivery_state, billing_txn_id: i.billing_txn_id || null, test: !!i.test,
};
}
++ /**
++ * viewer: 'supporter' (their own receipt: everything they chose), 'owner' (the creator) or
++ * 'service'. Anyone but the supporter gets no subject and "Anonymous" for an anonymous supporter;
++ * `public` is what may be shown to the public, `privacy` the supporter's choices.
++ */
function present(i, { viewer = 'owner' } = {}) {
if (!i) return null;
const req = json(i.request, {});
++ const own = viewer === 'supporter';
++ const named = own || !isAnonymous(i);
const out = {
-- id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,
-- supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,
++ id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject && named ? { type: 'user', id: i.supporter_subject } : null,
++ supporter_name: named ? (i.supporter_name || null) : publicName(i), kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,
tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null, goal_id: req.goal_id || null,
funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,
++ privacy: privacyOf(i), public: publicView(i), erased_at: i.erased_at || null,
payment: { state: i.payment_state, billing_txn_id: i.billing_txn_id || null, reversal_txn_ids: json(i.reversal_txn_ids, []), reversed_amount: i.reversed_bits, failure: i.failure || null, settled_at: i.settled_at || null, reversed_at: i.reversed_at || null },
delivery: { state: i.delivery_state, delivered_at: i.delivered_at || null },
created_at: i.created_at, updated_at: i.updated_at,
@@@ -529,6 +550,101 @@ function createInteractions(ctx) {
return { rows: page, next_cursor: next };
}
++ // ── The public supporters page ───────────────────────────
++ // Only settled, non-test interactions of supporters who kept both their name and their amount
++ // public count toward the leaderboard (a hidden amount must not be inferable from a rank).
++ const leaderboardWhere = (t) => `${t}.creator_subject = @c AND ${t}.supporter_subject IS NOT NULL AND ${t}.test = 0
++ AND ${t}.payment_state IN ('settled', 'reversed') AND ${t}.anonymous = 0 AND ${t}.hide_amount = 0 AND ${t}.erased_at IS NULL`;
++
++ /** Top supporters: [{ rank, name, total, tips }] (name = the latest name they tipped under). */
++ function leaderboard(creator, { limit = 20 } = {}) {
++ const rows = db.prepare(`SELECT t.supporter_subject AS s, SUM(t.amount - t.reversed_bits) AS total, COUNT(*) AS tips, MIN(t.created_at) AS first_at,
++ (SELECT j.supporter_name FROM tip_interactions j WHERE ${leaderboardWhere('j')} AND j.supporter_subject = t.supporter_subject
++ ORDER BY j.created_at DESC LIMIT 1) AS name
++ FROM tip_interactions t WHERE ${leaderboardWhere('t')}
++ GROUP BY t.supporter_subject HAVING total > 0 ORDER BY total DESC, first_at LIMIT @n`).all({ c: creator, n: Math.min(100, Math.max(1, limit)) });
++ return rows.map((r, k) => ({ rank: k + 1, name: r.name || 'Someone', total: r.total, tips: r.tips }));
++ }
++
++ /** Recent settled interactions with a public message, as publicView(). */
++ function recentPublic(creator, { limit = 20 } = {}) {
++ return db.prepare(`SELECT * FROM tip_interactions WHERE creator_subject = ? AND test = 0 AND payment_state = 'settled' AND private_message = 0
++ AND erased_at IS NULL AND message IS NOT NULL ORDER BY settled_at DESC, id DESC LIMIT ?`).all(creator, Math.min(100, Math.max(1, limit)))
++ .map((i) => publicView(i));
++ }
++
++ // ── A supporter's own data ───────────────────────────────
++ /** Everything Tips holds about a supporter's tips (their own view), for a download. */
++ function exportFor(subject) {
++ const rows = db.prepare('SELECT * FROM tip_interactions WHERE supporter_subject = ? ORDER BY created_at').all(subject);
++ return {
++ service: 'tips', subject: { type: 'user', id: subject }, exported_at: iso(ctx.now()),
++ interactions: rows.map((i) => ({
++ ...present(i, { viewer: 'supporter' }),
++ goal_contributions: db.prepare('SELECT goal_id, amount, reversed_amount, created_at FROM tip_goal_contributions WHERE interaction_id = ?').all(i.id),
++ paid_message: db.prepare('SELECT kind, text, voice, highlight_seconds, status, created_at, updated_at FROM paid_messages WHERE interaction_id = ?').get(i.id) || null,
++ media_request: db.prepare('SELECT url, provider, status, created_at, updated_at FROM paid_media_requests WHERE interaction_id = ?').get(i.id) || null,
++ })),
++ not_included: 'Payments, balances and refunds are OpenVibe.Billing\'s records; ask Billing for them.',
++ };
++ }
++
++ /**
++ * Erase a supporter's data from their tips. The money record stays (amount, creator, Billing
++ * transaction, goal contribution: Billing's books and the creator's totals must still reconcile);
++ * the person goes: subject, name, message, TTS text, media link, checkout reference, stored API
++ * answers, overlay payloads and the unsent events that named them. Each erased interaction emits
++ * tips.interaction.erased, whose `redacts` has OpenVibe.Events tombstone the earlier events about
++ * it. Interactions still waiting for their payment are kept (the payment needs the supporter) and
++ * counted in kept_pending.
++ */
++ function erase(subject) {
++ return ctx.tx(() => {
++ const rows = db.prepare('SELECT * FROM tip_interactions WHERE supporter_subject = ?').all(subject);
++ const at = iso(ctx.now());
++ let erased = 0;
++ for (const i of rows) {
++ if (i.payment_state === 'pending') continue;
++ const req = json(i.request, {});
++ const keep = {};
++ if (req.goal_id) keep.goal_id = req.goal_id;
++ if (req.highlight_seconds) keep.highlight_seconds = req.highlight_seconds;
++ db.prepare(`UPDATE tip_interactions SET supporter_subject = NULL, supporter_name = NULL, anonymous = 1, message = NULL, request = ?,
++ idempotency_key = NULL, checkout_url = NULL, checkout_ref = NULL, erased_at = ?, updated_at = ? WHERE id = ?`)
++ .run(JSON.stringify(keep), at, at, i.id);
++ db.prepare("UPDATE paid_messages SET text = '', voice = NULL, updated_at = ? WHERE interaction_id = ?").run(at, i.id);
++ db.prepare("UPDATE paid_media_requests SET url = '', updated_at = ? WHERE interaction_id = ?").run(at, i.id);
++ const now = get(i.id);
++ ctx.overlays.refreshInteraction(now);
++ scrubOutbox(now);
++ if (!i.test) {
++ ctx.outbox.emit('tips.interaction.erased', { type: 'interaction', id: i.id }, {
++ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, erased_at: at,
++ redacts: { subject_type: 'interaction', subject_ids: [i.id] },
++ });
++ }
++ erased++;
++ }
++ // Stored API answers (Idempotency-Key replays) carry messages and the subject.
++ const prefix = `${subject}:`;
++ const answers = db.prepare('DELETE FROM api_idempotency WHERE substr(key, 1, ?) = ? OR instr(response, ?) > 0').run(prefix.length, prefix, subject).changes;
++ if (erased) ctx.afterCommit(() => ctx.outboxKick());
++ return { erased, kept_pending: rows.length - erased, stored_answers_removed: answers };
++ });
++ }
++
++ /** Events about an erased interaction that are still in the local outbox lose the supporter's name. */
++ function scrubOutbox(i) {
++ const rows = db.prepare("SELECT id, envelope FROM event_outbox WHERE json_extract(envelope, '$.subject.type') = 'interaction' AND json_extract(envelope, '$.subject.id') = ?").all(i.id);
++ for (const r of rows) {
++ const env = JSON.parse(r.envelope);
++ if (!env.payload || !('supporter' in env.payload)) continue;
++ env.payload.supporter = null;
++ env.payload.supporter_name = publicName(i);
++ db.prepare('UPDATE event_outbox SET envelope = ? WHERE id = ?').run(JSON.stringify(env), r.id);
++ }
++ }
++
/**
* A creator's totals, derived from settled interactions. `billing` is what must equal Billing's
* books (settled through Billing or imported from Live's ledger, minus what Billing took back);
@@@ -548,6 +664,7 @@ function createInteractions(ctx) {
return {
get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
onBillingSettled, onBillingReversed, onBillingExternal, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
++ leaderboard, recentPublic, exportFor, erase,
};
}
ddiff --git a/server/domain/overlays.js b/server/domain/overlays.js
iindex 678dd06..c12c26a 100644
---- a/server/domain/overlays.js
++++ b/server/domain/overlays.js
@@@ -14,10 +14,16 @@
* delivery window becomes `failed` (tips.overlay.failed). A reconnect with Last-Event-ID
* REPLAYS rows after that id: replay only writes bytes to a socket — it never charges, never
* re-counts a goal, never emits another event. Simulated (test) rows are flagged and emit nothing.
++ *
++ * An alert's payload is the interaction's publicView() (privacy.js): an anonymous supporter reads
++ * "Anonymous", a hidden amount is null, a private message is absent. refreshInteraction() rewrites
++ * the stored payloads when that view changes (an erasure), so a replay or /state never shows the
++ * old one.
*/
const crypto = require('crypto');
const { fail, iso, prefixedId, sha256, json, text } = require('../util');
const { safeUrl } = require('./profiles');
++const { publicView, publicName } = require('./privacy');
const SCOPES = ['alerts', 'goals'];
const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;
@@@ -155,15 +161,23 @@ function createOverlays(ctx) {
}
/** Inside the settlement transaction (or a simulation). */
-- function addAlert(interaction, view) {
-- const payload = {
-- interaction_id: interaction.id, kind: interaction.kind, amount: interaction.amount, currency: interaction.currency,
-- supporter_name: interaction.supporter_name || 'Someone', message: view.message, tts: view.tts || null,
-- media: view.media || null, settlement: interaction.settlement, test: !!interaction.test, at: iso(ctx.now()),
-- };
++ function addAlert(interaction) {
++ const payload = publicView(interaction, { at: iso(ctx.now()) });
return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
}
++ /**
++ * Inside a transaction: the interaction's public view changed. Its alert payload is rewritten, and
++ * the goal updates it caused no longer name anyone.
++ */
++ function refreshInteraction(interaction) {
++ for (const row of db.prepare('SELECT seq, kind, payload FROM overlay_deliveries WHERE interaction_id = ?').all(interaction.id)) {
++ const old = json(row.payload, {});
++ const payload = row.kind === 'alert' ? publicView(interaction, { at: old.at }) : { ...old, by: interaction.hide_amount ? null : publicName(interaction) };
++ db.prepare('UPDATE overlay_deliveries SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
++ }
++ }
++
function addGoalDelivery(creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
return insertDelivery(creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
}
@@@ -216,7 +230,11 @@ function createOverlays(ctx) {
client.lastSeq = Math.max(client.lastSeq, row.seq);
if (!client.scopes.includes(scopeOf(row.kind))) return;
const payload = json(row.payload, {});
-- if (row.kind === 'alert' && client.minAmount && payload.amount < client.minAmount && !row.test) return;
++ if (row.kind === 'alert' && client.minAmount && !row.test) {
++ // A hidden amount is not in the payload; the threshold still applies to the real one.
++ const amount = payload.amount != null ? payload.amount : (db.prepare('SELECT amount FROM tip_interactions WHERE id = ?').get(row.interaction_id) || {}).amount;
++ if (amount < client.minAmount) return;
++ }
if (!write(client, row.kind, row.seq, { delivery_id: row.id, seq: row.seq, ...payload })) return;
db.prepare('UPDATE overlay_deliveries SET sends = sends + 1 WHERE seq = ?').run(row.seq);
if (row.status === 'pending') markDelivered(row);
@@@ -299,7 +317,7 @@ function createOverlays(ctx) {
return {
SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
getConfig, listConfigs, createConfig, updateConfig, presentConfig,
-- addAlert, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
++ addAlert, refreshInteraction, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
};
}
ddiff --git a/server/domain/privacy.js b/server/domain/privacy.js
nnew file mode 100644
iindex 0000000..deada96
---- /dev/null
++++ b/server/domain/privacy.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++/**
++ * What a supporter lets the public see of their interaction, and the one public shape of it.
++ *
++ * A supporter chooses per interaction (the tip form, or `privacy` on the API):
++ *
++ * anonymous their name reads "Anonymous" to everyone but themselves: the creator, moderators,
++ * overlays, chat lines, public pages, other products and events. Nobody but the
++ * supporter gets their subject from Tips for it (Billing, which moved the money,
++ * has its own record).
++ * hide_amount the amount is left out of overlays, chat lines and public pages, and the
++ * interaction stays off the supporters leaderboard. The creator still sees it
++ * (it is their money), internal events still carry it (goals and reconciliation
++ * need it), and a public goal bar still moves by it.
++ * private_message a tip's message is for the creator only: never on an overlay, in chat or on a
++ * public page. Paid messages, text-to-speech and media requests are shown on
++ * stream by definition, so they cannot be private.
++ *
++ * An interaction the supporter erased (erased_at) reads like an anonymous one with no message.
++ *
++ * publicView() is the only shape that leaves Tips for the public: overlay alerts, chat jobs, the
++ * supporters and goals pages, and the `public` block of API answers.
++ */
++const { fail, json } = require('../util');
++
++const ANONYMOUS = 'Anonymous';
++const FLAGS = ['anonymous', 'hide_amount', 'private_message'];
++const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
++
++/** The `privacy` object of a request → { anonymous, hide_amount, private_message } (booleans). */
++function parsePrivacy(v, kind) {
++ if (v != null && (typeof v !== 'object' || Array.isArray(v))) fail(422, 'tips.invalid_input', 'privacy must be an object { anonymous, hide_amount, private_message }');
++ const src = v || {};
++ const unknown = Object.keys(src).filter((k) => !FLAGS.includes(k));
++ if (unknown.length) fail(422, 'tips.invalid_input', `privacy has no field ${unknown[0]} (only ${FLAGS.join(', ')})`);
++ const out = { anonymous: truthy(src.anonymous), hide_amount: truthy(src.hide_amount), private_message: truthy(src.private_message) };
++ if (out.private_message && kind && kind !== 'tip') {
++ fail(422, 'tips.invalid_input', 'paid messages, text-to-speech and media requests are shown on stream; send a tip to keep a message private');
++ }
++ return out;
++}
++
++const isAnonymous = (i) => !!(i.anonymous || i.erased_at);
++const privacyOf = (i) => ({ anonymous: isAnonymous(i), hide_amount: !!i.hide_amount, private_message: !!i.private_message });
++
++/** The name anyone but the supporter sees. */
++function publicName(i) {
++ if (isAnonymous(i)) return ANONYMOUS;
++ return i.supporter_name || 'Someone';
++}
++
++/**
++ * The public shape of an interaction. `at` is kept from an earlier overlay payload when given, so a
++ * rewritten alert keeps its time.
++ */
++function publicView(i, { at } = {}) {
++ const req = json(i.request, {});
++ const privateMsg = !!i.private_message || !!i.erased_at;
++ return {
++ interaction_id: i.id,
++ kind: i.kind,
++ amount: i.hide_amount ? null : i.amount,
++ amount_hidden: !!i.hide_amount,
++ currency: i.currency,
++ supporter_name: publicName(i),
++ message: privateMsg ? null : (i.message || null),
++ tts: i.kind === 'tts' && req.tts && !i.erased_at ? { text: req.tts.text, voice: req.tts.voice } : null,
++ media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,
++ settlement: i.settlement,
++ test: !!i.test,
++ at: at || i.settled_at || i.created_at,
++ };
++}
++
++module.exports = { ANONYMOUS, FLAGS, parsePrivacy, publicName, publicView, privacyOf, isAnonymous, truthy };
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex 7fc6d94..fe534fd 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -7,8 +7,16 @@
*
* page_enabled is the creator's switch for the public page at openvibe.tips/<handle>: while it is
* off the page answers 404 to everyone else and nothing about it is indexable.
++ *
++ * page_settings (JSON, `page` in the API) is what the public pages show, each supporter's own
++ * privacy choices applying on top (privacy.js):
++ * goal_amounts goal amounts (off: a goal shows its percentage only)
++ * goal_supporters the latest supporters of each goal
++ * supporters_page openvibe.tips/<handle>/supporters: the top supporters
++ * supporters_amounts each top supporter's total, and amounts in the recent list
++ * supporters_messages recent public messages on the supporters page
*/
--const { fail, iso, text } = require('../util');
++const { fail, iso, text, json } = require('../util');
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
@@@ -21,10 +29,19 @@ function normalizeHandle(v) {
return HANDLE_RE.test(h) && !RESERVED.has(h) ? h : null;
}
++const PAGE_DEFAULTS = Object.freeze({ goal_amounts: true, goal_supporters: false, supporters_page: false, supporters_amounts: false, supporters_messages: false });
++
++function pageSettings(raw) {
++ const stored = json(raw, {});
++ const out = {};
++ for (const k of Object.keys(PAGE_DEFAULTS)) out[k] = typeof stored[k] === 'boolean' ? stored[k] : PAGE_DEFAULTS[k];
++ return out;
++}
++
function parse(row) {
if (!row) return null;
const b = (k) => !!row[k];
-- return { ...row, page_enabled: b('page_enabled'), accepting: b('accepting'), tts_enabled: b('tts_enabled'), media_requests_enabled: b('media_requests_enabled') };
++ return { ...row, page_enabled: b('page_enabled'), accepting: b('accepting'), tts_enabled: b('tts_enabled'), media_requests_enabled: b('media_requests_enabled'), page: pageSettings(row.page_settings) };
}
function createProfiles(ctx) {
@@@ -89,6 +106,14 @@ function createProfiles(ctx) {
set.tts_voice = String(patch.tts_voice);
}
if (patch.headline !== undefined) set.headline = text(patch.headline, 'headline', 280);
++ if (patch.page !== undefined) {
++ if (!patch.page || typeof patch.page !== 'object' || Array.isArray(patch.page)) fail(422, 'tips.invalid_input', `page must be an object of ${Object.keys(PAGE_DEFAULTS).join(', ')}`);
++ const unknown = Object.keys(patch.page).filter((k) => !(k in PAGE_DEFAULTS));
++ if (unknown.length) fail(422, 'tips.invalid_input', `page has no setting ${unknown[0]}`);
++ const next = { ...p.page };
++ for (const k of Object.keys(PAGE_DEFAULTS)) if (patch.page[k] !== undefined) next[k] = patch.page[k] === true || patch.page[k] === 'on' || patch.page[k] === '1' || patch.page[k] === 1;
++ set.page_settings = JSON.stringify(next);
++ }
if (patch.display_name !== undefined) {
const n = text(patch.display_name, 'display_name', 80);
if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
@@@ -110,6 +135,8 @@ function createProfiles(ctx) {
minimums: { tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null, media_request: p.media_requests_enabled ? p.media_request_min : null },
tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
++ page: p.page,
++ supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
currency: 'vibes-bits',
};
if (owner) Object.assign(out, { revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
@@@ -124,4 +151,4 @@ function safeUrl(u) {
try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.toString().slice(0, 500) : null; } catch { return null; }
}
--module.exports = { createProfiles, normalizeHandle, RESERVED, VOICES, safeUrl };
++module.exports = { createProfiles, normalizeHandle, RESERVED, VOICES, safeUrl, PAGE_DEFAULTS, pageSettings };
ddiff --git a/server/index.js b/server/index.js
iindex db97ea4..5312f60 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,7 +6,8 @@
*
* Background jobs (TIPS_JOBS=off disables them): the effects worker (chat delivery with retries),
* due Billing transfers (funded checkouts, retries after Billing was unreachable), the overlay
-- * delivery window (pending → failed), and the events outbox relay when EVENTS_URL is set.
++ * delivery window (pending → failed), stored API answers older than a week, and the events outbox
++ * relay when EVENTS_URL is set.
*/
const { loadConfig } = require('./config');
const { createApp } = require('./app');
@@@ -23,6 +24,7 @@ if (config.jobs.enabled) {
every(Math.max(config.jobs.intervalMs, 5000), () => domain.interactions.processDueTransfers());
every(30_000, () => domain.overlays.sweepFailed());
every(6 * 3600 * 1000, () => outbox.outbox.prune());
++ every(6 * 3600 * 1000, () => require('./api/idempotency').pruneAnswers(domain.db));
outbox.start();
}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex c6593ac..b3790db 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -18,12 +18,18 @@ function hidden(fields) {
return Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
}
++/** A goal: full (the dashboard) or its public shape (amounts null when the creator hides them). */
function goalCard(g) {
++ const amounts = g.current_amount != null;
++ const bar = amounts ? `aria-valuemin="0" aria-valuemax="${g.target_amount}" aria-valuenow="${g.current_amount}"` : `aria-valuemin="0" aria-valuemax="100" aria-valuenow="${g.percent}"`;
++ const supporters = Array.isArray(g.supporters) && g.supporters.length
++ ? `<ul class="supporters">${g.supporters.map((x) => `<li>${esc(x.name)}${x.amount != null ? ` <small>${n(x.amount)} Vibes</small>` : ''}</li>`).join('')}</ul>` : '';
return `<article class="goal${g.reached ? ' reached' : ''}">
<h3>${esc(g.title)}</h3>
${g.description ? `<p>${esc(g.description)}</p>` : ''}
-- <div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="${g.target_amount}" aria-valuenow="${g.current_amount}"><span style="width:${g.percent}%"></span></div>
-- <p class="meta">${n(g.current_amount)} of ${n(g.target_amount)} Vibes · ${g.percent}%${g.reached ? ' · reached' : ''}${g.status === 'closed' ? ' · closed' : ''}</p>
++ <div class="bar" role="progressbar" ${bar}><span style="width:${g.percent}%"></span></div>
++ <p class="meta">${amounts ? `${n(g.current_amount)} of ${n(g.target_amount)} Vibes · ` : ''}${g.percent}%${g.reached ? ' · reached' : ''}${g.status === 'closed' ? ' · closed' : ''}</p>
++ ${supporters}
</article>`;
}
@@@ -73,8 +79,13 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
${providers.length ? `<label><input type="radio" name="pay_with" value="checkout"${values.pay_with === 'checkout' ? ' checked' : ''}> Checkout (${providers.map(esc).join(', ')}) <small>buys exactly these Vibes and gives them</small></label>` : ''}
</fieldset>
<label>Show my name as <input type="text" name="supporter_name" maxlength="80" value="${esc(values.supporter_name || (viewer ? viewer.name || viewer.username || '' : ''))}"></label>
++ <fieldset class="privacy"><legend>Privacy</legend>
++ <label><input type="checkbox" name="anonymous" value="1"${values.anonymous ? ' checked' : ''}> Send anonymously <small>(everyone, ${esc(profile.display_name)} included, sees "Anonymous")</small></label>
++ <label><input type="checkbox" name="hide_amount" value="1"${values.hide_amount ? ' checked' : ''}> Hide the amount <small>(${esc(profile.display_name)} still sees it; a goal bar still moves by it)</small></label>
++ <label class="when-tip"><input type="checkbox" name="private_message" value="1"${values.private_message ? ' checked' : ''}> Keep my message private <small>(a tip only: ${esc(profile.display_name)} reads it, it is not shown on stream or on this site)</small></label>
++ </fieldset>
<button type="submit" class="btn">${viewer ? 'Send' : 'Sign in to send'}</button>
-- <p class="fine">Tips are final once paid. Your receipt shows the payment and delivery status separately.</p>
++ <p class="fine">Tips are final once paid. Your receipt shows the payment and delivery status separately. You can download or erase your tips data from your <a href="/receipts">receipts</a>.</p>
</form>`;
}
@@@ -84,7 +95,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<section class="creator-head">
${profile.avatar_url ? `<img class="avatar" src="${esc(profile.avatar_url)}" alt="" width="96" height="96">` : ''}
<div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}
-- <p class="links"><a href="https://openvibe.live/${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a></p></div>
++ <p class="links"><a href="https://openvibe.live/${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
</section>
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error })}`;
@@@ -94,7 +105,22 @@ function goalsPage({ profile, goals }) {
return `<h1>${esc(profile.display_name)} — goals</h1>
<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}
--<p class="fine">Goal totals count settled payments only.</p>`;
++<p class="fine">Goal totals count settled payments only.${profile.page.goal_supporters ? ' Supporters are listed as they chose to be shown.' : ''}</p>`;
++}
++
++/** openvibe.tips/<handle>/supporters: what the creator chose to show, as each supporter allowed. */
++function supportersPage({ profile, leaderboard, recent }) {
++ const page = profile.page;
++ const board = leaderboard.length
++ ? `<ol class="leaderboard">${leaderboard.map((r) => `<li><span>${esc(r.name)}</span>${r.total != null ? ` <small>${n(r.total)} Vibes</small>` : ''}</li>`).join('')}</ol>`
++ : '<p>No supporters to show yet.</p>';
++ const wall = recent && recent.length
++ ? `<section><h2>Recent messages</h2><ul class="wall">${recent.map((v) => `<li><b>${esc(v.supporter_name)}</b>${v.amount != null ? ` <small>${n(v.amount)} Vibes</small>` : ''}<p>${esc(v.message)}</p><small>${when(v.at)}</small></li>`).join('')}</ul></section>` : '';
++ return `<h1>${esc(profile.display_name)} — supporters</h1>
++<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
++<section><h2>Top supporters</h2>${board}</section>
++${page.supporters_messages ? wall || '<p>No public messages yet.</p>' : ''}
++<p class="fine">Anonymous tips and tips with a hidden amount are not on this list.</p>`;
}
function receiptRow(i, as) {
@@@ -102,10 +128,34 @@ function receiptRow(i, as) {
return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;
}
--function receiptsPage({ rows, next }) {
++function privacyLine(p) {
++ if (!p) return '';
++ const parts = [p.anonymous && 'sent anonymously', p.hide_amount && 'amount hidden', p.private_message && 'message private'].filter(Boolean);
++ return parts.length ? esc(parts.join(' · ')) : '';
++}
++
++function receiptsPage({ rows, next, flash }) {
return `<h1>Your receipts</h1>
++${flash ? `<p class="notice">${esc(flash)}</p>` : ''}
${rows.length ? `<table class="list"><thead><tr><th>When</th><th>Creator</th><th>What</th><th class="num">Vibes</th><th>Payment</th><th>Delivery</th></tr></thead><tbody>${rows.map((r) => receiptRow(r, 'supporter')).join('')}</tbody></table>` : '<p>No tips yet.</p>'}
--${next ? `<p><a href="/receipts?cursor=${esc(next)}">Older</a></p>` : ''}`;
++${next ? `<p><a href="/receipts?cursor=${esc(next)}">Older</a></p>` : ''}
++<section class="card"><h2>Your data</h2>
++ <p><a class="btn ghost" href="/receipts/export">Download your tips (JSON)</a></p>
++ <p><a href="/receipts/erase">Erase your data from your tips</a> <small>— your name and messages are removed; the payment record stays with the amount, because Billing's books and the creators' totals must add up.</small></p>
++</section>`;
++}
++
++function erasePage({ csrf, idem, pending, count }) {
++ return `<h1>Erase your data from your tips</h1>
++<section class="card">
++ <p>This removes you from the ${n(count)} tip${count === 1 ? '' : 's'} you sent on OpenVibe.Tips: your account, the name you showed, your messages, text-to-speech text and media links. They then read "Anonymous" everywhere, including overlays, and you no longer see them in your receipts. It cannot be undone.</p>
++ <p>What stays: the amount, the creator, the date and the OpenVibe.Billing reference of each payment, because Billing's books and the creators' totals must still add up. Billing keeps its own payment records; ask Billing about those.</p>
++ ${pending ? `<p class="notice">${n(pending)} tip${pending === 1 ? ' is' : 's are'} still waiting for payment and will be kept until the payment settles or fails; erase again afterwards.</p>` : ''}
++ <form method="post" action="/receipts/erase">${hidden({ csrf, idem })}
++ <label><input type="checkbox" name="confirm" value="1" required> I understand this cannot be undone</label>
++ <button class="btn" type="submit">Erase my data</button>
++ </form>
++</section>`;
}
function receiptPage({ i, profile, as, cancelled }) {
@@@ -116,6 +166,7 @@ ${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was c
<dt>Creator</dt><dd>${profile ? `<a href="/${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>
<dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd>
<dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>
++ ${privacyLine(i.privacy) ? `<dt>Privacy</dt><dd>${privacyLine(i.privacy)}</dd>` : ''}
<dt>Amount</dt><dd>${n(i.amount)} Vibes</dd>
${i.message ? `<dt>Message</dt><dd>${esc(i.message)}</dd>` : ''}
${i.tts ? `<dt>Read out</dt><dd>${esc(i.tts.text)} <small>(${esc(i.tts.voice)})</small></dd>` : ''}
@@@ -158,6 +209,14 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label>
<label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label>
<label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>
++ <fieldset><legend>Public pages</legend>
++ <label><input type="checkbox" name="page_goal_amounts" value="1"${p.page.goal_amounts ? ' checked' : ''}> Goals show their amounts <small>(off: the percentage only)</small></label>
++ <label><input type="checkbox" name="page_goal_supporters" value="1"${p.page.goal_supporters ? ' checked' : ''}> Goals list their latest supporters</label>
++ <label><input type="checkbox" name="page_supporters_page" value="1"${p.page.supporters_page ? ' checked' : ''}> A supporters page with your top supporters</label>
++ <label><input type="checkbox" name="page_supporters_amounts" value="1"${p.page.supporters_amounts ? ' checked' : ''}> Show amounts on the supporters page</label>
++ <label><input type="checkbox" name="page_supporters_messages" value="1"${p.page.supporters_messages ? ' checked' : ''}> Show recent public messages on the supporters page</label>
++ <p class="fine">Each supporter's own choice comes first: anonymous tips read "Anonymous", hidden amounts and private messages are never shown.</p>
++ </fieldset>
<input type="hidden" name="revision" value="${p.revision}">
<button class="btn" type="submit">Save</button>
</form>
@@@ -225,4 +284,4 @@ function errorPage({ status, title, message }) {
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
}
--module.exports = { home, creatorPage, goalsPage, receiptsPage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };
++module.exports = { home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex d1c6843..d70a2e6 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -7,8 +7,11 @@
* GET /dashboard creator dashboard (sign-in; opens the profile on first visit)
* POST /dashboard/… profile, goals, overlay tokens/configs, simulation (anti-forgery token)
* GET /receipts, /receipts/:id the signed-in supporter's receipts (a creator may open theirs too)
++ * GET /receipts/export the supporter's tips as a JSON download
++ * GET|POST /receipts/erase erase the supporter's data from their tips (confirmation, anti-forgery token)
* GET /:handle creator page: goals + tip form (indexable only when the page is on)
-- * GET /:handle/goals goals
++ * GET /:handle/goals goals (as the creator chose to show them)
++ * GET /:handle/supporters top supporters and recent public messages, when the creator shows them
* POST /:handle/tip the no-JS tip form → Billing (credit or checkout)
* GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)
* GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)
@@@ -21,8 +24,10 @@ const { TipsError, json: parseJson } = require('../util');
const { viewerMiddleware } = require('./session');
const pages = require('./pages');
const { esc, asset } = require('./layout');
++const { PAGE_DEFAULTS } = require('../domain/profiles');
const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay']);
++const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
function createWebRoutes({ domain, config, layout, userAuth }) {
const r = express.Router();
@@@ -49,6 +54,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
});
}
const idem = () => `form-${crypto.randomBytes(12).toString('base64url')}`;
++ const bool = (v) => v === '1' || v === 'on' || v === true;
// ── Home, robots, sitemap ────────────────────────────────
r.get('/', withViewer, (req, res) => {
@@@ -71,7 +77,32 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
r.get('/receipts', withViewer, (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts')}`);
const out = interactions.list({ supporter: req.viewer.subject, cursor: req.query.cursor, limit: 50 });
-- html(res, pageFor(req, { title: 'Receipts', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: '/receipts', body: pages.receiptsPage({ rows: out.rows.map(decorate), next: out.next_cursor }) }));
++ const flash = RECEIPT_FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null;
++ html(res, pageFor(req, { title: 'Receipts', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: '/receipts', body: pages.receiptsPage({ rows: out.rows.map(decorate), next: out.next_cursor, flash }) }));
++ });
++ // The supporter's own data: a download, and erasure (privacy.js, interactions.erase()).
++ r.get('/receipts/export', withViewer, (req, res) => {
++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts')}`);
++ const day = new Date(domain.now()).toISOString().slice(0, 10);
++ res.status(200).set({ 'Cache-Control': 'no-store', 'Content-Disposition': `attachment; filename="openvibe-tips-${day}.json"`, 'X-Robots-Tag': 'noindex, nofollow' })
++ .type('application/json').send(`${JSON.stringify(interactions.exportFor(req.viewer.subject), null, 2)}\n`);
++ });
++ function renderErase(req, res, status = 200) {
++ const rows = db.prepare("SELECT payment_state FROM tip_interactions WHERE supporter_subject = ?").all(req.viewer.subject);
++ html(res, pageFor(req, {
++ title: 'Erase your data', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: '/receipts/erase',
++ body: pages.erasePage({ csrf: csrfFor(req.viewer.subject), idem: idem(), count: rows.length, pending: rows.filter((x) => x.payment_state === 'pending').length }),
++ }), status);
++ }
++ r.get('/receipts/erase', withViewer, (req, res) => {
++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
++ renderErase(req, res);
++ });
++ r.post('/receipts/erase', withViewer, form, (req, res) => {
++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
++ if (!csrfOk(req) || !bool(req.body.confirm)) return renderErase(req, res, 403);
++ interactions.erase(req.viewer.subject);
++ return res.redirect(303, `/receipts?done=${encodeURIComponent('Your data was erased from your tips')}`);
});
r.get('/receipts/:id', withViewer, (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
@@@ -127,13 +158,13 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
});
}
-- const bool = (v) => v === '1' || v === 'on';
action('/dashboard/profile', (req, p) => {
const b = req.body;
profiles.update(p.creator_subject, {
page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),
headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,
tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,
++ page: Object.fromEntries(Object.keys(PAGE_DEFAULTS).map((k) => [k, bool(b[`page_${k}`])])),
}, { expectedRevision: b.revision });
return { done: 'Page settings saved' };
});
@@@ -220,7 +251,8 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);
if (!p || (!p.page_enabled && !owner)) return notFound(req, res);
if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path
-- const list = goals.list(p.creator_subject).map((g) => goals.present(g));
++ // The page shows what the public sees, for the creator too (a preview); the dashboard has the rest.
++ const list = goals.list(p.creator_subject).map((g) => goals.publicGoal(g, p.page));
const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';
const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${p.display_name} on ${'OpenVibe.Tips'}`, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];
return html(res, pageFor(req, {
@@@ -238,16 +270,33 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
if (!p.page_enabled && !owner) return notFound(req, res);
html(res, pageFor(req, {
title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',
-- body: pages.goalsPage({ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.present(g)) }),
++ body: pages.goalsPage({ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.publicGoal(g, p.page)) }),
}));
});
++ r.get('/:handle/supporters', withViewer, (req, res, next) => {
++ const p = profiles.byHandle(req.params.handle);
++ if (!p) return next();
++ const owner = req.viewer && req.viewer.subject === p.creator_subject;
++ // Off unless the creator shows it; the creator gets a preview while it is off.
++ if ((!p.page_enabled || !p.page.supporters_page) && !owner) return notFound(req, res);
++ const leaderboard = interactions.leaderboard(p.creator_subject).map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));
++ const recent = p.page.supporters_messages ? interactions.recentPublic(p.creator_subject).map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
++ const indexable = p.page_enabled && p.page.supporters_page;
++ html(res, pageFor(req, {
++ title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: indexable ? 'index,follow' : 'noindex,nofollow',
++ body: pages.supportersPage({ profile: p, leaderboard, recent }),
++ }), 200, indexable ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
++ });
r.post('/:handle/tip', withViewer, form, async (req, res, next) => {
const p = profiles.byHandle(req.params.handle);
if (!p) return next();
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);
const b = req.body || {};
-- const values = { kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with, supporter_name: b.supporter_name };
++ const values = {
++ kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with,
++ supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
++ };
if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
const owner = req.viewer.subject === p.creator_subject;
if (!p.page_enabled && !owner) return notFound(req, res);
@@@ -257,6 +306,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,
tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,
media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
++ privacy: { anonymous: values.anonymous, hide_amount: values.hide_amount, private_message: values.private_message },
};
const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';
const { interaction, replay } = interactions.request(p, input, { supporter: req.viewer.subject, supporterName: b.supporter_name || req.viewer.name, funding, idempotencyKey: key });
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
nnew file mode 100644
iindex 0000000..f921c7c
---- /dev/null
++++ b/test/privacy.test.js
@@@ -0,0 +1,287 @@
++'use strict';
++// Privacy: what a supporter lets the public see (anonymous, hidden amount, private message) holds in
++// the API, overlays, chat lines, public pages and events; what the creator shows on the goals and
++// supporters pages; a supporter's export and erasure of their own tips.
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const registry = require('openvibe-contracts/lib/registry');
++const { boot, check, done } = require('./helpers/app');
++
++/** A payload validator for an event type: the released contract, else the proposal in docs/events-proposal/. */
++function payloadValidator(type) {
++ try { contracts.resolve(type); return (p) => contracts.validate(type, p); } catch { /* not released yet */ }
++ const fn = registry.ajv.compile(require(`../docs/events-proposal/${type}.v1.json`));
++ return (p) => ({ valid: fn(p), errors: fn.errors || [] });
++}
++
++(async () => {
++ const t = await boot();
++ const { domain, billing } = t;
++ const alex = await t.creator('alex');
++ const viewer = t.network.newUser('viewer');
++ const other = t.network.newUser('other');
++ billing.fund(viewer.subject, 50_000);
++ billing.fund(other.subject, 50_000);
++ const goal = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 100_000 } })).json.goal;
++ const tok = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['alerts', 'goals'] } });
++ const secret = tok.json.secret;
++ const tip = (user, body) => t.call('POST', '/api/v1/checkout', { user, body: { creator: 'alex', ...body } });
++ const alertOf = async (id) => (await t.call('GET', `/overlay/${secret}/state`, { token: null })).json.alerts.find((a) => a.interaction_id === id);
++ const jobsOf = (id) => t.adapters.test.jobs.filter((j) => j.interaction.id === id);
++ const eventsOf = (type, id) => t.outboxRows(type).filter((e) => e.subject.id === id);
++
++ let anonId;
++ await check('anonymous: "Anonymous" to the creator, overlays, chat and events; the supporter keeps their receipt', async () => {
++ const r = await tip(viewer, { amount: 120, message: 'from nobody', supporter_name: 'Viewer Real Name', privacy: { anonymous: true } });
++ assert.strictEqual(r.status, 201, r.text);
++ anonId = r.json.interaction.id;
++ assert.strictEqual(r.json.interaction.supporter.id, viewer.subject, 'the supporter sees themselves');
++ assert.strictEqual(r.json.interaction.supporter_name, 'Viewer Real Name');
++ assert.deepStrictEqual(r.json.interaction.privacy, { anonymous: true, hide_amount: false, private_message: false });
++ assert.strictEqual(r.json.interaction.public.supporter_name, 'Anonymous');
++
++ const s = await t.sse(`/overlay/${secret}/events`);
++ const a = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === anonId);
++ s.close();
++ assert.strictEqual(a.data.supporter_name, 'Anonymous');
++ assert.strictEqual(a.data.message, 'from nobody');
++ assert.ok(!JSON.stringify(a.data).includes('Viewer Real Name') && !JSON.stringify(a.data).includes(viewer.subject));
++ const g = t.db.prepare("SELECT payload FROM overlay_deliveries WHERE kind = 'goal' AND interaction_id = ?").get(anonId);
++ assert.strictEqual(JSON.parse(g.payload).by, 'Anonymous');
++
++ await domain.effects.drain();
++ const [job] = jobsOf(anonId);
++ assert.deepStrictEqual(job.supporter, { name: 'Anonymous', subject: null });
++ assert.strictEqual(job.text, 'Anonymous tipped 120 Vibes: from nobody');
++
++ const [ready] = eventsOf('tips.interaction.ready', anonId);
++ assert.strictEqual(ready.payload.supporter, null);
++ assert.strictEqual(ready.payload.supporter_name, 'Anonymous');
++ assert.ok(contracts.validate('tips.interaction.ready', ready.payload).valid);
++ assert.ok(!JSON.stringify(t.outboxRows()).includes('Viewer Real Name'), 'no event anywhere carries the name');
++
++ for (const as of [{ user: alex }, { cap: ['tips.interaction.get'] }]) {
++ const v = (await t.call('GET', `/api/v1/interactions/${anonId}`, as)).json.interaction;
++ assert.strictEqual(v.supporter, null);
++ assert.strictEqual(v.supporter_name, 'Anonymous');
++ assert.ok(!JSON.stringify(v).includes(viewer.subject) && !JSON.stringify(v).includes('Viewer Real Name'));
++ }
++ const goalView = (await t.call('GET', `/api/v1/goals/${goal.id}`, { user: alex })).json.goal;
++ assert.strictEqual(goalView.contributions.find((c) => c.interaction_id === anonId).supporter_name, 'Anonymous');
++ const dash = await fetch(`${t.base}/dashboard`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((x) => x.text());
++ assert.ok(!dash.includes('Viewer Real Name'), 'the creator dashboard does not name them');
++ });
++
++ let hiddenId;
++ await check('a hidden amount: not on overlays or in chat lines; the creator and internal events keep it', async () => {
++ const r = await tip(viewer, { amount: 222, message: 'keep it quiet', supporter_name: 'Viewer', privacy: { hide_amount: true } });
++ hiddenId = r.json.interaction.id;
++ const a = await alertOf(hiddenId);
++ assert.strictEqual(a.amount, null);
++ assert.strictEqual(a.amount_hidden, true);
++ assert.strictEqual(a.supporter_name, 'Viewer');
++ assert.strictEqual(JSON.parse(t.db.prepare("SELECT payload FROM overlay_deliveries WHERE kind = 'goal' AND interaction_id = ?").get(hiddenId).payload).by, null,
++ 'the goal update does not pin the jump on them');
++ await domain.effects.drain();
++ const [job] = jobsOf(hiddenId);
++ assert.strictEqual(job.text, 'Viewer sent a tip: keep it quiet');
++ assert.strictEqual(job.interaction.amount, null);
++ assert.strictEqual(job.privacy.hide_amount, true);
++ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${hiddenId}`, { user: alex })).json.interaction.amount, 222);
++ assert.strictEqual(eventsOf('tips.interaction.ready', hiddenId)[0].payload.amount, 222);
++ });
++
++ await check('an overlay\'s minimum still applies to a hidden amount (without revealing it)', async () => {
++ const cfg = await t.call('POST', '/api/v1/overlay-configs', { user: alex, body: { kind: 'alerts', min_amount: 100 } });
++ const t2 = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['alerts'], config_id: cfg.json.config.id } });
++ const s = await t.sse(`/overlay/${t2.json.secret}/events`);
++ await s.waitFor((e) => e.event === 'hello');
++ const small = await tip(viewer, { amount: 10, privacy: { hide_amount: true } });
++ const big = await tip(viewer, { amount: 150, privacy: { hide_amount: true } });
++ const shown = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === big.json.interaction.id);
++ assert.strictEqual(shown.data.amount, null);
++ assert.ok(!s.events.some((e) => e.event === 'alert' && e.data.interaction_id === small.json.interaction.id), 'below the minimum');
++ s.close();
++ });
++
++ let privateId;
++ await check('a private message reaches the creator only; paid messages cannot be private', async () => {
++ const r = await tip(viewer, { amount: 50, message: 'just for you', supporter_name: 'Viewer', privacy: { private_message: true } });
++ privateId = r.json.interaction.id;
++ assert.strictEqual((await alertOf(privateId)).message, null);
++ await domain.effects.drain();
++ assert.strictEqual(jobsOf(privateId)[0].text, 'Viewer tipped 50 Vibes');
++ assert.strictEqual(jobsOf(privateId)[0].interaction.message, null);
++ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${privateId}`, { user: alex })).json.interaction.message, 'just for you');
++ const pm = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 200, message: 'x', privacy: { private_message: true } } });
++ assert.strictEqual(pm.status, 422);
++ const bad = await tip(viewer, { amount: 5, privacy: { secret: true } });
++ assert.strictEqual(bad.status, 422);
++ });
++
++ await check('the creator chooses what the goal pages show; each supporter\'s choice still applies', async () => {
++ await tip(other, { amount: 300, supporter_name: 'Other', goal_id: goal.id });
++ let pub = (await t.call('GET', `/api/v1/goals/${goal.id}`, { token: null })).json.goal;
++ assert.ok(pub.current_amount > 0 && pub.supporters === undefined && pub.contributions === undefined);
++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { goal_amounts: false, goal_supporters: true } } });
++ pub = (await t.call('GET', `/api/v1/goals/${goal.id}`, { token: null })).json.goal;
++ assert.strictEqual(pub.current_amount, null);
++ assert.strictEqual(pub.target_amount, null);
++ assert.strictEqual(typeof pub.percent, 'number');
++ const names = pub.supporters.map((x) => x.name);
++ assert.ok(names.includes('Other') && names.includes('Anonymous'));
++ assert.ok(pub.supporters.every((x) => x.amount === null), 'no amounts while goal amounts are off');
++ assert.ok(!JSON.stringify(pub).includes('Viewer Real Name'));
++ const list = (await t.call('GET', '/api/v1/goals?creator=alex', { token: null })).json.goals;
++ assert.strictEqual(list[0].current_amount, null);
++ assert.ok((await t.call('GET', `/api/v1/goals/${goal.id}`, { user: alex })).json.goal.current_amount > 0, 'the creator sees everything');
++ const html = await fetch(`${t.base}/alex/goals`).then((x) => x.text());
++ assert.ok(!/of 100,000 Vibes/.test(html), 'the page shows the percentage only');
++ assert.match(html, /Other/);
++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { goal_amounts: true } } });
++ pub = (await t.call('GET', `/api/v1/goals/${goal.id}`, { token: null })).json.goal;
++ const hiddenRow = pub.supporters.find((x) => x.name === 'Viewer' && x.amount === null);
++ assert.ok(hiddenRow, 'a hidden amount stays hidden when goal amounts are on');
++ assert.strictEqual(pub.supporters.find((x) => x.name === 'Other').amount, 300);
++ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { everything: true } } });
++ assert.strictEqual(bad.status, 422);
++ });
++
++ await check('the supporters page: off by default; the creator picks names, amounts and messages', async () => {
++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).status, 404);
++ assert.strictEqual((await fetch(`${t.base}/alex/supporters`)).status, 404);
++ await tip(other, { amount: 1000, supporter_name: 'Other', message: 'big fan' });
++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { supporters_page: true } } });
++ let r = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;
++ assert.deepStrictEqual(r.leaderboard.map((x) => x.name), ['Other', 'Viewer']);
++ assert.ok(r.leaderboard.every((x) => x.total === null), 'no amounts unless the creator shows them');
++ assert.strictEqual(r.recent, null);
++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { supporters_amounts: true, supporters_messages: true } } });
++ r = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;
++ assert.strictEqual(r.leaderboard[0].total, 1300);
++ // Viewer's total counts only what they left public: not the anonymous 120, not the hidden 222/10/150.
++ assert.strictEqual(r.leaderboard[1].total, 50);
++ const msgs = r.recent.map((v) => v.message);
++ assert.ok(msgs.includes('big fan') && msgs.includes('from nobody'));
++ assert.ok(!msgs.includes('just for you'), 'a private message never shows');
++ assert.strictEqual(r.recent.find((v) => v.message === 'from nobody').supporter_name, 'Anonymous');
++ assert.strictEqual(r.recent.find((v) => v.message === 'keep it quiet').amount, null);
++ const html = await fetch(`${t.base}/alex/supporters`).then((x) => x.text());
++ assert.match(html, /big fan/);
++ assert.match(html, /<meta name="robots" content="index,follow">/);
++ assert.ok(!html.includes('just for you') && !html.includes('Viewer Real Name'));
++ assert.match(await fetch(`${t.base}/alex`).then((x) => x.text()), /href="\/alex\/supporters"/);
++ });
++
++ await check('the no-JS tip form carries the privacy choices', async () => {
++ const cookie = `ov_token=${t.network.signUser(other)}`;
++ const page = await fetch(`${t.base}/alex`, { headers: { Cookie: cookie } }).then((x) => x.text());
++ assert.match(page, /name="anonymous"/);
++ const csrf = page.match(/name="csrf" value="([^"]*)"/)[1];
++ const res = await fetch(`${t.base}/alex/tip`, {
++ method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
++ body: new URLSearchParams({ csrf, idem: 'form-privacy-00001', kind: 'tip', amount: '15', message: 'shh', pay_with: 'credit', supporter_name: 'Other', anonymous: '1', private_message: '1' }).toString(),
++ });
++ assert.strictEqual(res.status, 303);
++ const i = domain.interactions.get(res.headers.get('location').split('/').pop());
++ assert.strictEqual(i.anonymous, 1);
++ assert.strictEqual(i.private_message, 1);
++ assert.strictEqual(i.hide_amount, 0);
++ });
++
++ await check('export: a supporter downloads their own tips, with messages and choices, nobody else\'s', async () => {
++ const r = await t.call('GET', '/api/v1/me/export', { user: viewer });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.subject.id, viewer.subject);
++ assert.ok(r.json.interactions.length >= 5);
++ assert.ok(r.json.interactions.every((i) => i.supporter.id === viewer.subject));
++ const anon = r.json.interactions.find((i) => i.id === anonId);
++ assert.strictEqual(anon.message, 'from nobody');
++ assert.strictEqual(anon.privacy.anonymous, true);
++ assert.ok(anon.goal_contributions.length === 1);
++ assert.strictEqual((await t.call('GET', '/api/v1/me/export', { cap: ['tips.*'] })).status, 403, 'people only');
++ const web = await fetch(`${t.base}/receipts/export`, { headers: { Cookie: `ov_token=${t.network.signUser(viewer)}` } });
++ assert.match(web.headers.get('content-disposition'), /^attachment; filename="openvibe-tips-/);
++ assert.strictEqual(web.headers.get('cache-control'), 'no-store');
++ assert.strictEqual((await web.json()).interactions.length, r.json.interactions.length);
++ });
++
++ await check('erasure: the person goes, the money record stays; tips.interaction.erased redacts the earlier events', async () => {
++ const totalsBefore = domain.interactions.totals(alex.subject);
++ const goalBefore = domain.goals.present(domain.goals.get(goal.id)).current_amount;
++ const buyer = t.network.newUser('buyer');
++ billing.fund(buyer.subject, 1000);
++ const kept = await tip(buyer, { amount: 150, pay_with: 'checkout', provider: 'stripe', message: 'pending one' });
++ const done1 = await tip(buyer, { amount: 40, message: 'my secret words', supporter_name: 'Buyer Person' });
++ assert.ok(t.db.prepare('SELECT COUNT(*) AS n FROM api_idempotency WHERE key LIKE ?').get(`${buyer.subject}:%`).n >= 2);
++ const unsent = eventsOf('tips.interaction.ready', done1.json.interaction.id)[0];
++ assert.strictEqual(unsent.payload.supporter.id, buyer.subject);
++
++ const r = await t.call('POST', '/api/v1/me/erase', { user: buyer });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.erased, 1);
++ assert.strictEqual(r.json.kept_pending, 1);
++ assert.ok(r.json.stored_answers_removed >= 2);
++
++ const i = domain.interactions.get(done1.json.interaction.id);
++ assert.strictEqual(i.supporter_subject, null);
++ assert.strictEqual(i.supporter_name, null);
++ assert.strictEqual(i.message, null);
++ assert.ok(i.erased_at);
++ assert.strictEqual(i.amount, 40);
++ assert.ok(i.billing_txn_id, 'the Billing reference stays');
++ assert.strictEqual(domain.interactions.get(kept.json.interaction.id).supporter_subject, buyer.subject, 'a pending payment keeps its supporter');
++ const a = await alertOf(i.id);
++ assert.strictEqual(a.supporter_name, 'Anonymous');
++ assert.strictEqual(a.message, null);
++ const row = t.db.prepare('SELECT * FROM tip_interactions WHERE id = ?').get(i.id);
++ assert.ok(!JSON.stringify(row).includes('my secret words') && !JSON.stringify(row).includes('Buyer Person') && !JSON.stringify(row).includes(buyer.subject));
++ const everything = JSON.stringify(t.db.prepare('SELECT * FROM paid_messages').all()) + JSON.stringify(t.db.prepare('SELECT payload FROM overlay_deliveries').all())
++ + JSON.stringify(t.db.prepare('SELECT * FROM api_idempotency').all());
++ assert.ok(!everything.includes('my secret words') && !everything.includes('Buyer Person'));
++ const scrubbed = eventsOf('tips.interaction.ready', i.id)[0];
++ assert.strictEqual(scrubbed.payload.supporter, null, 'the local outbox copy no longer names them');
++ assert.strictEqual(scrubbed.payload.supporter_name, 'Anonymous');
++
++ const [erased] = eventsOf('tips.interaction.erased', i.id);
++ assert.deepStrictEqual(erased.payload.redacts, { subject_type: 'interaction', subject_ids: [i.id] });
++ const v = payloadValidator('tips.interaction.erased')(erased.payload);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.ok(contracts.validate('events.event-envelope@1', erased).valid);
++
++ const totalsAfter = domain.interactions.totals(alex.subject);
++ assert.strictEqual(totalsAfter.settled_via_billing, totalsBefore.settled_via_billing + 40, 'the erased tip still counts');
++ assert.strictEqual(totalsAfter.settled_via_billing, billing.payable.get(alex.subject), 'still reconciles to Billing');
++ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, goalBefore + 40, 'the goal keeps the money');
++ const receipts = (await t.call('GET', '/api/v1/interactions', { user: buyer })).json.interactions;
++ assert.deepStrictEqual(receipts.map((x) => x.id), [kept.json.interaction.id]);
++ });
++
++ await check('erasure from the receipts page needs the confirmation and the anti-forgery token', async () => {
++ const cookie = `ov_token=${t.network.signUser(other)}`;
++ const page = await fetch(`${t.base}/receipts/erase`, { headers: { Cookie: cookie } }).then((x) => x.text());
++ assert.match(page, /cannot be undone/);
++ const csrf = page.match(/name="csrf" value="([^"]*)"/)[1];
++ const post = (form) => fetch(`${t.base}/receipts/erase`, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie }, body: new URLSearchParams(form).toString() });
++ assert.strictEqual((await post({ csrf, idem: 'form-erase-000001' })).status, 403, 'no confirmation');
++ assert.strictEqual((await post({ csrf: 'forged', idem: 'form-erase-000002', confirm: '1' })).status, 403);
++ assert.ok(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ?').get(other.subject).n > 0);
++ const ok = await post({ csrf, idem: 'form-erase-000003', confirm: '1' });
++ assert.strictEqual(ok.status, 303);
++ assert.match(ok.headers.get('location'), /^\/receipts\?done=/);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ?').get(other.subject).n, 0);
++ const lb = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json.leaderboard;
++ assert.ok(!lb.some((x) => x.name === 'Other'), 'an erased supporter leaves the leaderboard');
++ });
++
++ await check('every event is a valid envelope with a valid payload', async () => {
++ for (const e of t.outboxRows()) {
++ assert.ok(contracts.validate('events.event-envelope@1', e).valid, e.event_type);
++ const v = payloadValidator(e.event_type)(e.payload);
++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
++ }
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });