openvibers/openvibe.tips Commits
Moderation: the creator's word filter before anything is shown or read; the creator and their moderators hide and show paid messages without touching the money
Changes
@@@ -121,12 +121,18 @@ token as a Bearer and act on their own things only. The API never reads cookies.| `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 |++| `GET /moderation?creator=&state=held\|hidden\|visible\|all` | `tips.interaction.moderate` (proposed) | the creator and their moderators |++| `POST /interactions/:id/hide`, `POST /interactions/:id/restore` `{reason?}` | `tips.interaction.moderate` (proposed) | the creator and their moderators |++| `GET /moderation/log?creator=` | `tips.interaction.moderate` (proposed) | owner |++| `GET\|POST /moderators`, `POST /moderators/:subject/remove` | `tips.profile.update` | owner || `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 || `POST /simulate` | `tips.simulation.run` | owner || `POST /internal/events` | Events webhook signature (`TIPS_EVENTS_SECRET`), loopback only | — |++`tips.interaction.moderate` is proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) and++not in openvibe-contracts yet (grants are matched by string, so a Network grant of it works today).The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids:the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNALtips); the drafts stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and@@@ -167,15 +173,42 @@ Tips still waiting for their payment are kept until it settles or fails. Each er`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.++## Moderation++++[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).++The money is never touched: hiding a paid message does not refund it, and a hidden tip still counts++toward its goal and the creator's totals.++++- **Word filter** (`filter: { words, action, links }` on the profile, the dashboard's *Moderation* card;++ the list is the creator's only). Blocked words or phrases match whole, in any letter case, after++ Unicode NFKC folding and with invisible and direction-override characters removed, in the name and in++ what is shown or read. `mask` (default) stars them out of overlays, chat lines and pages and leaves++ them out of what text-to-speech reads; `hold` holds a paid message that matches before anything is++ shown, posted or read, until the creator or a moderator shows it (then it is released as settlement++ would have released it, still masked). Links in paid messages read `[link]` unless the creator turns++ that off.++- **Hide / show.** The creator, their moderators and services with `tips.interaction.moderate` hide a++ paid message: its queued chat, TTS and media deliveries are cancelled (never delivered), its overlay++ alert is retracted (connected overlays get a `retract` event and drop it, replays and `/state` skip++ it) and public pages leave it out. Showing it again puts it back on pages and in overlay state; what++ the hide cancelled stays cancelled. `/moderate/<handle>` is the review page (held, shown, hidden).++- **Moderators** are added by the creator: a show-once invitation link from the dashboard (hashed at rest,++ single use, 7 days, never in nginx's log), accepted signed in; or `POST /moderators` by the creator or++ a service. Moderators see what was written but never private messages, hidden amounts or payments.++- Every outcome is in `tip_moderation_log` (with the moderator and their note) and is published as++ `tips.interaction.moderated` (below), which carries no supporter, message, note or moderator.++## EventsProduced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, same transaction as thechange; 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`, 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:++`tips.overlay.failed`, and two whose schemas are proposed in [docs/events-proposal/](docs/events-proposal/)++(not yet in openvibe-contracts): `tips.interaction.erased` (a supporter's erasure; `{ interaction_id,++creator, erased_at, redacts }`) and `tips.interaction.moderated` (`{ interaction_id, creator, action:++filtered|held|hidden|restored, by: filter|creator|moderator|service, moderation_state, cancelled_effects }`).++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`,@@@ -187,7 +220,8 @@ id mapped by the Live import; otherwise Live's rule (the only active goal).## Overlays`GET /overlay/<token>` is an OBS Browser Source page; `/overlay/<token>/events` is the SSE stream--(`hello` with config and active goals, then `alert` and `goal`, `config` on changes, `revoked`), and++(`hello` with config and active goals, then `alert` and `goal`, `config` on changes, `retract` when a++moderator hides an alert, `revoked`), and`/overlay/<token>/state` a read-only JSON for other overlay clients (e.g. Live). Tokens are scoped(`alerts`, `goals`), shown once, stored as SHA-256, revocable (open streams close at once) and never acookie. Every alert and goal change is one `overlay_deliveries` row with a monotonic `seq` (the SSE id):@@@ -217,7 +251,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 thepage on, otherwise 404 for everyone else and a `noindex` preview for the creator) · `/<handle>/goals` ·`/<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,++shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`++(the creator and their moderators), `/moderate/invite/<secret>` · `/dashboard` (page settings, public pages, moderation,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@@@ -249,6 +284,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) |++| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event 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@@@ -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. 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.",++ "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). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). Not done: 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"}@@@ -5,7 +5,7 @@## holds (until then the domain keeps its OpenVibe.Sites placeholder). The Node app (port 4610,## unit openvibe-tips) renders every page, serves the API and the overlay streams. nginx terminates## TLS, redirects www → apex, rate-limits auth and API, keeps overlay streams unbuffered, keeps--## overlay tokens out of the access log, and never exposes /internal/ (OpenVibe.Events calls++## overlay tokens and moderator invitation links out of the access log, and never exposes /internal/ (OpenVibe.Events calls## 127.0.0.1:4610 directly) or /metrics (host-local scrapers only; Node also refuses any request## carrying X-Forwarded-For).## ═══════════════════════════════════════════════════════════════@@@ -98,8 +98,22 @@ server {proxy_read_timeout 1h;}++ # Moderator invitation links carry a show-once secret in the path: not logged either (^~ so the++ # form-post regex below does not take them).++ location ^~ /moderate/invite/ {++ access_log off;++ limit_req zone=ovtips_forms burst=10 nodelay;++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++# Tip and dashboard form posts.-- location ~ ^/(dashboard/|[^/]+/tip$) {++ location ~ ^/(dashboard/|moderate/|receipts/erase$|[^/]+/tip$) {limit_req zone=ovtips_forms burst=10 nodelay;proxy_pass http://127.0.0.1:4610;proxy_http_version 1.1;nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "tips.interaction.moderate",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "internal",++ "description": "Moderate a creator's paid messages for them: read the review queue, hide a paid message from overlays, chat still to come and public pages, or show again one that was hidden or that the creator's word filter held. Never touches the money (no refund, no uncounting). People moderate as the creator or one of the creator's moderators; a service (e.g. Chat or Live relaying a channel moderator's action) needs this capability. Not in the charter list: proposed for the roadmap's \"privacy/moderation controls\" (Wave 9).",++ "permissions": [++ "tips:interactions"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "standard",++ "events": [++ "tips.interaction.moderated"++ ],++ "implementedBy": [++ "GET /api/v1/moderation",++ "GET /api/v1/moderation/log",++ "POST /api/v1/interactions/:id/hide",++ "POST /api/v1/interactions/:id/restore"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",++ "title": "TipsInteractionModeratedPayload",++ "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",++ "type": "object",++ "required": ["interaction_id", "creator", "action", "by", "moderation_state", "cancelled_effects"],++ "additionalProperties": false,++ "properties": {++ "interaction_id": { "type": "string", "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$" },++ "creator": { "$ref": "../../identity/subject-ref.v1.json" },++ "action": { "enum": ["filtered", "held", "hidden", "restored"] },++ "by": { "enum": ["filter", "creator", "moderator", "service"], "description": "Who acted, by role." },++ "moderation_state": { "enum": ["visible", "held", "hidden"], "description": "The interaction's moderation state after this outcome." },++ "cancelled_effects": {++ "type": "array",++ "uniqueItems": true,++ "items": { "enum": ["chat_line", "paid_message", "tts", "media_request"] },++ "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."++ }++ }++}@@@ -30,7 +30,8 @@"tips.overlay.token.revoke","tips.overlay.config.get","tips.overlay.config.update",-- "tips.simulation.run"++ "tips.simulation.run",++ "tips.interaction.moderate"],"eventsProduced": ["tips.interaction.ready",@@@ -39,7 +40,8 @@"tips.goal.updated","tips.overlay.delivered","tips.overlay.failed",-- "tips.interaction.erased"++ "tips.interaction.erased",++ "tips.interaction.moderated"],"eventsConsumed": ["billing.transaction.settled",@@@ -69,3 +69,11 @@ 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; } }++.pill { display: inline-block; font-size: .8rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--tips-border); color: var(--tips-muted); margin-left: 6px; }++nav.tabs { display: flex; gap: 14px; margin: 12px 0; }++nav.tabs a[aria-current] { font-weight: 700; text-decoration: none; color: var(--tips-text); }++ul.mod-list, ul.plain { list-style: none; padding: 0; }++.mod-item { border-bottom: 1px solid var(--tips-border); padding: 10px 0; }++.mod-item.hidden .msg, .mod-item.test { color: var(--tips-muted); }++.mod-item.held { border-left: 3px solid var(--tips-bad); padding-left: 10px; }++.mod-item .msg { margin: 6px 0; overflow-wrap: anywhere; }@@@ -10,6 +10,8 @@var goals = {};var queue = [];var busy = false;++ var current = null;++ var retracted = {};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' };@@@ -33,6 +35,7 @@if (busy || !queue.length) return;busy = true;var a = queue.shift();++ current = a;var s = config.settings || {};alertEl.textContent = '';var box = el('div', 'box');@@@ -57,8 +60,9 @@try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }}setTimeout(function () {++ if (current !== a) return; // retracted while on screen: already clearedalertEl.className = '';-- setTimeout(function () { busy = false; next(); }, 400);++ setTimeout(function () { busy = false; current = null; next(); }, 400);}, Number(s.duration_ms) || 8000);}@@@ -73,7 +77,7 @@es.addEventListener('config', function (e) { var d = JSON.parse(e.data); if (d.config) config = d.config; });es.addEventListener('alert', function (e) {var d = JSON.parse(e.data);-- if (shown[d.delivery_id]) return; // a replay of one this page already showed++ if (shown[d.delivery_id] || retracted[d.interaction_id]) return; // a replay of one this page already showed, or hidden sinceshown[d.delivery_id] = 1;queue.push(d);next();@@@ -86,5 +90,17 @@renderGoals();if (d.test) setTimeout(function () { if (goals[g.id] && goals[g.id]._test) { delete goals[g.id]; renderGoals(); es.close(); location.reload(); } }, 15000);});++ // A moderator hid it: drop it from the queue, or take it off the screen now (and stop its voice).++ es.addEventListener('retract', function (e) {++ var d = JSON.parse(e.data);++ retracted[d.interaction_id] = 1;++ queue = queue.filter(function (a) { return a.interaction_id !== d.interaction_id; });++ if (current && current.interaction_id === d.interaction_id) {++ try { if (window.speechSynthesis) window.speechSynthesis.cancel(); } catch (err) { /* no voice */ }++ alertEl.className = ''; alertEl.textContent = '';++ current = null;++ setTimeout(function () { busy = false; next(); }, 400);++ }++ });es.addEventListener('revoked', function () { es.close(); alertEl.textContent = ''; goalsEl.textContent = ''; });})();@@@ -29,6 +29,10 @@* | 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 |++ * | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |++ * | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |++ * | GET /moderation/log?creator= | tips.interaction.moderate | owner |++ * | GET|POST /moderators, POST …/:s/remove | tips.profile.update | owner |** 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@@@ -57,6 +61,7 @@ const CAP = {configGet: 'tips.overlay.config.get',configUpdate: 'tips.overlay.config.update',simulate: 'tips.simulation.run',++ moderate: 'tips.interaction.moderate', // proposed (docs/capabilities-proposal/), not yet in openvibe-contracts};function v1Router({ domain, apiAuth }) {@@@ -316,6 +321,57 @@ function v1Router({ domain, apiAuth }) {res.json({ config: overlays.updateConfig(c, req.body || {}) });}));++ // ── Moderation (server/domain/moderation.js) ─────────────++ const { moderation } = domain;++ /** The creator, one of their moderators, or a service with tips.interaction.moderate. */++ function moderatorOf(req, creator, { hideExistence = false } = {}) {++ const p = req.principal;++ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in (a Network user token) or present a service token');++ const a = moderation.actorFor(p, creator, p.kind === 'service' && apiAuth.granted(p, CAP.moderate));++ if (a) return a;++ if (p.kind === 'service') fail(403, 'capability.denied', `${CAP.moderate} not granted`);++ if (hideExistence) fail(404, 'tips.interaction_not_found', 'no such interaction');++ fail(403, 'tips.forbidden', 'only the creator and their moderators moderate this page');++ return null;++ }++ r.get('/moderation', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ moderatorOf(req, profile.creator_subject);++ const out = moderation.queue(profile.creator_subject, { state: req.query.state || 'all', cursor: req.query.cursor, limit: req.query.limit });++ res.json({ creator: { type: 'user', id: profile.creator_subject }, interactions: out.rows, next_cursor: out.next_cursor });++ }));++ r.get('/moderation/log', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ allow(req, CAP.moderate, profile.creator_subject);++ res.json({ log: moderation.log(profile.creator_subject) });++ }));++ for (const action of ['hide', 'restore']) {++ r.post(`/interactions/:id/${action}`, ...write((req, res) => {++ const i = interactions.get(req.params.id);++ if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');++ const actor = moderatorOf(req, i.creator_subject, { hideExistence: true });++ const out = moderation[action](i, { ...actor, reason: (req.body || {}).reason });++ res.json({ interaction: moderation.present(out.interaction), changed: out.changed, cancelled_effects: out.cancelled_effects || [] });++ }));++ }++ r.get('/moderators', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ allow(req, CAP.profileUpdate, profile.creator_subject);++ res.json({ moderators: moderation.listModerators(profile.creator_subject).map(moderation.presentModerator) });++ }));++ r.post('/moderators', ...write((req, res) => {++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ allow(req, CAP.profileUpdate, profile.creator_subject);++ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ res.status(201).json({ moderator: moderation.addModerator(profile.creator_subject, b.moderator, { name: b.name, addedBy: by }) });++ }));++ r.post('/moderators/:subject/remove', ...write((req, res) => {++ const profile = creatorOf(req, (req.body || {}).creator);++ allow(req, CAP.profileUpdate, profile.creator_subject);++ res.json(moderation.removeModerator(profile.creator_subject, req.params.subject));++ }));++// ── Simulation ───────────────────────────────────────────r.post('/simulate', ...write((req, res) => {const b = req.body || {};@@@ -247,6 +247,43 @@ CREATE TABLE IF NOT EXISTS import_runs (report TEXT);++-- Moderation (server/domain/moderation.js): the creator's moderators, invitations to become one++-- (show-once links, hashed like overlay tokens) and the record of every moderation outcome.++CREATE TABLE IF NOT EXISTS tip_moderators (++ creator_subject TEXT NOT NULL,++ moderator_subject TEXT NOT NULL,++ name TEXT, -- display name when they joined (for the creator's list)++ added_by TEXT NOT NULL, -- the creator, a service, or 'invite:<id>'++ created_at TEXT NOT NULL,++ removed_at TEXT,++ PRIMARY KEY (creator_subject, moderator_subject)++);++CREATE INDEX IF NOT EXISTS idx_tmod_moderator ON tip_moderators (moderator_subject);++++CREATE TABLE IF NOT EXISTS tip_moderator_invites (++ id TEXT PRIMARY KEY, -- tmin_<ULID>++ creator_subject TEXT NOT NULL,++ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the link's secret (shown once)++ created_by TEXT NOT NULL,++ created_at TEXT NOT NULL,++ expires_at TEXT NOT NULL,++ used_at TEXT,++ used_by TEXT,++ revoked_at TEXT++);++++CREATE TABLE IF NOT EXISTS tip_moderation_log (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ creator_subject TEXT NOT NULL,++ interaction_id TEXT NOT NULL,++ action TEXT NOT NULL CHECK (action IN ('held', 'filtered', 'hidden', 'restored')),++ by_role TEXT NOT NULL CHECK (by_role IN ('filter', 'creator', 'moderator', 'service')),++ actor TEXT, -- usr_… / svc:… ; null for the filter++ reason TEXT,++ created_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_tmodlog_creator ON tip_moderation_log (creator_subject, id);++CREATE TABLE IF NOT EXISTS api_idempotency (key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>request_hash TEXT NOT NULL,@@@ -264,6 +301,8 @@ CREATE TABLE IF NOT EXISTS api_idempotency (** 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++ * v3 moderation (server/domain/moderation.js): an interaction's moderation state, the creator's++ * word filter, overlay alerts retracted by a moderator*/const COLUMNS = [['tip_interactions', 'anonymous', 'INTEGER NOT NULL DEFAULT 0'], // name shown as "Anonymous" to everyone but the supporter@@@ -271,9 +310,17 @@ const COLUMNS = [['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++ ['tip_interactions', 'moderation', "TEXT NOT NULL DEFAULT 'visible'"], // visible | held (the filter, awaiting review) | hidden (a moderator)++ ['tip_interactions', 'moderated_at', 'TEXT'],++ ['tip_interactions', 'moderated_by', 'TEXT'], // 'filter', usr_… or svc:…++ ['tip_interactions', 'filtered', 'INTEGER NOT NULL DEFAULT 0'], // the word filter matched it at settlement++ ['creator_tip_profiles', 'filter_words', "TEXT NOT NULL DEFAULT '[]'"], // JSON: the creator's blocklist (filter.js)++ ['creator_tip_profiles', 'filter_action', "TEXT NOT NULL DEFAULT 'mask'"],// mask | hold++ ['creator_tip_profiles', 'filter_links', 'INTEGER NOT NULL DEFAULT 1'], // links in paid messages shown as [link]++ ['overlay_deliveries', 'hidden', 'INTEGER NOT NULL DEFAULT 0'], // retracted: never sent or replayed again];--const SCHEMA_VERSION = 2;++const SCHEMA_VERSION = 3;function addColumns(db) {const have = new Map();@@@ -11,10 +11,12 @@** 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.++ * is not sent, and the creator's word filter has starred blocked words out of the line and dropped++ * them from the TTS text. `privacy` tells the receiving product what was withheld. An effect whose++ * interaction a moderator hid (or whose effect was cancelled meanwhile) is never delivered.*/const { iso, json } = require('../util');--const { publicView, privacyOf, isAnonymous } = require('./privacy');++const { privacyOf, isAnonymous } = require('./privacy');function createEffects(ctx) {const { db, config } = ctx;@@@ -23,7 +25,7 @@ function createEffects(ctx) {function jobFor(effect, i, profile) {const req = json(i.request, {});-- const pv = publicView(i);++ const pv = ctx.view(i);const name = pv.supporter_name;const amount = pv.amount != null ? `${pv.amount.toLocaleString('en-US')} Vibes` : null;const msg = pv.message ? `: ${pv.message}` : '';@@@ -80,12 +82,21 @@ function createEffects(ctx) {}async function runOne(effect) {++ // Re-read: the batch was selected before earlier deliveries awaited; a hide or a reversal may++ // have cancelled this one since.++ const current = db.prepare('SELECT state FROM interaction_effects WHERE id = ?').get(effect.id);++ if (!current || current.state !== 'queued') return 'cancelled';const i = ctx.interactions.get(effect.interaction_id);if (!i || i.payment_state !== 'settled') {// Reversed or failed before its turn: nothing to deliver.ctx.tx(() => ctx.interactions.cancelQueued(i, 'payment_not_settled'));return 'cancelled';}++ if (i.moderation !== 'visible') {++ // Hidden or held: moderation.js cancels or releases these; never deliver one meanwhile.++ db.prepare("UPDATE interaction_effects SET state = 'cancelled', last_error = 'hidden by moderation', updated_at = ? WHERE id = ? AND state = 'queued'").run(iso(ctx.now()), effect.id);++ return 'cancelled';++ }const adapter = ctx.adapters[effect.adapter];if (!adapter) { markFailed(effect, Object.assign(new Error(`no adapter ${effect.adapter} configured`), { permanent: true })); return 'failed'; }try {nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * The creator's word filter for paid messages (and the names that come with them), applied before++ * anything is shown on an overlay, posted to chat, read out by text-to-speech or put on a public page.++ *++ * words the creator's blocklist: words or phrases, matched whole, case-insensitively, after Unicode++ * NFKC normalisation (full-width and compatibility letters fold to plain ones)++ * action mask the matches are starred out for display and left out of what TTS reads++ * hold a paid message that matches is held: nothing is shown or read until the creator++ * or a moderator approves it (moderation.js); the money is untouched either way++ * links links in a paid message are replaced by "[link]" (display) or "link" (speech), whatever++ * the blocklist says (on by default: a link on stream is an easy phishing vector)++ *++ * Every text also loses invisible and direction-override characters (they would hide a word from++ * the filter or turn a line around on stream) and long runs of combining marks.++ */++const { fail } = require('../util');++++const MAX_WORDS = 200;++const MAX_WORD_CHARS = 60;++const ACTIONS = ['mask', 'hold'];++// Zero-width, bidi embedding/override/isolate marks, word joiner, BOM, soft hyphen.++const INVISIBLE = /[----]/g;++const COMBINING_RUN = /(\p{M}{2})\p{M}+/gu;++const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"']+/gi;++++/** Invisible characters and combining-mark runs out, NFKC. */++function clean(s) {++ return String(s == null ? '' : s).replace(INVISIBLE, '').normalize('NFKC').replace(COMBINING_RUN, '$1');++}++++/** The creator's input (a list, or text with one entry per line or comma) → a clean, bounded list. */++function normalizeWords(v) {++ const raw = Array.isArray(v) ? v : String(v == null ? '' : v).split(/[\n,]/);++ const out = [];++ for (const w of raw) {++ const s = clean(w).toLowerCase().replace(/\s+/g, ' ').trim();++ if (!s) continue;++ if (s.length > MAX_WORD_CHARS) fail(422, 'tips.invalid_input', `a blocked word or phrase is at most ${MAX_WORD_CHARS} characters`);++ if (!out.includes(s)) out.push(s);++ }++ if (out.length > MAX_WORDS) fail(422, 'tips.invalid_input', `at most ${MAX_WORDS} blocked words or phrases`);++ return out;++}++++const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++const cache = new Map();++/** One case-insensitive regexp matching any entry as a whole word or phrase (literal text only). */++function compile(words) {++ if (!words || !words.length) return null;++ const key = JSON.stringify(words);++ if (cache.has(key)) return cache.get(key);++ const alt = words.map((w) => w.split(' ').map(escapeRe).join('\\s+')).sort((a, b) => b.length - a.length).join('|');++ const re = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alt})(?![\\p{L}\\p{N}_])`, 'giu');++ if (cache.size > 500) cache.clear();++ cache.set(key, re);++ return re;++}++++/**++ * Filter one text. mode 'display' stars out a match, 'speech' drops it. Returns { text, hit } where++ * hit says a blocked word matched (links alone are not a hit).++ */++function apply(text, settings, mode = 'display') {++ if (text == null || text === '') return { text, hit: false };++ let s = clean(text);++ if (!settings || settings.links !== false) s = s.replace(URL_RE, mode === 'speech' ? 'link' : '[link]');++ const re = settings ? compile(settings.words) : null;++ let hit = false;++ if (re) {++ re.lastIndex = 0;++ s = s.replace(re, (m) => { hit = true; return mode === 'speech' ? '' : '*'.repeat([...m].length); });++ if (mode === 'speech') s = s.replace(/\s{2,}/g, ' ').trim();++ }++ return { text: s, hit };++}++++/** Whether any of the texts hits a blocked word. */++function hits(settings, ...texts) {++ const re = settings ? compile(settings.words) : null;++ if (!re) return false;++ return texts.some((t) => { if (!t) return false; re.lastIndex = 0; return re.test(clean(t)); });++}++++module.exports = { clean, normalizeWords, compile, apply, hits, ACTIONS, MAX_WORDS, MAX_WORD_CHARS };@@@ -18,7 +18,7 @@*/const { fail, iso, prefixedId, text, positiveInt } = require('../util');const { safeUrl } = require('./profiles');--const { publicName } = require('./privacy');++const { publicName, shownName } = require('./privacy');function createGoals(ctx) {const { db } = ctx;@@@ -64,10 +64,12 @@ function createGoals(ctx) {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) {++ // Moderated (held or hidden) interactions are left out; names go through the creator's filter.++ const settings = ctx.profiles.filterOf(g.creator_subject);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_atFROM 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 }));++ AND i.moderation = 'visible' ORDER BY c.id DESC LIMIT 10`).all(g.id)++ .map((c) => ({ name: shownName(c, settings), amount: page.goal_amounts && !c.hide_amount ? c.amount : null, at: c.created_at }));}return out;}@@@ -169,8 +171,9 @@ function createGoals(ctx) {if (!r.changes) return null;const reachedNow = markReached(g.id);// 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) });++ // (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.++ const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : ctx.view(interaction).supporter_name;++ return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });}/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */@@@ -6,12 +6,15 @@* ctx.tx(fn) run fn in one SQLite transaction; ctx.afterCommit(hook) queues work (overlay* pushes, worker kicks) that must only happen once the transaction committed* ctx.outbox.emit() durable event, inside the transaction (openvibe-sdk outbox)++ * ctx.view(i) the interaction's public view (privacy.js) through its creator's word filter*/++const { publicView } = require('./privacy');const { createProfiles } = require('./profiles');const { createGoals } = require('./goals');const { createOverlays } = require('./overlays');const { createInteractions } = require('./interactions');const { createEffects } = require('./effects');++const { createModeration } = require('./moderation');function createDomain({ db, config, outbox, billing, adapters, now = () => Date.now(), log = console }) {const ctx = { db, config, now, log, outbox, billing, adapters, _after: null };@@@ -29,10 +32,12 @@ function createDomain({ db, config, outbox, billing, adapters, now = () => Date.ctx.outboxKick = () => { try { outbox.kick(); } catch { /* relay off */ } };ctx.profiles = createProfiles(ctx);++ ctx.view = (i, opts = {}) => publicView(i, { ...opts, filter: ctx.profiles.filterOf(i.creator_subject) });ctx.goals = createGoals(ctx);ctx.overlays = createOverlays(ctx);ctx.interactions = createInteractions(ctx);ctx.effects = createEffects(ctx);++ ctx.moderation = createModeration(ctx);return ctx;}@@@ -40,7 +40,7 @@*/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 { parsePrivacy, publicName, shownName, privacyOf, isAnonymous } = require('./privacy');const { BillingCallError } = require('../billing-client');const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];@@@ -247,7 +247,9 @@ function createInteractions(ctx) {const at = iso(ctx.now());const test = !!i.test;const req = json(i.request, {});-- db.prepare("UPDATE tip_interactions SET payment_state = 'settled', delivery_state = 'queued', settled_at = ?, updated_at = ? WHERE id = ?").run(at, at, i.id);++ // A tip hidden by a moderator while its payment was pending will deliver nothing.++ db.prepare(`UPDATE tip_interactions SET payment_state = 'settled', delivery_state = CASE WHEN moderation = 'hidden' THEN 'cancelled' ELSE 'queued' END,++ settled_at = ?, updated_at = ? WHERE id = ?`).run(at, at, i.id);i = get(i.id);if (i.kind === 'paid_message' || i.kind === 'tts') {@@@ -259,12 +261,31 @@ function createInteractions(ctx) {db.prepare(`INSERT OR IGNORE INTO paid_media_requests (id, interaction_id, creator_subject, url, provider, status, test, created_at, updated_at)VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tmr', ctx.now()), i.id, i.creator_subject, req.media.url, req.media.provider, test ? 1 : 0, at, at);}++ // The creator's word filter runs before anything is shown, posted or read (moderation.js): a++ // match is masked, or held for review when the creator chose that. History is not screened.++ const verdict = i.origin !== 'import' && i.moderation === 'visible' ? ctx.moderation.screen(i) : { hit: false };++ if (verdict.hit) ctx.moderation.markScreened(i, verdict);++ i = get(i.id);if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);---- if (i.origin !== 'import') {-- ctx.overlays.addAlert(i);-- addEffect(i, 'overlay_alert', 'overlay', 'delivered');++ if (i.origin !== 'import' && i.moderation === 'visible') release(i);++ recomputeDelivery(i.id);++ if (!test && i.origin !== 'import') {++ ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));}++ if (verdict.hit) ctx.moderation.recordScreened(get(i.id), verdict);++ ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });++ return get(i.id);++ }++++ /**++ * Inside a transaction: show a settled interaction — the overlay alert and the chat, TTS and media++ * deliveries. Settlement calls it, and moderation's restore() for one the filter held. Idempotent:++ * nothing that exists (delivered or cancelled) is created again.++ */++ function release(i) {++ const test = !!i.test;++ ctx.overlays.addAlert(i);++ addEffect(i, 'overlay_alert', 'overlay', 'delivered');// Chat effects: never for imports (history), never twice for a donation another product already// announced (origin billing), never to a real chat room for a simulation.const adapter = test ? 'test' : config.chat.adapter;@@@ -275,11 +296,7 @@ function createInteractions(ctx) {if (i.kind === 'media_request') addEffect(i, 'media_request', adapter);}recomputeDelivery(i.id);-- if (!test && i.origin !== 'import') {-- ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));-- }-- ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });-- return get(i.id);++ ctx.afterCommit(() => ctx.effects.kick());}function addEffect(i, effect, adapter, state = 'queued') {@@@ -362,7 +379,16 @@ function createInteractions(ctx) {.run(JSON.stringify(ids), take, at, at, i.id);if (take > 0 && !i.test) ctx.goals.reverse(i, take);// Undelivered effects are cancelled; a delivery that already happened stays on record.-- const cancelled = cancelQueued(get(i.id), 'payment_reversed');++ let cancelled = cancelQueued(get(i.id), 'payment_reversed');++ const now = get(i.id);++ if (!cancelled && now.moderation === 'held' && now.delivery_state === 'queued') {++ // Held by the filter and never shown: it will not be released now.++ db.prepare("UPDATE tip_interactions SET delivery_state = 'cancelled', updated_at = ? WHERE id = ?").run(at, i.id);++ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);++ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);++ if (!now.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason: 'payment_reversed' });++ cancelled = true;++ }return cancelled ? 'reversed_cancelled' : 'reversed';}@@@ -383,6 +409,7 @@ function createInteractions(ctx) {const i = get(id);if (!i || i.delivery_state === 'cancelled' || i.delivery_state === 'awaiting_payment') return i;const effects = db.prepare('SELECT state FROM interaction_effects WHERE interaction_id = ?').all(id).map((e) => e.state);++ if (i.moderation === 'held' && !effects.length) return i; // waiting for a moderator: still to deliverlet next = 'queued';if (effects.includes('failed') && !effects.includes('queued')) next = 'failed';else if (!effects.includes('queued')) next = 'delivered';@@@ -490,7 +517,8 @@ 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.hide_amount ? null : publicName(i), dedupe: `goal:${g.id}:sim:${i.id}`, test: true });++ const shown = get(i.id);++ ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: shown.hide_amount || shown.moderation !== 'visible' ? null : ctx.view(shown).supporter_name, dedupe: `goal:${g.id}:sim:${i.id}`, test: true });}return out;});@@@ -522,7 +550,8 @@ function createInteractions(ctx) {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,++ privacy: privacyOf(i), public: ctx.view(i), erased_at: i.erased_at || null,++ moderation: { state: i.moderation, filtered: !!i.filtered, at: i.moderated_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,@@@ -554,7 +583,8 @@ function createInteractions(ctx) {// 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`;++ AND ${t}.payment_state IN ('settled', 'reversed') AND ${t}.anonymous = 0 AND ${t}.hide_amount = 0 AND ${t}.erased_at IS NULL++ AND ${t}.moderation = 'visible'`;/** Top supporters: [{ rank, name, total, tips }] (name = the latest name they tipped under). */function leaderboard(creator, { limit = 20 } = {}) {@@@ -563,14 +593,15 @@ function createInteractions(ctx) {ORDER BY j.created_at DESC LIMIT 1) AS nameFROM 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 }));++ const settings = ctx.profiles.filterOf(creator);++ return rows.map((r, k) => ({ rank: k + 1, name: shownName({ supporter_name: r.name }, settings), 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));++ AND erased_at IS NULL AND moderation = 'visible' AND message IS NOT NULL ORDER BY settled_at DESC, id DESC LIMIT ?`).all(creator, Math.min(100, Math.max(1, limit)))++ .map((i) => ctx.view(i));}// ── A supporter's own data ───────────────────────────────@@@ -664,7 +695,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,++ leaderboard, recentPublic, exportFor, erase, release,};}nnew file mode 100644@@@ -0,0 +1,231 @@++'use strict';++++/**++ * Moderation of what supporters paid to show: a paid message, a TTS text, a media request, and the++ * name that comes with a tip. The money is never touched here: hiding a paid message does not refund++ * it, and a hidden tip still counts toward its goal and the creator's totals (Billing's books).++ *++ * moderation visible shown as the supporter allowed (privacy.js), through the creator's filter++ * held the creator's filter (action hold) matched at settlement: nothing was shown,++ * posted or read yet; restore() releases it (overlay alert, chat, TTS, media)++ * hidden a creator, moderator or service hid it: queued chat/TTS/media deliveries are++ * cancelled, the overlay alert is retracted (connected overlays drop it, replays++ * and /state skip it) and public pages leave it out; restore() shows it again on++ * pages and overlay state, without replaying what was cancelled++ *++ * Who may moderate a creator's interactions: the creator, their moderators (tip_moderators: added by++ * the creator through a show-once invitation link, or by a service), and services holding++ * tips.interaction.moderate. Every outcome is recorded in tip_moderation_log and published as++ * tips.interaction.moderated (never for simulations): { interaction_id, creator, action, by,++ * moderation_state, cancelled_effects }. The payload names no supporter, message or moderator.++ */++const crypto = require('crypto');++const { fail, iso, prefixedId, sha256, userSubject, displayName, text } = require('../util');++const filter = require('./filter');++++const INVITE_RE = /^tmin_[A-Za-z0-9_-]{43}$/;++const INVITE_TTL_MS = 7 * 24 * 3600 * 1000;++const MAX_MODERATORS = 50;++const CHAT_EFFECTS = ['chat_line', 'paid_message', 'tts', 'media_request'];++++function createModeration(ctx) {++ const { db } = ctx;++++ // ── Moderators ───────────────────────────────────────────++ const isModerator = (creator, subject) => !!(subject && db.prepare('SELECT 1 FROM tip_moderators WHERE creator_subject = ? AND moderator_subject = ? AND removed_at IS NULL').get(creator, subject));++ const listModerators = (creator) => db.prepare('SELECT * FROM tip_moderators WHERE creator_subject = ? AND removed_at IS NULL ORDER BY created_at').all(creator);++ /** Creators whose interactions this person moderates (with their handles). */++ const moderatedBy = (subject) => db.prepare(`SELECT p.creator_subject, p.handle, p.display_name FROM tip_moderators m++ JOIN creator_tip_profiles p ON p.creator_subject = m.creator_subject WHERE m.moderator_subject = ? AND m.removed_at IS NULL ORDER BY p.handle`).all(subject);++++ function addModerator(creator, moderator, { name, addedBy }) {++ const subject = userSubject(moderator, 'moderator');++ if (subject === creator) fail(422, 'tips.invalid_input', 'the creator moderates their own page already');++ const count = db.prepare('SELECT COUNT(*) AS n FROM tip_moderators WHERE creator_subject = ? AND removed_at IS NULL').get(creator).n;++ if (!isModerator(creator, subject) && count >= MAX_MODERATORS) fail(409, 'tips.too_many_moderators', `remove a moderator first (${MAX_MODERATORS} at most)`);++ db.prepare(`INSERT INTO tip_moderators (creator_subject, moderator_subject, name, added_by, created_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (creator_subject, moderator_subject) DO UPDATE SET removed_at = NULL, name = COALESCE(excluded.name, name), added_by = excluded.added_by`)++ .run(creator, subject, displayName(name), addedBy, iso(ctx.now()));++ return presentModerator(db.prepare('SELECT * FROM tip_moderators WHERE creator_subject = ? AND moderator_subject = ?').get(creator, subject));++ }++++ function removeModerator(creator, moderator) {++ const subject = userSubject(moderator, 'moderator');++ const r = db.prepare('UPDATE tip_moderators SET removed_at = ? WHERE creator_subject = ? AND moderator_subject = ? AND removed_at IS NULL').run(iso(ctx.now()), creator, subject);++ return { removed: r.changes > 0 };++ }++++ const presentModerator = (m) => (m ? { moderator: { type: 'user', id: m.moderator_subject }, name: m.name || null, added_at: m.created_at } : null);++++ // ── Invitations (a show-once link the creator hands to a moderator) ──++ function createInvite(creator, { createdBy }) {++ const open = db.prepare('SELECT COUNT(*) AS n FROM tip_moderator_invites WHERE creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?').get(creator, iso(ctx.now())).n;++ if (open >= 10) fail(409, 'tips.too_many_invites', 'revoke an open invitation first (10 at most)');++ const secret = `tmin_${crypto.randomBytes(32).toString('base64url')}`;++ const id = prefixedId('tmin', ctx.now());++ const at = ctx.now();++ db.prepare('INSERT INTO tip_moderator_invites (id, creator_subject, token_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')++ .run(id, creator, sha256(secret), createdBy, iso(at), iso(at + INVITE_TTL_MS));++ return { id, url: `${ctx.config.baseUrl}/moderate/invite/${secret}`, expires_at: iso(at + INVITE_TTL_MS) };++ }++++ /** The open invitation for a presented secret, or null (unknown, used, revoked or expired). */++ function findInvite(secret) {++ if (!INVITE_RE.test(String(secret || ''))) return null;++ const row = db.prepare('SELECT * FROM tip_moderator_invites WHERE token_hash = ?').get(sha256(secret));++ if (!row || row.used_at || row.revoked_at || row.expires_at <= iso(ctx.now())) return null;++ return row;++ }++++ function acceptInvite(secret, { subject, name }) {++ return ctx.tx(() => {++ const inv = findInvite(secret);++ if (!inv) fail(404, 'tips.invite_not_found', 'this invitation is not valid (it may have been used, revoked or expired)');++ if (inv.creator_subject === subject) fail(422, 'tips.invalid_input', 'this is your own page');++ db.prepare('UPDATE tip_moderator_invites SET used_at = ?, used_by = ? WHERE id = ? AND used_at IS NULL').run(iso(ctx.now()), subject, inv.id);++ return { creator: inv.creator_subject, moderator: addModerator(inv.creator_subject, subject, { name, addedBy: `invite:${inv.id}` }) };++ });++ }++++ const openInvites = (creator) => db.prepare('SELECT id, created_at, expires_at FROM tip_moderator_invites WHERE creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ? ORDER BY created_at').all(creator, iso(ctx.now()));++ function revokeInvite(creator, id) {++ return { revoked: db.prepare('UPDATE tip_moderator_invites SET revoked_at = ? WHERE id = ? AND creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL').run(iso(ctx.now()), String(id || ''), creator).changes > 0 };++ }++++ // ── The filter at settlement ─────────────────────────────++ /**++ * Inside the settlement transaction, before anything is shown: does the creator's filter match the++ * name or what will be shown or read? Returns { hit, hold }.++ */++ function screen(i) {++ const settings = ctx.profiles.filterOf(i.creator_subject);++ if (!settings || !settings.words.length) return { hit: false, hold: false };++ const req = JSON.parse(i.request || '{}');++ const texts = [i.anonymous ? null : i.supporter_name, i.private_message ? null : i.message, req.tts && req.tts.text];++ const hit = filter.hits(settings, ...texts);++ return { hit, hold: hit && settings.action === 'hold' };++ }++++ // ── Outcomes ─────────────────────────────────────────────++ function record(i, action, { role, actor = null, reason = null, cancelled = [] }) {++ db.prepare('INSERT INTO tip_moderation_log (creator_subject, interaction_id, action, by_role, actor, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(i.creator_subject, i.id, action, role, actor, reason, iso(ctx.now()));++ if (!i.test) {++ const now = ctx.interactions.get(i.id);++ ctx.outbox.emit('tips.interaction.moderated', { type: 'interaction', id: i.id }, {++ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, action, by: role,++ moderation_state: now.moderation, cancelled_effects: cancelled,++ });++ ctx.afterCommit(() => ctx.outboxKick());++ }++ }++++ /**++ * Settlement found a filter match (inside its transaction). markScreened() sets the state before++ * anything is shown (held, or only filtered = masked); recordScreened() logs and publishes it once++ * the settlement's own event is out.++ */++ function markScreened(i, verdict) {++ const at = iso(ctx.now());++ if (verdict.hold) db.prepare("UPDATE tip_interactions SET filtered = 1, moderation = 'held', moderated_at = ?, moderated_by = 'filter', updated_at = ? WHERE id = ?").run(at, at, i.id);++ else db.prepare('UPDATE tip_interactions SET filtered = 1, updated_at = ? WHERE id = ?').run(at, i.id);++ }++ function recordScreened(i, verdict) {++ record(i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });++ }++++ /** Who is acting: { role, actor } for the creator, a moderator or a granted service; else 403. */++ function actorFor(principal, creator, granted) {++ if (principal.kind === 'service' && granted) return { role: 'service', actor: principal.sub };++ if (principal.kind === 'user' && principal.subject === creator) return { role: 'creator', actor: principal.subject };++ if (principal.kind === 'user' && isModerator(creator, principal.subject)) return { role: 'moderator', actor: principal.subject };++ return null;++ }++++ /** Hide from overlays, chat/TTS still to come and public pages. The money is untouched. */++ function hide(i, { role, actor, reason }) {++ const note = text(reason, 'reason', 200);++ return ctx.tx(() => {++ const cur = ctx.interactions.get(i.id);++ if (cur.moderation === 'hidden') return { interaction: cur, changed: false };++ const at = iso(ctx.now());++ const cancelled = db.prepare(`SELECT effect FROM interaction_effects WHERE interaction_id = ? AND state = 'queued' AND effect IN (${CHAT_EFFECTS.map(() => '?').join(',')})`)++ .all(cur.id, ...CHAT_EFFECTS).map((e) => e.effect);++ if (cancelled.length) {++ db.prepare(`UPDATE interaction_effects SET state = 'cancelled', last_error = 'hidden by moderation', updated_at = ? WHERE interaction_id = ? AND state = 'queued'`).run(at, cur.id);++ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, cur.id);++ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, cur.id);++ }++ const undelivered = cancelled.length > 0 || cur.moderation === 'held' || cur.payment_state === 'pending';++ db.prepare(`UPDATE tip_interactions SET moderation = 'hidden', moderated_at = ?, moderated_by = ?,++ delivery_state = CASE WHEN ? AND delivery_state IN ('queued', 'awaiting_payment') THEN 'cancelled' ELSE delivery_state END, updated_at = ? WHERE id = ?`)++ .run(at, actor || role, undelivered ? 1 : 0, at, cur.id);++ const now = ctx.interactions.get(cur.id);++ ctx.overlays.retract(now);++ record(now, 'hidden', { role, actor, reason: note, cancelled });++ return { interaction: ctx.interactions.get(cur.id), changed: true, cancelled_effects: cancelled };++ });++ }++++ /**++ * Show it again. Held (never shown) and settled: released now, as settlement would have (overlay++ * alert, chat, TTS, media). Hidden after it was shown: back on pages and in overlay state; what the++ * hide cancelled stays cancelled.++ */++ function restore(i, { role, actor, reason }) {++ const note = text(reason, 'reason', 200);++ return ctx.tx(() => {++ const cur = ctx.interactions.get(i.id);++ if (cur.moderation === 'visible') return { interaction: cur, changed: false };++ const at = iso(ctx.now());++ // Never shown (held, or hidden before it was): released now, its delivery starting over.++ const neverShown = !db.prepare("SELECT 1 FROM overlay_deliveries WHERE interaction_id = ? AND kind = 'alert'").get(cur.id);++ const release = cur.payment_state === 'settled' && cur.origin !== 'import' && neverShown;++ db.prepare(`UPDATE tip_interactions SET moderation = 'visible', moderated_at = ?, moderated_by = ?,++ delivery_state = CASE WHEN ? THEN 'queued' ELSE delivery_state END, updated_at = ? WHERE id = ?`).run(at, actor || role, release ? 1 : 0, at, cur.id);++ const now = ctx.interactions.get(cur.id);++ ctx.overlays.unretract(now);++ if (release) ctx.interactions.release(now);++ record(now, 'restored', { role, actor, reason: note });++ return { interaction: ctx.interactions.get(cur.id), changed: true };++ });++ }++++ /** The moderators' view: what was written (not private things), and what the public sees. */++ function present(i) {++ const req = JSON.parse(i.request || '{}');++ return {++ id: i.id, kind: i.kind, created_at: i.created_at, settled_at: i.settled_at || null, payment_state: i.payment_state, test: !!i.test,++ moderation: { state: i.moderation, at: i.moderated_at || null, filtered: !!i.filtered },++ supporter_name: i.anonymous || i.erased_at ? 'Anonymous' : (i.supporter_name || 'Someone'),++ amount: i.hide_amount ? null : i.amount,++ message: i.private_message || i.erased_at ? null : (i.message || null),++ tts_text: req.tts && !i.erased_at ? req.tts.text : null,++ media_url: req.media && !i.erased_at ? req.media.url : null,++ public: ctx.view(i),++ };++ }++++ /** A creator's interactions for review, newest first. state: held | hidden | visible | all. */++ function queue(creator, { state = 'all', cursor, limit = 50 } = {}) {++ if (!['held', 'hidden', 'visible', 'all'].includes(state)) fail(422, 'tips.invalid_input', 'state must be held, hidden, visible or all');++ const n = Math.min(200, Math.max(1, Number(limit) || 50));++ let cur = null;++ if (cursor) { try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { fail(422, 'tips.invalid_input', 'bad cursor'); } }++ const rows = db.prepare(`SELECT * FROM tip_interactions WHERE creator_subject = @c AND payment_state IN ('settled', 'reversed', 'pending')++ AND (@s = 'all' OR moderation = @s) AND (@ca IS NULL OR created_at < @ca OR (created_at = @ca AND id < @ci))++ ORDER BY created_at DESC, id DESC LIMIT @n`).all({ c: creator, s: state, ca: cur ? cur[0] : null, ci: cur ? cur[1] : null, n: n + 1 });++ const page = rows.slice(0, n);++ return { rows: page.map(present), next_cursor: rows.length > n ? Buffer.from(JSON.stringify([page[n - 1].created_at, page[n - 1].id])).toString('base64url') : null };++ }++++ const log = (creator, limit = 100) => db.prepare('SELECT interaction_id, action, by_role, actor, reason, created_at FROM tip_moderation_log WHERE creator_subject = ? ORDER BY id DESC LIMIT ?').all(creator, limit);++++ return {++ isModerator, listModerators, moderatedBy, addModerator, removeModerator, presentModerator,++ createInvite, findInvite, acceptInvite, openInvites, revokeInvite,++ screen, markScreened, recordScreened, actorFor, hide, restore, present, queue, log,++ };++}++++module.exports = { createModeration, INVITE_RE };@@@ -16,14 +16,15 @@* 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.++ * "Anonymous", a hidden amount is null, a private message is absent, blocked words are starred out.++ * refreshInteraction() rewrites the stored payloads when that view changes (an erasure), so a replay++ * or /state never shows the old one. retract() (a moderator hid it) marks the alert hidden — never++ * sent, replayed or listed again — and tells connected overlays to drop it (`retract` event).*/const crypto = require('crypto');const { fail, iso, prefixedId, sha256, json, text } = require('../util');const { safeUrl } = require('./profiles');--const { publicView, publicName } = require('./privacy');++const { isHidden } = require('./privacy');const SCOPES = ['alerts', 'goals'];const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;@@@ -162,7 +163,7 @@ function createOverlays(ctx) {/** Inside the settlement transaction (or a simulation). */function addAlert(interaction) {-- const payload = publicView(interaction, { at: iso(ctx.now()) });++ const payload = ctx.view(interaction, { at: iso(ctx.now()) });return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });}@@@ -171,13 +172,33 @@ function createOverlays(ctx) {* the goal updates it caused no longer name anyone.*/function refreshInteraction(interaction) {++ const view = ctx.view(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) };++ const payload = row.kind === 'alert' ? ctx.view(interaction, { at: old.at }) : { ...old, by: interaction.hide_amount || isHidden(interaction) ? null : view.supporter_name };db.prepare('UPDATE overlay_deliveries SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);}}++ /** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */++ function retract(interaction) {++ db.prepare("UPDATE overlay_deliveries SET hidden = 1 WHERE interaction_id = ? AND kind = 'alert'").run(interaction.id);++ refreshInteraction(interaction);++ const rows = db.prepare("SELECT id, seq FROM overlay_deliveries WHERE interaction_id = ? AND kind = 'alert'").all(interaction.id);++ ctx.afterCommit(() => {++ for (const c of clients.get(interaction.creator_subject) || []) {++ if (!c.scopes.includes('alerts')) continue;++ for (const r of rows) write(c, 'retract', null, { interaction_id: interaction.id, delivery_id: r.id, seq: r.seq });++ }++ });++ }++++ /** Inside a transaction: shown again. Back in /state and replays; open overlays are not re-alerted. */++ function unretract(interaction) {++ db.prepare("UPDATE overlay_deliveries SET hidden = 0 WHERE interaction_id = ? AND kind = 'alert'").run(interaction.id);++ refreshInteraction(interaction);++ }++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 });}@@@ -198,7 +219,7 @@ function createOverlays(ctx) {/** Pending rows older than the window → failed (tips.overlay.failed). Returns how many. */function sweepFailed() {const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);-- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND hidden = 0 AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);if (!rows.length) return 0;ctx.tx(() => {for (const row of rows) {@@@ -228,7 +249,7 @@ function createOverlays(ctx) {function send(client, row) {client.lastSeq = Math.max(client.lastSeq, row.seq);-- if (!client.scopes.includes(scopeOf(row.kind))) return;++ if (row.hidden || !client.scopes.includes(scopeOf(row.kind))) return;const payload = json(row.payload, {});if (row.kind === 'alert' && client.minAmount && !row.test) {// A hidden amount is not in the payload; the threshold still applies to the real one.@@@ -241,7 +262,7 @@ function createOverlays(ctx) {}function pushNew(client) {-- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' AND hidden = 0 ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);for (const row of rows) send(client, row);}@@@ -272,13 +293,13 @@ function createOverlays(ctx) {const last = Number(lastEventId);if (lastEventId != null && Number.isInteger(last) && last >= 0) {const limit = ctx.config.overlays.replayLimit;-- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' AND hidden = 0 ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();client.lastSeq = rows.length ? rows[0].seq - 1 : maxSeq;for (const row of rows) send(client, row);client.lastSeq = Math.max(client.lastSeq, maxSeq);} else {const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);-- const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);++ const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND hidden = 0 AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);for (const row of pending) send(client, row);client.lastSeq = Math.max(client.lastSeq, maxSeq);}@@@ -311,13 +332,13 @@ function createOverlays(ctx) {function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }function recentDeliveries(creator, limit = 50) {-- return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);++ return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, hidden, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);}return {SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,getConfig, listConfigs, createConfig, updateConfig, presentConfig,-- addAlert, refreshInteraction, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,++ addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,};}@@@ -20,9 +20,13 @@* 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.++ * supporters and goals pages, and the `public` block of API answers. It also applies moderation++ * (moderation.js): a held or hidden interaction shows nothing but that it is hidden, and the creator's++ * word filter (filter.js) stars blocked words out of the name and message and drops them from what++ * text-to-speech reads.*/const { fail, json } = require('../util');++const filter = require('./filter');const ANONYMOUS = 'Anonymous';const FLAGS = ['anonymous', 'hide_amount', 'private_message'];@@@ -50,27 +54,42 @@ function publicName(i) {return i.supporter_name || 'Someone';}++const isHidden = (i) => !!i.moderation && i.moderation !== 'visible';++++/** The name as the public sees it, through the creator's filter (`settings` from profiles.filterOf). */++function shownName(i, settings) {++ if (isAnonymous(i)) return ANONYMOUS;++ return filter.apply(i.supporter_name || 'Someone', settings).text;++}++/*** The public shape of an interaction. `at` is kept from an earlier overlay payload when given, so a-- * rewritten alert keeps its time.++ * rewritten alert keeps its time; `filter` is the creator's filter settings.*/--function publicView(i, { at } = {}) {++function publicView(i, { at, filter: settings = null } = {}) {const req = json(i.request, {});const privateMsg = !!i.private_message || !!i.erased_at;-- return {++ const base = {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,};++ if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, hidden: true };++ const message = privateMsg || !i.message ? null : filter.apply(i.message, settings).text;++ const tts = i.kind === 'tts' && req.tts && !i.erased_at ? { text: filter.apply(req.tts.text, settings, 'speech').text, voice: req.tts.voice } : null;++ return {++ ...base,++ supporter_name: shownName(i, settings),++ message,++ tts,++ media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,++ hidden: false,++ };}--module.exports = { ANONYMOUS, FLAGS, parsePrivacy, publicName, publicView, privacyOf, isAnonymous, truthy };++module.exports = { ANONYMOUS, FLAGS, parsePrivacy, publicName, shownName, publicView, privacyOf, isAnonymous, isHidden, truthy };@@@ -15,13 +15,18 @@* 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++ *++ * filter_words / filter_action / filter_links are the creator's word filter for paid messages++ * (filter.js; `filter` in the API): filterOf(subject) is what every public view applies.*/const { fail, iso, text, json } = require('../util');++const filterLib = require('./filter');const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;// Paths the site itself uses; no creator page can take them.const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',-- 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www']);++ 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',++ 'moderate', 'supporters', 'metrics']);const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];function normalizeHandle(v) {@@@ -41,7 +46,11 @@ function pageSettings(raw) {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'), page: pageSettings(row.page_settings) };++ 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),++ filter: { words: json(row.filter_words, []), action: filterLib.ACTIONS.includes(row.filter_action) ? row.filter_action : 'mask', links: row.filter_links !== 0 },++ };}function createProfiles(ctx) {@@@ -114,6 +123,18 @@ function createProfiles(ctx) {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.filter !== undefined) {++ const f = patch.filter;++ if (!f || typeof f !== 'object' || Array.isArray(f)) fail(422, 'tips.invalid_input', 'filter must be an object { words, action, links }');++ const unknown = Object.keys(f).filter((k) => !['words', 'action', 'links'].includes(k));++ if (unknown.length) fail(422, 'tips.invalid_input', `filter has no setting ${unknown[0]}`);++ if (f.words !== undefined) set.filter_words = JSON.stringify(filterLib.normalizeWords(f.words));++ if (f.action !== undefined) {++ if (!filterLib.ACTIONS.includes(f.action)) fail(422, 'tips.invalid_input', `filter.action must be one of ${filterLib.ACTIONS.join(', ')}`);++ set.filter_action = f.action;++ }++ if (f.links !== undefined) set.filter_links = f.links === true || f.links === 'on' || f.links === '1' || f.links === 1 ? 1 : 0;++ }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');@@@ -139,11 +160,18 @@ function createProfiles(ctx) {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 });++ if (owner) Object.assign(out, { filter: p.filter, revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });return out;}-- return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle };++ /** The creator's filter settings for publicView() (null when they have no profile). */++ function filterOf(subject) {++ const row = db.prepare('SELECT filter_words, filter_action, filter_links FROM creator_tip_profiles WHERE creator_subject = ?').get(subject);++ if (!row) return null;++ return { words: json(row.filter_words, []), action: row.filter_action === 'hold' ? 'hold' : 'mask', links: row.filter_links !== 0 };++ }++++ return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle, filterOf };}function safeUrl(u) {@@@ -167,6 +167,7 @@ ${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was c<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>` : ''}++ ${i.moderation && i.moderation.state !== 'visible' ? `<dt>Moderation</dt><dd>${esc(MOD_LABEL[i.moderation.state])} by the creator's moderation; the payment is not affected</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>` : ''}@@@ -181,7 +182,7 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;}--function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter }) {++function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {} }) {const p = profile;const f = (k) => (p[k] ? ' checked' : '');const alertCfg = configs.find((c) => c.kind === 'alerts');@@@ -256,6 +257,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}<button class="btn ghost" type="submit">Save alert settings</button></form>` : ''}</section>++${moderationCard({ p, csrf, idem, mod })}<section class="card"><h2>Test your setup</h2><p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p><form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}@@@ -273,6 +275,71 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}</section>`;}++const MOD_LABEL = { visible: 'Shown', held: 'Held for review', hidden: 'Hidden' };++const ROLE_LABEL = { filter: 'word filter', creator: 'you', moderator: 'a moderator', service: 'a service' };++++/** The dashboard's moderation card: the word filter, moderators and invitations, the latest outcomes. */++function moderationCard({ p, csrf, idem, mod }) {++ const fl = p.filter;++ const moderators = mod.moderators || [];++ const invites = mod.invites || [];++ return `<section class="card" id="moderation"><h2>Moderation</h2>++ <p>${mod.held ? `<b>${n(mod.held)}</b> paid message${mod.held === 1 ? ' is' : 's are'} held for review. ` : ''}<a href="/moderate/${esc(p.handle)}">Review paid messages</a> — hide one from overlays and your pages, or show a held one. Hiding never refunds or uncounts a payment.</p>++ <form method="post" action="/dashboard/filter">${hidden({ csrf, idem: `${idem}-f` })}++ <h3>Word filter</h3>++ <label>Blocked words or phrases, one per line <small>(matched as whole words, any letter case; only you see this list)</small><textarea name="words" rows="4" maxlength="12000">${esc(fl.words.join('\n'))}</textarea></label>++ <label>When a paid message or a name contains one <select name="action"><option value="mask"${fl.action === 'mask' ? ' selected' : ''}>star the words out (and skip them in text-to-speech)</option><option value="hold"${fl.action === 'hold' ? ' selected' : ''}>hold it until you or a moderator reviews it</option></select></label>++ <label><input type="checkbox" name="links" value="1"${fl.links ? ' checked' : ''}> Replace links in paid messages with [link]</label>++ <button class="btn ghost" type="submit">Save the filter</button>++ </form>++ <h3>Moderators</h3>++ ${moderators.length ? `<ul class="plain">${moderators.map((m) => `<li>${esc(m.name || m.moderator.id)} <small>since ${when(m.added_at)}</small> <form class="inline" method="post" action="/dashboard/moderators/${esc(m.moderator.id)}/remove">${hidden({ csrf, idem: `${idem}-m-${m.moderator.id}` })}<button class="btn ghost" type="submit">Remove</button></form></li>`).join('')}</ul>` : '<p>No moderators yet.</p>'}++ ${invites.length ? `<p class="fine">Open invitations: ${invites.map((x) => `created ${when(x.created_at)}, expires ${when(x.expires_at)} <form class="inline" method="post" action="/dashboard/moderator-invites/${esc(x.id)}/revoke">${hidden({ csrf, idem: `${idem}-ri-${x.id}` })}<button class="btn ghost" type="submit">Revoke</button></form>`).join(' · ')}</p>` : ''}++ <form method="post" action="/dashboard/moderator-invites">${hidden({ csrf, idem: `${idem}-i` })}<button class="btn ghost" type="submit">Create a moderator invitation link</button></form>++ ${mod.log && mod.log.length ? `<details><summary>Latest moderation</summary><table class="list"><thead><tr><th>When</th><th>What</th><th>By</th><th>Receipt</th></tr></thead><tbody>${mod.log.map((l) => `<tr><td>${when(l.created_at)}</td><td>${esc(l.action)}</td><td>${esc(ROLE_LABEL[l.by_role] || l.by_role)}</td><td><a href="/receipts/${esc(l.interaction_id)}"><code>${esc(l.interaction_id.slice(-8))}</code></a></td></tr>`).join('')}</tbody></table></details>` : ''}++ ${mod.moderating && mod.moderating.length ? `<p class="fine">You moderate: ${mod.moderating.map((c) => `<a href="/moderate/${esc(c.handle)}">${esc(c.display_name)}</a>`).join(', ')}</p>` : ''}++</section>`;++}++++/** /moderate/<handle>: paid messages for the creator and their moderators to review. */++function moderatePage({ profile, rows, state, next, csrf, idem, flash, error, isCreator }) {++ const tab = (k, label) => `<a href="/moderate/${esc(profile.handle)}?state=${k}"${state === k ? ' aria-current="page"' : ''}>${label}</a>`;++ const item = (r) => {++ const what = [r.message && `<p class="msg">${esc(r.message)}</p>`, r.tts_text && `<p class="msg"><small>Read out:</small> ${esc(r.tts_text)}</p>`, r.media_url && `<p class="msg"><small>Media:</small> ${esc(r.media_url)}</p>`].filter(Boolean).join('');++ const act = r.moderation.state === 'visible' ? 'hide' : 'restore';++ const btn = r.moderation.state === 'held' ? 'Show it now' : act === 'hide' ? 'Hide' : 'Show again';++ return `<li class="mod-item ${esc(r.moderation.state)}${r.test ? ' test' : ''}">++ <div><b>${esc(r.supporter_name)}</b> · ${esc(KIND_LABEL[r.kind])}${r.amount != null ? ` · ${n(r.amount)} Vibes` : ''} · <small>${when(r.created_at)}${r.test ? ' · simulation' : ''}${r.payment_state === 'pending' ? ' · payment pending' : ''}</small>++ <span class="pill">${esc(MOD_LABEL[r.moderation.state])}${r.moderation.filtered ? ' · filtered' : ''}</span></div>++ ${what || '<p class="msg"><small>No message.</small></p>'}++ <form class="inline" method="post" action="/moderate/${esc(profile.handle)}/${esc(r.id)}/${act}">${hidden({ csrf, idem: `${idem}-${r.id}` })}++ ${act === 'hide' ? '<label>Note <small>(optional, only in your log)</small> <input type="text" name="reason" maxlength="200"></label>' : ''}++ <button class="btn${act === 'hide' ? ' ghost' : ''}" type="submit">${btn}</button></form>++</li>`;++ };++ return `<h1>Paid messages — ${esc(profile.display_name)}</h1>++${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}++<p class="fine">${isCreator ? 'You and your moderators' : `You moderate ${esc(profile.display_name)}'s page. You`} can hide a paid message from overlays, chat still to come and the public pages, or show one the word filter held. The payment is never touched. Private messages and hidden amounts stay private here too.</p>++<nav class="tabs">${tab('held', 'Held')} ${tab('visible', 'Shown')} ${tab('hidden', 'Hidden')} ${tab('all', 'All')}</nav>++${rows.length ? `<ul class="mod-list">${rows.map(item).join('')}</ul>` : '<p>Nothing here.</p>'}++${next ? `<p><a href="/moderate/${esc(profile.handle)}?state=${esc(state)}&cursor=${esc(next)}">Older</a></p>` : ''}`;++}++++function invitePage({ profile, csrf, idem, secret, isSelf }) {++ return `<h1>Moderate ${esc(profile.display_name)}</h1>++<section class="card">++ ${isSelf ? '<p>This invitation is for your own page: you moderate it already.</p>' : `<p>${esc(profile.display_name)} invited you to moderate the paid messages on their OpenVibe.Tips page: you will be able to hide a paid message from their overlays and pages, and show one their word filter held. You will not see payments, private messages or hidden amounts.</p>++ <form method="post" action="/moderate/invite/${esc(secret)}">${hidden({ csrf, idem })}<button class="btn" type="submit">Become a moderator</button></form>`}++</section>`;++}++++function inviteCreated({ out }) {++ return `<h1>Moderator invitation</h1>++<p class="notice">Copy it now: this is the only time it is shown. Whoever opens it signed in becomes one of your moderators, so send it only to them. It works once and expires ${when(out.expires_at)}.</p>++<p><label>Invitation link<input class="copy" type="text" readonly value="${esc(out.url)}" onclick="this.select()"></label></p>++<p><a class="btn" href="/dashboard#moderation">Back to the dashboard</a></p>`;++}++function tokenCreated({ out }) {return `<h1>Overlay link created</h1><p class="notice">Copy it now: this is the only time it is shown. Anyone with the link can see your alerts and goals (nothing else), so treat it like a password and revoke it if it leaks.</p>@@@ -284,4 +351,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, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };++module.exports = { home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };@@@ -12,6 +12,9 @@* GET /:handle creator page: goals + tip form (indexable only when the page is on)* 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++ * GET /moderate/:handle paid messages to review (the creator and their moderators)++ * POST /moderate/:handle/:id/hide|restore++ * GET|POST /moderate/invite/:token accept a moderator invitation (show-once link, never logged by nginx)* 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)@@@ -26,12 +29,14 @@ 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 FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay',++ 'Filter saved', 'Moderator removed', 'Invitation revoked']);++const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);function createWebRoutes({ domain, config, layout, userAuth }) {const r = express.Router();-- const { profiles, goals, interactions, overlays, db } = domain;++ const { profiles, goals, interactions, overlays, moderation, db } = domain;const withViewer = viewerMiddleware(userAuth);const form = express.urlencoded({ extended: false, limit: '32kb' });const secret = config.formSecret || crypto.randomBytes(32).toString('hex');@@@ -62,7 +67,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {html(res, pageFor(req, { active: 'home', canonicalPath: '/', body: pages.home({ creators }) }));});r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(-- `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));++ `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /moderate/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));r.get('/sitemap.xml', (req, res) => {const rows = db.prepare('SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY handle').all();const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];@@@ -133,6 +138,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {configs: overlays.listConfigs(p.creator_subject).map(overlays.presentConfig), totals: interactions.totals(p.creator_subject), recent,deliveries: overlays.recentDeliveries(p.creator_subject, 20), csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,connected: overlays.connected(p.creator_subject), chatAdapter: config.chat.adapter,++ mod: {++ held: db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE creator_subject = ? AND moderation = 'held' AND payment_state = 'settled'").get(p.creator_subject).n,++ moderators: moderation.listModerators(p.creator_subject).map(moderation.presentModerator), invites: moderation.openInvites(p.creator_subject),++ log: moderation.log(p.creator_subject, 20), moderating: moderation.moderatedBy(req.viewer.subject),++ },}),}), status);}@@@ -195,6 +205,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {overlays.updateConfig(c, { ...b, show_message: bool(b.show_message), speak_message: bool(b.speak_message), show_amount: bool(b.show_amount) });return { done: 'Alert settings saved' };});++ action('/dashboard/filter', (req, p) => {++ profiles.update(p.creator_subject, { filter: { words: req.body.words || '', action: req.body.action, links: bool(req.body.links) } });++ return { done: 'Filter saved' };++ });++ action('/dashboard/moderators/:subject/remove', (req, p) => { moderation.removeModerator(p.creator_subject, req.params.subject); return { done: 'Moderator removed' }; });++ action('/dashboard/moderator-invites', (req, p) => {++ const out = moderation.createInvite(p.creator_subject, { createdBy: req.viewer.subject });++ return { render: (res) => html(res, pageFor(req, { title: 'Moderator invitation', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', body: pages.inviteCreated({ out }) }), 201, { 'Cache-Control': 'no-store' }) };++ });++ action('/dashboard/moderator-invites/:id/revoke', (req, p) => { moderation.revokeInvite(p.creator_subject, req.params.id); return { done: 'Invitation revoked' }; });action('/dashboard/simulate', (req, p) => {const b = req.body;interactions.simulate(p, {@@@ -238,12 +258,84 @@ function createWebRoutes({ domain, config, layout, userAuth }) {const out = { creator: { type: 'user', id: t.creator_subject }, scopes: t.scopes };if (t.scopes.includes('goals')) out.goals = goals.list(t.creator_subject, { status: 'active' }).map((g) => goals.present(g));if (t.scopes.includes('alerts')) {-- out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' ORDER BY seq DESC LIMIT 20")++ out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' AND hidden = 0 ORDER BY seq DESC LIMIT 20").all(t.creator_subject).map((d) => ({ seq: d.seq, ...parseJson(d.payload, {}), test: !!d.test, created_at: d.created_at }));}res.set(overlayHeaders).json(out);});++ // ── Moderation (the creator and their moderators) ─────────++ // Invitation links carry a secret in the path, like overlay links: no-store, no referrer, noindex.++ const inviteHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };++ function inviteOr404(req, res) {++ const inv = moderation.findInvite(req.params.token);++ const profile = inv && profiles.bySubject(inv.creator_subject);++ if (!inv || !profile) {++ html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: 404, title: 'This invitation is not valid', message: 'It may have been used, revoked or expired. Ask the creator for a new one.' }) }), 404, inviteHeaders);++ return null;++ }++ return { inv, profile };++ }++ r.get('/moderate/invite/:token', withViewer, (req, res) => {++ if (!req.viewer) return res.set(inviteHeaders).redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const found = inviteOr404(req, res);++ if (!found) return undefined;++ return html(res, pageFor(req, {++ title: `Moderate ${found.profile.display_name}`, robots: 'noindex,nofollow', canonicalPath: '/',++ body: pages.invitePage({ profile: found.profile, csrf: csrfFor(req.viewer.subject), idem: idem(), secret: req.params.token, isSelf: found.profile.creator_subject === req.viewer.subject }),++ }), 200, inviteHeaders);++ });++ r.post('/moderate/invite/:token', withViewer, form, (req, res) => {++ if (!req.viewer) return res.set(inviteHeaders).redirect(303, '/auth/login');++ if (!csrfOk(req)) return html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: 403, title: 'That form expired', message: 'Open the invitation link again.' }) }), 403, inviteHeaders);++ try {++ const out = moderation.acceptInvite(req.params.token, { subject: req.viewer.subject, name: req.viewer.name || req.viewer.username });++ const profile = profiles.bySubject(out.creator);++ return res.set(inviteHeaders).redirect(303, `/moderate/${encodeURIComponent(profile.handle)}?done=${encodeURIComponent('You are now a moderator')}`);++ } catch (e) {++ if (e instanceof TipsError) return html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: e.status, title: 'This invitation is not valid', message: e.detail || e.message }) }), e.status, inviteHeaders);++ throw e;++ }++ });++++ /** The creator or one of their moderators, for /moderate/:handle; else null (the page is a 404). */++ function modAccess(req) {++ const p = profiles.byHandle(req.params.handle);++ if (!p || !req.viewer) return { p };++ const actor = moderation.actorFor(req.viewer, p.creator_subject, false);++ return { p, actor };++ }++ function renderModerate(req, res, p, { error, status = 200 } = {}) {++ const state = ['held', 'hidden', 'visible', 'all'].includes(req.query.state) ? req.query.state : 'all';++ const out = moderation.queue(p.creator_subject, { state, cursor: req.query.cursor, limit: 50 });++ const flash = MOD_FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null;++ html(res, pageFor(req, {++ title: `Paid messages — ${p.display_name}`, robots: 'noindex,nofollow', canonicalPath: `/moderate/${p.handle}`,++ body: pages.moderatePage({ profile: p, rows: out.rows, state, next: out.next_cursor, csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error, isCreator: req.viewer.subject === p.creator_subject }),++ }), status);++ }++ r.get('/moderate/:handle', withViewer, (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const { p, actor } = modAccess(req);++ if (!p || !actor) return notFound(req, res);++ return renderModerate(req, res, p);++ });++ r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, (req, res) => {++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);++ const { p, actor } = modAccess(req);++ if (!p || !actor) return notFound(req, res);++ if (!csrfOk(req)) return renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });++ const i = interactions.get(req.params.id);++ if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);++ try {++ moderation[req.params.act](i, { ...actor, reason: req.body.reason });++ } catch (e) {++ if (e instanceof TipsError) return renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });++ throw e;++ }++ return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);++ });++// ── Creator pages (last: /:handle catches everything else) ──const providers = config.billing.providers;function creatorView(req, res, { values, error, status = 200 } = {}) {nnew file mode 100644@@@ -0,0 +1,271 @@++'use strict';++// Moderation: the creator's word filter (mask or hold) before anything is shown or read; the creator,++// their moderators (invitation links) and granted services hide or show paid messages on overlays,++// chat still to come and public pages; the money is never touched; outcomes are logged and published++// as tips.interaction.moderated.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const registry = require('openvibe-contracts/lib/registry');++const { boot, check, done } = require('./helpers/app');++++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 || [] });++}++const validModerated = payloadValidator('tips.interaction.moderated');++++(async () => {++ const t = await boot();++ const { domain, billing } = t;++ const alex = await t.creator('alex', { settings: { tts_enabled: true, page: { supporters_page: true, supporters_messages: true } } });++ const viewer = t.network.newUser('viewer');++ const mod = t.network.newUser('modder');++ const stranger = t.network.newUser('stranger');++ billing.fund(viewer.subject, 100_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 pay = (kind, body) => t.call('POST', { tip: '/api/v1/checkout', paid_message: '/api/v1/paid-messages', tts: '/api/v1/tts-requests' }[kind], { user: viewer, body: { creator: 'alex', ...body } });++ const state = async () => (await t.call('GET', `/overlay/${secret}/state`, { token: null })).json.alerts;++ const jobsOf = (id) => t.adapters.test.jobs.filter((j) => j.interaction.id === id);++ const moderated = (id) => t.outboxRows('tips.interaction.moderated').filter((e) => e.subject.id === id).map((e) => e.payload);++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;++ const get = (p, u) => fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));++ const post = (p, u, form) => fetch(t.base + p, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie(u) }, body: new URLSearchParams(form).toString() })++ .then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));++ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };++++ await check('the filter settings are the creator\'s, validated, and never public', async () => {++ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { action: 'nuke' } } });++ assert.strictEqual(bad.status, 422);++ const long = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: ['x'.repeat(61)] } } });++ assert.strictEqual(long.status, 422);++ const ok = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: 'Badword\ntwo words, badword', action: 'mask' } } });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.deepStrictEqual(ok.json.profile.filter, { words: ['badword', 'two words'], action: 'mask', links: true });++ const pub = await t.call('GET', '/api/v1/profiles/alex', { token: null });++ assert.strictEqual(pub.json.profile.filter, undefined, 'the blocklist is private');++ });++++ await check('mask: blocked words are starred out of overlays, chat and pages, dropped from TTS; tricks do not get through', async () => {++ const r = await pay('paid_message', { amount: 200, supporter_name: 'badword fan', message: 'you BADWORD, two words and badword and badword https://phish.example/x ok' });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json.interaction.id;++ const a = (await state()).find((x) => x.interaction_id === id);++ assert.strictEqual(a.supporter_name, '******* fan');++ assert.strictEqual(a.message, 'you *******, ********** and ******* and ******* [link] ok');++ await domain.effects.drain();++ assert.match(jobsOf(id)[0].text, /^\*\*\*\*\*\*\* fan tipped 200 Vibes: you \*\*\*\*\*\*\*,/);++ assert.ok(!jobsOf(id)[0].text.includes('phish'));++ const own = (await t.call('GET', `/api/v1/interactions/${id}`, { user: alex })).json.interaction;++ assert.match(own.message, /BADWORD/, 'the creator reads the original');++ assert.strictEqual(own.moderation.filtered, true);++ assert.strictEqual(own.moderation.state, 'visible');++ const [ev] = moderated(id);++ assert.deepStrictEqual(ev, { interaction_id: id, creator: { type: 'user', id: alex.subject }, action: 'filtered', by: 'filter', moderation_state: 'visible', cancelled_effects: [] });++ const tts = await pay('tts', { amount: 150, tts: { text: 'say badword now please' } });++ await domain.effects.drain();++ assert.strictEqual(jobsOf(tts.json.interaction.id).find((j) => j.effect === 'tts').tts.text, 'say now please');++ const page = await get('/alex/supporters');++ assert.ok(!/badword/i.test(page.text.replace(/<style[\s\S]*?<\/style>/g, '')), 'the supporters page shows no blocked word');++ });++++ let heldId;++ await check('hold: nothing is shown, posted or read until someone reviews it; the money counts at once', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { action: 'hold' } } });++ const goalBefore = domain.goals.present(domain.goals.get(goal.id)).current_amount;++ const s = await t.sse(`/overlay/${secret}/events`);++ await s.waitFor((e) => e.event === 'hello');++ const r = await pay('tts', { amount: 300, supporter_name: 'Viewer', tts: { text: 'read this badword aloud' } });++ heldId = r.json.interaction.id;++ await domain.effects.drain();++ await new Promise((ok) => setTimeout(ok, 50));++ assert.ok(!s.events.some((e) => e.event === 'alert' && e.data.interaction_id === heldId), 'no overlay alert');++ s.close();++ assert.ok(!(await state()).some((x) => x.interaction_id === heldId));++ assert.strictEqual(jobsOf(heldId).length, 0, 'no chat line, no TTS');++ const i = domain.interactions.get(heldId);++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(i.moderation, 'held');++ assert.strictEqual(i.delivery_state, 'queued', 'waiting for review, not "delivered"');++ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, goalBefore + 300);++ assert.strictEqual(t.outboxRows('tips.interaction.ready').filter((e) => e.subject.id === heldId).length, 1);++ assert.deepStrictEqual(moderated(heldId).map((e) => [e.action, e.by, e.moderation_state]), [['held', 'filter', 'held']]);++ const q = await t.call('GET', '/api/v1/moderation?creator=alex&state=held', { user: alex });++ assert.deepStrictEqual(q.json.interactions.map((x) => x.id), [heldId]);++ assert.strictEqual(q.json.interactions[0].tts_text, 'read this badword aloud', 'reviewers read what was written');++ assert.strictEqual(q.json.interactions[0].public.hidden, true);++ });++++ await check('moderators: a show-once invitation link, accepted signed in; only the creator manages them', async () => {++ const dash = await get('/dashboard', alex);++ assert.match(dash.text, /Word filter/);++ const csrf = field(dash.text, 'csrf');++ const inv = await post('/dashboard/moderator-invites', alex, { csrf, idem: 'x' });++ assert.strictEqual(inv.status, 201);++ assert.strictEqual(inv.headers.get('cache-control'), 'no-store');++ const url = inv.text.match(/value="(http:\/\/tips\.test\/moderate\/invite\/tmin_[^"]+)"/)[1];++ const path = url.replace('http://tips.test', '');++ assert.ok(!(await get('/dashboard', alex)).text.includes(url), 'shown once');++ assert.ok(!JSON.stringify(t.db.prepare('SELECT * FROM tip_moderator_invites').all()).includes(path.split('/').pop()), 'stored hashed');++ assert.match((await get(path)).headers.get('location'), /^\/auth\/login/);++ const page = await get(path, mod);++ assert.strictEqual(page.status, 200);++ assert.strictEqual(page.headers.get('referrer-policy'), 'no-referrer');++ assert.match(page.text, /Become a moderator/);++ const forged = await post(path, mod, { csrf: 'nope', idem: 'x' });++ assert.strictEqual(forged.status, 403);++ const ok = await post(path, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });++ assert.strictEqual(ok.status, 303);++ assert.match(ok.headers.get('location'), /^\/moderate\/alex\?done=/);++ assert.strictEqual((await post(path, stranger, { csrf: field((await get('/alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');++ assert.strictEqual((await get('/api/v1/moderators?creator=alex', null)).status, 401);++ const list = await t.call('GET', '/api/v1/moderators', { user: alex });++ assert.deepStrictEqual(list.json.moderators.map((m) => m.moderator.id), [mod.subject]);++ assert.strictEqual((await t.call('GET', '/api/v1/moderators?creator=alex', { user: mod })).status, 403, 'moderators do not manage moderators');++ });++++ await check('a moderator shows a held message: it is released as settlement would have released it', async () => {++ const page = await get('/moderate/alex?state=held', mod);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /read this badword aloud/);++ const s = await t.sse(`/overlay/${secret}/events`);++ await s.waitFor((e) => e.event === 'hello');++ const r = await post(`/moderate/alex/${heldId}/restore`, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });++ assert.strictEqual(r.status, 303);++ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === heldId);++ s.close();++ assert.strictEqual(alert.data.tts.text, 'read this aloud', 'blocked words stay out of what is read');++ await domain.effects.drain();++ assert.deepStrictEqual(jobsOf(heldId).map((j) => j.effect).sort(), ['chat_line', 'tts']);++ const i = domain.interactions.get(heldId);++ assert.strictEqual(i.moderation, 'visible');++ assert.strictEqual(i.delivery_state, 'delivered');++ assert.deepStrictEqual(moderated(heldId).map((e) => [e.action, e.by, e.moderation_state]), [['held', 'filter', 'held'], ['restored', 'moderator', 'visible']]);++ });++++ let hiddenId;++ await check('hide: queued chat/TTS cancelled, overlay alert retracted (live, replay, /state), pages drop it; the money untouched', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: [] } } });++ const payableBefore = billing.payable.get(alex.subject);++ const s = await t.sse(`/overlay/${secret}/events`);++ await s.waitFor((e) => e.event === 'hello');++ const r = await pay('tts', { amount: 500, supporter_name: 'Viewer', message: 'nasty but not blocked', tts: { text: 'nasty but not blocked' } });++ hiddenId = r.json.interaction.id;++ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === hiddenId);++ const goalBefore = domain.goals.present(domain.goals.get(goal.id)).current_amount;++ const h = await t.call('POST', `/api/v1/interactions/${hiddenId}/hide`, { user: mod, body: { reason: 'abuse' } });++ assert.strictEqual(h.status, 200, h.text);++ assert.strictEqual(h.json.changed, true);++ assert.deepStrictEqual(h.json.cancelled_effects.sort(), ['chat_line', 'tts']);++ const retract = await s.waitFor((e) => e.event === 'retract' && e.data.interaction_id === hiddenId);++ assert.strictEqual(retract.data.delivery_id, alert.data.delivery_id);++ s.close();++ await domain.effects.drain();++ assert.strictEqual(jobsOf(hiddenId).length, 0, 'nothing was posted or read');++ const i = domain.interactions.get(hiddenId);++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(i.delivery_state, 'cancelled');++ assert.strictEqual(billing.payable.get(alex.subject), payableBefore + 500, 'the payment stands');++ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, goalBefore, 'the goal keeps it');++ assert.ok(!(await state()).some((x) => x.interaction_id === hiddenId));++ const replay = await t.sse(`/overlay/${secret}/events`, { lastEventId: alert.id - 1 });++ await replay.waitFor((e) => e.event === 'hello');++ await new Promise((ok) => setTimeout(ok, 50));++ assert.ok(!replay.events.some((e) => e.event === 'alert' && e.data.interaction_id === hiddenId), 'a replay skips it');++ replay.close();++ const sup = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;++ assert.ok(!sup.recent.some((v) => v.interaction_id === hiddenId));++ assert.strictEqual(t.outboxRows('tips.interaction.cancelled').filter((e) => e.subject.id === hiddenId).length, 0, 'not a payment cancellation');++ const [ev] = moderated(hiddenId);++ assert.deepStrictEqual({ ...ev, cancelled_effects: ev.cancelled_effects.sort() }, { interaction_id: hiddenId, creator: { type: 'user', id: alex.subject }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['chat_line', 'tts'] });++ assert.ok(!JSON.stringify(ev).includes('abuse') && !JSON.stringify(ev).includes(mod.subject), 'the event carries no note and no moderator');++ const again = await t.call('POST', `/api/v1/interactions/${hiddenId}/hide`, { user: alex });++ assert.strictEqual(again.json.changed, false);++ assert.strictEqual(moderated(hiddenId).length, 1, 'hiding twice publishes once');++ const receipt = (await t.call('GET', `/api/v1/interactions/${hiddenId}`, { user: viewer })).json.interaction;++ assert.strictEqual(receipt.moderation.state, 'hidden');++ assert.strictEqual(receipt.payment.state, 'settled');++ });++++ await check('restore after a hide: back on pages and overlay state; what was cancelled stays cancelled', async () => {++ const r = await t.call('POST', `/api/v1/interactions/${hiddenId}/restore`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok((await state()).some((x) => x.interaction_id === hiddenId));++ await domain.effects.drain();++ assert.strictEqual(jobsOf(hiddenId).length, 0, 'no late TTS');++ assert.ok((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json.recent.some((v) => v.interaction_id === hiddenId));++ assert.deepStrictEqual(moderated(hiddenId).map((e) => [e.action, e.by]), [['hidden', 'moderator'], ['restored', 'creator']]);++ });++++ await check('who may moderate: the creator, their moderators, services with tips.interaction.moderate; nobody else', async () => {++ const r = await pay('paid_message', { amount: 200, message: 'hello' });++ const id = r.json.interaction.id;++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: stranger })).status, 404);++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: viewer })).status, 404, 'the supporter cannot hide it either');++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { token: null })).status, 401);++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { cap: ['tips.interaction.get'] })).status, 403);++ const svc = await t.call('POST', `/api/v1/interactions/${id}/hide`, { cap: ['tips.interaction.moderate'], sub: 'svc:chat' });++ assert.strictEqual(svc.status, 200, svc.text);++ assert.strictEqual(moderated(id)[0].by, 'service');++ assert.strictEqual((await get('/moderate/alex', stranger)).status, 404);++ assert.strictEqual((await t.call('GET', '/api/v1/moderation?creator=alex', { user: stranger })).status, 403);++ const log = await t.call('GET', '/api/v1/moderation/log?creator=alex', { user: alex });++ assert.ok(log.json.log.some((l) => l.interaction_id === id && l.by_role === 'service' && l.actor === 'svc:chat'));++ assert.strictEqual((await t.call('GET', '/api/v1/moderation/log?creator=alex', { user: mod })).status, 403);++ // Removed: the moderator loses access at once.++ const dash = await get('/dashboard', alex);++ const rm = await post(`/dashboard/moderators/${mod.subject}/remove`, alex, { csrf: field(dash.text, 'csrf'), idem: 'x' });++ assert.strictEqual(rm.status, 303);++ assert.strictEqual((await get('/moderate/alex', mod)).status, 404);++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/restore`, { user: mod })).status, 404);++ });++++ await check('a tip hidden while its payment is pending delivers nothing once paid', async () => {++ const buyer = t.network.newUser('buyer');++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, message: 'later', pay_with: 'checkout', provider: 'stripe' } });++ const id = r.json.interaction.id;++ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: alex })).status, 200);++ await t.deliver(billing.settlePurchase(domain.interactions.get(id).billing_intent_id));++ await new Promise((ok) => setTimeout(ok, 50));++ await domain.interactions.processDueTransfers();++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(i.delivery_state, 'cancelled');++ await domain.effects.drain();++ assert.strictEqual(jobsOf(id).length, 0);++ assert.ok(!(await state()).some((x) => x.interaction_id === id));++ });++++ await check('simulations run through the filter too, and publish nothing', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: ['badword'], action: 'hold' } } });++ const r = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 200, message: 'a badword test' } });++ assert.strictEqual(r.json.interaction.moderation.state, 'held');++ assert.strictEqual(moderated(r.json.interaction.id).length, 0);++ const shown = await t.call('POST', `/api/v1/interactions/${r.json.interaction.id}/restore`, { user: alex });++ assert.strictEqual(shown.json.interaction.moderation.state, 'visible');++ assert.strictEqual((await state()).find((x) => x.interaction_id === r.json.interaction.id).message, 'a ******* test');++ // Held, then hidden, then shown: released once, and its delivery state follows.++ const r2 = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 200, message: 'another badword' } });++ const id2 = r2.json.interaction.id;++ await t.call('POST', `/api/v1/interactions/${id2}/hide`, { user: alex });++ assert.strictEqual(domain.interactions.get(id2).delivery_state, 'cancelled');++ await t.call('POST', `/api/v1/interactions/${id2}/restore`, { user: alex });++ await domain.effects.drain();++ assert.strictEqual(domain.interactions.get(id2).delivery_state, 'delivered');++ assert.strictEqual(jobsOf(id2).length, 1);++ assert.ok((await state()).some((x) => x.interaction_id === id2));++ });++++ 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 = e.event_type === 'tips.interaction.moderated' ? validModerated(e.payload) : payloadValidator(e.event_type)(e.payload);++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)} ${JSON.stringify(e.payload)}`);++ }++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -121,12 +121,18 @@ token as a Bearer and act on their own things only. The API never reads cookies.
@@ -121,12 +121,18 @@ token as a Bearer and act on their own things only. The API never reads cookies.
| `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 /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 /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 |
| `GET /me/export`, `POST /me/erase` | — (people only) | the supporter's own tips: download; erase their data from them |
+| `GET /moderation?creator=&state=held\|hidden\|visible\|all` | `tips.interaction.moderate` (proposed) | the creator and their moderators |
+| `POST /interactions/:id/hide`, `POST /interactions/:id/restore` `{reason?}` | `tips.interaction.moderate` (proposed) | the creator and their moderators |
+| `GET /moderation/log?creator=` | `tips.interaction.moderate` (proposed) | owner |
+| `GET\|POST /moderators`, `POST /moderators/:subject/remove` | `tips.profile.update` | owner |
| `POST /goals`, `PATCH /goals/:id`, `POST /goals/:id/close` | `tips.goal.create` / `.update` / `.close` | owner |
| `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\|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 |
| `GET /overlay-configs[/:id]`, `POST /overlay-configs`, `PATCH /overlay-configs/:id` | `tips.overlay.config.get` / `.update` | owner |
| `POST /simulate` | `tips.simulation.run` | owner |
| `POST /simulate` | `tips.simulation.run` | owner |
| `POST /internal/events` | Events webhook signature (`TIPS_EVENTS_SECRET`), loopback only | — |
| `POST /internal/events` | Events webhook signature (`TIPS_EVENTS_SECRET`), loopback only | — |
+`tips.interaction.moderate` is proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
+not in openvibe-contracts yet (grants are matched by string, so a Network grant of it works today).
The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids:
The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids:
the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL
the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL
tips); the drafts stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
tips); the drafts stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
@@ -167,15 +173,42 @@ Tips still waiting for their payment are kept until it settles or fails. Each er
@@ -167,15 +173,42 @@ Tips still waiting for their payment are kept until it settles or fails. Each er
`tips.interaction.erased`, whose `redacts` has OpenVibe.Events tombstone Tips' earlier events about it.
`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.
Stored API answers (Idempotency-Key replays) are pruned after a week.
+## Moderation
+
+[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).
+The money is never touched: hiding a paid message does not refund it, and a hidden tip still counts
+toward its goal and the creator's totals.
+
+- **Word filter** (`filter: { words, action, links }` on the profile, the dashboard's *Moderation* card;
+ the list is the creator's only). Blocked words or phrases match whole, in any letter case, after
+ Unicode NFKC folding and with invisible and direction-override characters removed, in the name and in
+ what is shown or read. `mask` (default) stars them out of overlays, chat lines and pages and leaves
+ them out of what text-to-speech reads; `hold` holds a paid message that matches before anything is
+ shown, posted or read, until the creator or a moderator shows it (then it is released as settlement
+ would have released it, still masked). Links in paid messages read `[link]` unless the creator turns
+ that off.
+- **Hide / show.** The creator, their moderators and services with `tips.interaction.moderate` hide a
+ paid message: its queued chat, TTS and media deliveries are cancelled (never delivered), its overlay
+ alert is retracted (connected overlays get a `retract` event and drop it, replays and `/state` skip
+ it) and public pages leave it out. Showing it again puts it back on pages and in overlay state; what
+ the hide cancelled stays cancelled. `/moderate/<handle>` is the review page (held, shown, hidden).
+- **Moderators** are added by the creator: a show-once invitation link from the dashboard (hashed at rest,
+ single use, 7 days, never in nginx's log), accepted signed in; or `POST /moderators` by the creator or
+ a service. Moderators see what was written but never private messages, hidden amounts or payments.
+- Every outcome is in `tip_moderation_log` (with the moderator and their note) and is published as
+ `tips.interaction.moderated` (below), which carries no supporter, message, note or moderator.
+
## Events
## Events
Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, same transaction as the
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`,
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.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,
-`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:
+`tips.overlay.failed`, and two whose schemas are proposed in [docs/events-proposal/](docs/events-proposal/)
+(not yet in openvibe-contracts): `tips.interaction.erased` (a supporter's erasure; `{ interaction_id,
+creator, erased_at, redacts }`) and `tips.interaction.moderated` (`{ interaction_id, creator, action:
+filtered|held|hidden|restored, by: filter|creator|moderator|service, moderation_state, cancelled_effects }`).
+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.transaction.settled`, `billing.transaction.reversed`, `billing.receipt.external` (only from source
`billing`). `billing.receipt.external` (payload: `streamer` SubjectRef, `amount_cents`, `value_bits`,
`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`,
`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
@@ -187,7 +220,8 @@ id mapped by the Live import; otherwise Live's rule (the only active goal).
@@ -187,7 +220,8 @@ id mapped by the Live import; otherwise Live's rule (the only active goal).
## Overlays
## Overlays
`GET /overlay/<token>` is an OBS Browser Source page; `/overlay/<token>/events` is the SSE stream
`GET /overlay/<token>` is an OBS Browser Source page; `/overlay/<token>/events` is the SSE stream
-(`hello` with config and active goals, then `alert` and `goal`, `config` on changes, `revoked`), and
+(`hello` with config and active goals, then `alert` and `goal`, `config` on changes, `retract` when a
+moderator hides an alert, `revoked`), and
`/overlay/<token>/state` a read-only JSON for other overlay clients (e.g. Live). Tokens are scoped
`/overlay/<token>/state` a read-only JSON for other overlay clients (e.g. Live). Tokens are scoped
(`alerts`, `goals`), shown once, stored as SHA-256, revocable (open streams close at once) and never a
(`alerts`, `goals`), shown once, stored as SHA-256, revocable (open streams close at once) and never a
cookie. Every alert and goal change is one `overlay_deliveries` row with a monotonic `seq` (the SSE id):
cookie. Every alert and goal change is one `overlay_deliveries` row with a monotonic `seq` (the SSE id):
@@ -217,7 +251,8 @@ no chat effects, and it is what production runs. When OpenVibe.Chat publishes a
@@ -217,7 +251,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
`/` · `/<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` ·
page on, otherwise 404 for everyone else and a `noindex` preview for the creator) · `/<handle>/goals` ·
`/<handle>/supporters` (when the creator shows it) · `/receipts`, `/receipts/:id` (payment and delivery
`/<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,
+shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`
+(the creator and their moderators), `/moderate/invite/<secret>` · `/dashboard` (page settings, public pages, moderation,
goals, overlay links shown once, alert settings, simulation, totals, recent tips, overlay deliveries) ·
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`.
`/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
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. Shared chrome: Network
@@ -249,6 +284,7 @@ a copy of Billing's database for the links and totals:
@@ -249,6 +284,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) |
| 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` |
| 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) |
| 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) |
+| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event 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) |
| 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
## What waits
@@ -7,6 +7,6 @@
@@ -7,6 +7,6 @@
"adr": "ADR-012",
"adr": "ADR-012",
"authoritative": false,
"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.",
"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. 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.",
+ "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). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). Not done: 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"
"updated": "2026-09-23"
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
## holds (until then the domain keeps its OpenVibe.Sites placeholder). The Node app (port 4610,
## holds (until then the domain keeps its OpenVibe.Sites placeholder). The Node app (port 4610,
## unit openvibe-tips) renders every page, serves the API and the overlay streams. nginx terminates
## unit openvibe-tips) renders every page, serves the API and the overlay streams. nginx terminates
## TLS, redirects www → apex, rate-limits auth and API, keeps overlay streams unbuffered, keeps
## TLS, redirects www → apex, rate-limits auth and API, keeps overlay streams unbuffered, keeps
-## overlay tokens out of the access log, and never exposes /internal/ (OpenVibe.Events calls
+## overlay tokens and moderator invitation links out of the access log, and never exposes /internal/ (OpenVibe.Events calls
## 127.0.0.1:4610 directly) or /metrics (host-local scrapers only; Node also refuses any request
## 127.0.0.1:4610 directly) or /metrics (host-local scrapers only; Node also refuses any request
## carrying X-Forwarded-For).
## carrying X-Forwarded-For).
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
@@ -98,8 +98,22 @@ server {
@@ -98,8 +98,22 @@ server {
proxy_read_timeout 1h;
proxy_read_timeout 1h;
}
}
+ # Moderator invitation links carry a show-once secret in the path: not logged either (^~ so the
+ # form-post regex below does not take them).
+ location ^~ /moderate/invite/ {
+ access_log off;
+ limit_req zone=ovtips_forms burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# Tip and dashboard form posts.
# Tip and dashboard form posts.
- location ~ ^/(dashboard/|[^/]+/tip$) {
+ location ~ ^/(dashboard/|moderate/|receipts/erase$|[^/]+/tip$) {
limit_req zone=ovtips_forms burst=10 nodelay;
limit_req zone=ovtips_forms burst=10 nodelay;
proxy_pass http://127.0.0.1:4610;
proxy_pass http://127.0.0.1:4610;
proxy_http_version 1.1;
proxy_http_version 1.1;
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "tips.interaction.moderate",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Moderate a creator's paid messages for them: read the review queue, hide a paid message from overlays, chat still to come and public pages, or show again one that was hidden or that the creator's word filter held. Never touches the money (no refund, no uncounting). People moderate as the creator or one of the creator's moderators; a service (e.g. Chat or Live relaying a channel moderator's action) needs this capability. Not in the charter list: proposed for the roadmap's \"privacy/moderation controls\" (Wave 9).",
+ "permissions": [
+ "tips:interactions"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "standard",
+ "events": [
+ "tips.interaction.moderated"
+ ],
+ "implementedBy": [
+ "GET /api/v1/moderation",
+ "GET /api/v1/moderation/log",
+ "POST /api/v1/interactions/:id/hide",
+ "POST /api/v1/interactions/:id/restore"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",
+ "title": "TipsInteractionModeratedPayload",
+ "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",
+ "type": "object",
+ "required": ["interaction_id", "creator", "action", "by", "moderation_state", "cancelled_effects"],
+ "additionalProperties": false,
+ "properties": {
+ "interaction_id": { "type": "string", "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "creator": { "$ref": "../../identity/subject-ref.v1.json" },
+ "action": { "enum": ["filtered", "held", "hidden", "restored"] },
+ "by": { "enum": ["filter", "creator", "moderator", "service"], "description": "Who acted, by role." },
+ "moderation_state": { "enum": ["visible", "held", "hidden"], "description": "The interaction's moderation state after this outcome." },
+ "cancelled_effects": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": { "enum": ["chat_line", "paid_message", "tts", "media_request"] },
+ "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."
+ }
+ }
+}
@@ -30,7 +30,8 @@
@@ -30,7 +30,8 @@
"tips.overlay.token.revoke",
"tips.overlay.token.revoke",
"tips.overlay.config.get",
"tips.overlay.config.get",
"tips.overlay.config.update",
"tips.overlay.config.update",
- "tips.simulation.run"
+ "tips.simulation.run",
+ "tips.interaction.moderate"
],
],
"eventsProduced": [
"eventsProduced": [
"tips.interaction.ready",
"tips.interaction.ready",
@@ -39,7 +40,8 @@
@@ -39,7 +40,8 @@
"tips.goal.updated",
"tips.goal.updated",
"tips.overlay.delivered",
"tips.overlay.delivered",
"tips.overlay.failed",
"tips.overlay.failed",
- "tips.interaction.erased"
+ "tips.interaction.erased",
+ "tips.interaction.moderated"
],
],
"eventsConsumed": [
"eventsConsumed": [
"billing.transaction.settled",
"billing.transaction.settled",
@@ -69,3 +69,11 @@ ul.wall { list-style: none; padding: 0; }
@@ -69,3 +69,11 @@ ul.wall { list-style: none; padding: 0; }
ul.wall li { border-bottom: 1px solid var(--tips-border); padding: 8px 0; }
ul.wall li { border-bottom: 1px solid var(--tips-border); padding: 8px 0; }
ul.wall p { margin: 4px 0; overflow-wrap: anywhere; }
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; } }
@media (max-width: 560px) { dl.receipt { grid-template-columns: 1fr; } .creator-head { flex-direction: column; align-items: flex-start; } table.list { font-size: .85rem; } }
+.pill { display: inline-block; font-size: .8rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--tips-border); color: var(--tips-muted); margin-left: 6px; }
+nav.tabs { display: flex; gap: 14px; margin: 12px 0; }
+nav.tabs a[aria-current] { font-weight: 700; text-decoration: none; color: var(--tips-text); }
+ul.mod-list, ul.plain { list-style: none; padding: 0; }
+.mod-item { border-bottom: 1px solid var(--tips-border); padding: 10px 0; }
+.mod-item.hidden .msg, .mod-item.test { color: var(--tips-muted); }
+.mod-item.held { border-left: 3px solid var(--tips-bad); padding-left: 10px; }
+.mod-item .msg { margin: 6px 0; overflow-wrap: anywhere; }
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
var goals = {};
var goals = {};
var queue = [];
var queue = [];
var busy = false;
var busy = false;
+ var current = null;
+ var retracted = {};
var shown = {};
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' };
var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media' };
@@ -33,6 +35,7 @@
@@ -33,6 +35,7 @@
if (busy || !queue.length) return;
if (busy || !queue.length) return;
busy = true;
busy = true;
var a = queue.shift();
var a = queue.shift();
+ current = a;
var s = config.settings || {};
var s = config.settings || {};
alertEl.textContent = '';
alertEl.textContent = '';
var box = el('div', 'box');
var box = el('div', 'box');
@@ -57,8 +60,9 @@
@@ -57,8 +60,9 @@
try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }
try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }
}
}
setTimeout(function () {
setTimeout(function () {
+ if (current !== a) return; // retracted while on screen: already cleared
alertEl.className = '';
alertEl.className = '';
- setTimeout(function () { busy = false; next(); }, 400);
+ setTimeout(function () { busy = false; current = null; next(); }, 400);
}, Number(s.duration_ms) || 8000);
}, Number(s.duration_ms) || 8000);
}
}
@@ -73,7 +77,7 @@
@@ -73,7 +77,7 @@
es.addEventListener('config', function (e) { var d = JSON.parse(e.data); if (d.config) config = d.config; });
es.addEventListener('config', function (e) { var d = JSON.parse(e.data); if (d.config) config = d.config; });
es.addEventListener('alert', function (e) {
es.addEventListener('alert', function (e) {
var d = JSON.parse(e.data);
var d = JSON.parse(e.data);
- if (shown[d.delivery_id]) return; // a replay of one this page already showed
+ if (shown[d.delivery_id] || retracted[d.interaction_id]) return; // a replay of one this page already showed, or hidden since
shown[d.delivery_id] = 1;
shown[d.delivery_id] = 1;
queue.push(d);
queue.push(d);
next();
next();
@@ -86,5 +90,17 @@
@@ -86,5 +90,17 @@
renderGoals();
renderGoals();
if (d.test) setTimeout(function () { if (goals[g.id] && goals[g.id]._test) { delete goals[g.id]; renderGoals(); es.close(); location.reload(); } }, 15000);
if (d.test) setTimeout(function () { if (goals[g.id] && goals[g.id]._test) { delete goals[g.id]; renderGoals(); es.close(); location.reload(); } }, 15000);
});
});
+ // A moderator hid it: drop it from the queue, or take it off the screen now (and stop its voice).
+ es.addEventListener('retract', function (e) {
+ var d = JSON.parse(e.data);
+ retracted[d.interaction_id] = 1;
+ queue = queue.filter(function (a) { return a.interaction_id !== d.interaction_id; });
+ if (current && current.interaction_id === d.interaction_id) {
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel(); } catch (err) { /* no voice */ }
+ alertEl.className = ''; alertEl.textContent = '';
+ current = null;
+ setTimeout(function () { busy = false; next(); }, 400);
+ }
+ });
es.addEventListener('revoked', function () { es.close(); alertEl.textContent = ''; goalsEl.textContent = ''; });
es.addEventListener('revoked', function () { es.close(); alertEl.textContent = ''; goalsEl.textContent = ''; });
})();
})();
@@ -29,6 +29,10 @@
@@ -29,6 +29,10 @@
* | POST /simulate | tips.simulation.run | owner |
* | POST /simulate | tips.simulation.run | owner |
* | GET /profiles/:creator/supporters | tips.profile.get (page off) | anyone when the page shows it; 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 |
* | GET /me/export, POST /me/erase | — | the supporter's own tips |
+ * | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |
+ * | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |
+ * | GET /moderation/log?creator= | tips.interaction.moderate | owner |
+ * | GET|POST /moderators, POST …/:s/remove | tips.profile.update | owner |
*
*
* Paid requests take `privacy: { anonymous, hide_amount, private_message }` (server/domain/privacy.js).
* 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
* Goals and supporters are answered in their public shape (the creator's page settings) to anyone
@@ -57,6 +61,7 @@ const CAP = {
@@ -57,6 +61,7 @@ const CAP = {
configGet: 'tips.overlay.config.get',
configGet: 'tips.overlay.config.get',
configUpdate: 'tips.overlay.config.update',
configUpdate: 'tips.overlay.config.update',
simulate: 'tips.simulation.run',
simulate: 'tips.simulation.run',
+ moderate: 'tips.interaction.moderate', // proposed (docs/capabilities-proposal/), not yet in openvibe-contracts
};
};
function v1Router({ domain, apiAuth }) {
function v1Router({ domain, apiAuth }) {
@@ -316,6 +321,57 @@ function v1Router({ domain, apiAuth }) {
@@ -316,6 +321,57 @@ function v1Router({ domain, apiAuth }) {
res.json({ config: overlays.updateConfig(c, req.body || {}) });
res.json({ config: overlays.updateConfig(c, req.body || {}) });
}));
}));
+ // ── Moderation (server/domain/moderation.js) ─────────────
+ const { moderation } = domain;
+ /** The creator, one of their moderators, or a service with tips.interaction.moderate. */
+ function moderatorOf(req, creator, { hideExistence = false } = {}) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in (a Network user token) or present a service token');
+ const a = moderation.actorFor(p, creator, p.kind === 'service' && apiAuth.granted(p, CAP.moderate));
+ if (a) return a;
+ if (p.kind === 'service') fail(403, 'capability.denied', `${CAP.moderate} not granted`);
+ if (hideExistence) fail(404, 'tips.interaction_not_found', 'no such interaction');
+ fail(403, 'tips.forbidden', 'only the creator and their moderators moderate this page');
+ return null;
+ }
+ r.get('/moderation', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ moderatorOf(req, profile.creator_subject);
+ const out = moderation.queue(profile.creator_subject, { state: req.query.state || 'all', cursor: req.query.cursor, limit: req.query.limit });
+ res.json({ creator: { type: 'user', id: profile.creator_subject }, interactions: out.rows, next_cursor: out.next_cursor });
+ }));
+ r.get('/moderation/log', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ allow(req, CAP.moderate, profile.creator_subject);
+ res.json({ log: moderation.log(profile.creator_subject) });
+ }));
+ for (const action of ['hide', 'restore']) {
+ r.post(`/interactions/:id/${action}`, ...write((req, res) => {
+ const i = interactions.get(req.params.id);
+ if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');
+ const actor = moderatorOf(req, i.creator_subject, { hideExistence: true });
+ const out = moderation[action](i, { ...actor, reason: (req.body || {}).reason });
+ res.json({ interaction: moderation.present(out.interaction), changed: out.changed, cancelled_effects: out.cancelled_effects || [] });
+ }));
+ }
+ r.get('/moderators', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ allow(req, CAP.profileUpdate, profile.creator_subject);
+ res.json({ moderators: moderation.listModerators(profile.creator_subject).map(moderation.presentModerator) });
+ }));
+ r.post('/moderators', ...write((req, res) => {
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ allow(req, CAP.profileUpdate, profile.creator_subject);
+ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ res.status(201).json({ moderator: moderation.addModerator(profile.creator_subject, b.moderator, { name: b.name, addedBy: by }) });
+ }));
+ r.post('/moderators/:subject/remove', ...write((req, res) => {
+ const profile = creatorOf(req, (req.body || {}).creator);
+ allow(req, CAP.profileUpdate, profile.creator_subject);
+ res.json(moderation.removeModerator(profile.creator_subject, req.params.subject));
+ }));
+
// ── Simulation ───────────────────────────────────────────
// ── Simulation ───────────────────────────────────────────
r.post('/simulate', ...write((req, res) => {
r.post('/simulate', ...write((req, res) => {
const b = req.body || {};
const b = req.body || {};
@@ -247,6 +247,43 @@ CREATE TABLE IF NOT EXISTS import_runs (
@@ -247,6 +247,43 @@ CREATE TABLE IF NOT EXISTS import_runs (
report TEXT
report TEXT
);
);
+-- Moderation (server/domain/moderation.js): the creator's moderators, invitations to become one
+-- (show-once links, hashed like overlay tokens) and the record of every moderation outcome.
+CREATE TABLE IF NOT EXISTS tip_moderators (
+ creator_subject TEXT NOT NULL,
+ moderator_subject TEXT NOT NULL,
+ name TEXT, -- display name when they joined (for the creator's list)
+ added_by TEXT NOT NULL, -- the creator, a service, or 'invite:<id>'
+ created_at TEXT NOT NULL,
+ removed_at TEXT,
+ PRIMARY KEY (creator_subject, moderator_subject)
+);
+CREATE INDEX IF NOT EXISTS idx_tmod_moderator ON tip_moderators (moderator_subject);
+
+CREATE TABLE IF NOT EXISTS tip_moderator_invites (
+ id TEXT PRIMARY KEY, -- tmin_<ULID>
+ creator_subject TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the link's secret (shown once)
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ used_by TEXT,
+ revoked_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS tip_moderation_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ creator_subject TEXT NOT NULL,
+ interaction_id TEXT NOT NULL,
+ action TEXT NOT NULL CHECK (action IN ('held', 'filtered', 'hidden', 'restored')),
+ by_role TEXT NOT NULL CHECK (by_role IN ('filter', 'creator', 'moderator', 'service')),
+ actor TEXT, -- usr_… / svc:… ; null for the filter
+ reason TEXT,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_tmodlog_creator ON tip_moderation_log (creator_subject, id);
+
CREATE TABLE IF NOT EXISTS api_idempotency (
CREATE TABLE IF NOT EXISTS api_idempotency (
key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>
key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>
request_hash TEXT NOT NULL,
request_hash TEXT NOT NULL,
@@ -264,6 +301,8 @@ CREATE TABLE IF NOT EXISTS api_idempotency (
@@ -264,6 +301,8 @@ CREATE TABLE IF NOT EXISTS api_idempotency (
*
*
* v2 privacy: what the supporter lets the public see of an interaction (server/domain/privacy.js),
* 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
* their erasure, and what the creator's public goal and supporters pages show
+ * v3 moderation (server/domain/moderation.js): an interaction's moderation state, the creator's
+ * word filter, overlay alerts retracted by a moderator
*/
*/
const COLUMNS = [
const COLUMNS = [
['tip_interactions', 'anonymous', 'INTEGER NOT NULL DEFAULT 0'], // name shown as "Anonymous" to everyone but the supporter
['tip_interactions', 'anonymous', 'INTEGER NOT NULL DEFAULT 0'], // name shown as "Anonymous" to everyone but the supporter
@@ -271,9 +310,17 @@ const COLUMNS = [
@@ -271,9 +310,17 @@ const COLUMNS = [
['tip_interactions', 'private_message', 'INTEGER NOT NULL DEFAULT 0'], // a tip's message is for the creator only
['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
['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
['creator_tip_profiles', 'page_settings', "TEXT NOT NULL DEFAULT '{}'"], // JSON: what the public goal / supporters pages show
+ ['tip_interactions', 'moderation', "TEXT NOT NULL DEFAULT 'visible'"], // visible | held (the filter, awaiting review) | hidden (a moderator)
+ ['tip_interactions', 'moderated_at', 'TEXT'],
+ ['tip_interactions', 'moderated_by', 'TEXT'], // 'filter', usr_… or svc:…
+ ['tip_interactions', 'filtered', 'INTEGER NOT NULL DEFAULT 0'], // the word filter matched it at settlement
+ ['creator_tip_profiles', 'filter_words', "TEXT NOT NULL DEFAULT '[]'"], // JSON: the creator's blocklist (filter.js)
+ ['creator_tip_profiles', 'filter_action', "TEXT NOT NULL DEFAULT 'mask'"],// mask | hold
+ ['creator_tip_profiles', 'filter_links', 'INTEGER NOT NULL DEFAULT 1'], // links in paid messages shown as [link]
+ ['overlay_deliveries', 'hidden', 'INTEGER NOT NULL DEFAULT 0'], // retracted: never sent or replayed again
];
];
-const SCHEMA_VERSION = 2;
+const SCHEMA_VERSION = 3;
function addColumns(db) {
function addColumns(db) {
const have = new Map();
const have = new Map();
@@ -11,10 +11,12 @@
@@ -11,10 +11,12 @@
*
*
* A job carries the interaction's public view only (privacy.js): an anonymous supporter is
* 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
* "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.
+ * is not sent, and the creator's word filter has starred blocked words out of the line and dropped
+ * them from the TTS text. `privacy` tells the receiving product what was withheld. An effect whose
+ * interaction a moderator hid (or whose effect was cancelled meanwhile) is never delivered.
*/
*/
const { iso, json } = require('../util');
const { iso, json } = require('../util');
-const { publicView, privacyOf, isAnonymous } = require('./privacy');
+const { privacyOf, isAnonymous } = require('./privacy');
function createEffects(ctx) {
function createEffects(ctx) {
const { db, config } = ctx;
const { db, config } = ctx;
@@ -23,7 +25,7 @@ function createEffects(ctx) {
@@ -23,7 +25,7 @@ function createEffects(ctx) {
function jobFor(effect, i, profile) {
function jobFor(effect, i, profile) {
const req = json(i.request, {});
const req = json(i.request, {});
- const pv = publicView(i);
+ const pv = ctx.view(i);
const name = pv.supporter_name;
const name = pv.supporter_name;
const amount = pv.amount != null ? `${pv.amount.toLocaleString('en-US')} Vibes` : null;
const amount = pv.amount != null ? `${pv.amount.toLocaleString('en-US')} Vibes` : null;
const msg = pv.message ? `: ${pv.message}` : '';
const msg = pv.message ? `: ${pv.message}` : '';
@@ -80,12 +82,21 @@ function createEffects(ctx) {
@@ -80,12 +82,21 @@ function createEffects(ctx) {
}
}
async function runOne(effect) {
async function runOne(effect) {
+ // Re-read: the batch was selected before earlier deliveries awaited; a hide or a reversal may
+ // have cancelled this one since.
+ const current = db.prepare('SELECT state FROM interaction_effects WHERE id = ?').get(effect.id);
+ if (!current || current.state !== 'queued') return 'cancelled';
const i = ctx.interactions.get(effect.interaction_id);
const i = ctx.interactions.get(effect.interaction_id);
if (!i || i.payment_state !== 'settled') {
if (!i || i.payment_state !== 'settled') {
// Reversed or failed before its turn: nothing to deliver.
// Reversed or failed before its turn: nothing to deliver.
ctx.tx(() => ctx.interactions.cancelQueued(i, 'payment_not_settled'));
ctx.tx(() => ctx.interactions.cancelQueued(i, 'payment_not_settled'));
return 'cancelled';
return 'cancelled';
}
}
+ if (i.moderation !== 'visible') {
+ // Hidden or held: moderation.js cancels or releases these; never deliver one meanwhile.
+ db.prepare("UPDATE interaction_effects SET state = 'cancelled', last_error = 'hidden by moderation', updated_at = ? WHERE id = ? AND state = 'queued'").run(iso(ctx.now()), effect.id);
+ return 'cancelled';
+ }
const adapter = ctx.adapters[effect.adapter];
const adapter = ctx.adapters[effect.adapter];
if (!adapter) { markFailed(effect, Object.assign(new Error(`no adapter ${effect.adapter} configured`), { permanent: true })); return 'failed'; }
if (!adapter) { markFailed(effect, Object.assign(new Error(`no adapter ${effect.adapter} configured`), { permanent: true })); return 'failed'; }
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * The creator's word filter for paid messages (and the names that come with them), applied before
+ * anything is shown on an overlay, posted to chat, read out by text-to-speech or put on a public page.
+ *
+ * words the creator's blocklist: words or phrases, matched whole, case-insensitively, after Unicode
+ * NFKC normalisation (full-width and compatibility letters fold to plain ones)
+ * action mask the matches are starred out for display and left out of what TTS reads
+ * hold a paid message that matches is held: nothing is shown or read until the creator
+ * or a moderator approves it (moderation.js); the money is untouched either way
+ * links links in a paid message are replaced by "[link]" (display) or "link" (speech), whatever
+ * the blocklist says (on by default: a link on stream is an easy phishing vector)
+ *
+ * Every text also loses invisible and direction-override characters (they would hide a word from
+ * the filter or turn a line around on stream) and long runs of combining marks.
+ */
+const { fail } = require('../util');
+
+const MAX_WORDS = 200;
+const MAX_WORD_CHARS = 60;
+const ACTIONS = ['mask', 'hold'];
+// Zero-width, bidi embedding/override/isolate marks, word joiner, BOM, soft hyphen.
+const INVISIBLE = /[----]/g;
+const COMBINING_RUN = /(\p{M}{2})\p{M}+/gu;
+const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"']+/gi;
+
+/** Invisible characters and combining-mark runs out, NFKC. */
+function clean(s) {
+ return String(s == null ? '' : s).replace(INVISIBLE, '').normalize('NFKC').replace(COMBINING_RUN, '$1');
+}
+
+/** The creator's input (a list, or text with one entry per line or comma) → a clean, bounded list. */
+function normalizeWords(v) {
+ const raw = Array.isArray(v) ? v : String(v == null ? '' : v).split(/[\n,]/);
+ const out = [];
+ for (const w of raw) {
+ const s = clean(w).toLowerCase().replace(/\s+/g, ' ').trim();
+ if (!s) continue;
+ if (s.length > MAX_WORD_CHARS) fail(422, 'tips.invalid_input', `a blocked word or phrase is at most ${MAX_WORD_CHARS} characters`);
+ if (!out.includes(s)) out.push(s);
+ }
+ if (out.length > MAX_WORDS) fail(422, 'tips.invalid_input', `at most ${MAX_WORDS} blocked words or phrases`);
+ return out;
+}
+
+const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const cache = new Map();
+/** One case-insensitive regexp matching any entry as a whole word or phrase (literal text only). */
+function compile(words) {
+ if (!words || !words.length) return null;
+ const key = JSON.stringify(words);
+ if (cache.has(key)) return cache.get(key);
+ const alt = words.map((w) => w.split(' ').map(escapeRe).join('\\s+')).sort((a, b) => b.length - a.length).join('|');
+ const re = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alt})(?![\\p{L}\\p{N}_])`, 'giu');
+ if (cache.size > 500) cache.clear();
+ cache.set(key, re);
+ return re;
+}
+
+/**
+ * Filter one text. mode 'display' stars out a match, 'speech' drops it. Returns { text, hit } where
+ * hit says a blocked word matched (links alone are not a hit).
+ */
+function apply(text, settings, mode = 'display') {
+ if (text == null || text === '') return { text, hit: false };
+ let s = clean(text);
+ if (!settings || settings.links !== false) s = s.replace(URL_RE, mode === 'speech' ? 'link' : '[link]');
+ const re = settings ? compile(settings.words) : null;
+ let hit = false;
+ if (re) {
+ re.lastIndex = 0;
+ s = s.replace(re, (m) => { hit = true; return mode === 'speech' ? '' : '*'.repeat([...m].length); });
+ if (mode === 'speech') s = s.replace(/\s{2,}/g, ' ').trim();
+ }
+ return { text: s, hit };
+}
+
+/** Whether any of the texts hits a blocked word. */
+function hits(settings, ...texts) {
+ const re = settings ? compile(settings.words) : null;
+ if (!re) return false;
+ return texts.some((t) => { if (!t) return false; re.lastIndex = 0; return re.test(clean(t)); });
+}
+
+module.exports = { clean, normalizeWords, compile, apply, hits, ACTIONS, MAX_WORDS, MAX_WORD_CHARS };
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
*/
*/
const { fail, iso, prefixedId, text, positiveInt } = require('../util');
const { fail, iso, prefixedId, text, positiveInt } = require('../util');
const { safeUrl } = require('./profiles');
const { safeUrl } = require('./profiles');
-const { publicName } = require('./privacy');
+const { publicName, shownName } = require('./privacy');
function createGoals(ctx) {
function createGoals(ctx) {
const { db } = ctx;
const { db } = ctx;
@@ -64,10 +64,12 @@ function createGoals(ctx) {
@@ -64,10 +64,12 @@ function createGoals(ctx) {
if (!out) return null;
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_amounts) Object.assign(out, { target_amount: null, current_amount: null, carried_over_amount: null, amounts_hidden: true });
if (page.goal_supporters) {
if (page.goal_supporters) {
+ // Moderated (held or hidden) interactions are left out; names go through the creator's filter.
+ const settings = ctx.profiles.filterOf(g.creator_subject);
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
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
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 }));
+ AND i.moderation = 'visible' ORDER BY c.id DESC LIMIT 10`).all(g.id)
+ .map((c) => ({ name: shownName(c, settings), amount: page.goal_amounts && !c.hide_amount ? c.amount : null, at: c.created_at }));
}
}
return out;
return out;
}
}
@@ -169,8 +171,9 @@ function createGoals(ctx) {
@@ -169,8 +171,9 @@ function createGoals(ctx) {
if (!r.changes) return null;
if (!r.changes) return null;
const reachedNow = markReached(g.id);
const reachedNow = markReached(g.id);
// The overlay's goal update names the supporter as the alert does; not when the amount is hidden
// 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) });
+ // (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.
+ const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : ctx.view(interaction).supporter_name;
+ return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });
}
}
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
@@ -6,12 +6,15 @@
@@ -6,12 +6,15 @@
* ctx.tx(fn) run fn in one SQLite transaction; ctx.afterCommit(hook) queues work (overlay
* ctx.tx(fn) run fn in one SQLite transaction; ctx.afterCommit(hook) queues work (overlay
* pushes, worker kicks) that must only happen once the transaction committed
* pushes, worker kicks) that must only happen once the transaction committed
* ctx.outbox.emit() durable event, inside the transaction (openvibe-sdk outbox)
* ctx.outbox.emit() durable event, inside the transaction (openvibe-sdk outbox)
+ * ctx.view(i) the interaction's public view (privacy.js) through its creator's word filter
*/
*/
+const { publicView } = require('./privacy');
const { createProfiles } = require('./profiles');
const { createProfiles } = require('./profiles');
const { createGoals } = require('./goals');
const { createGoals } = require('./goals');
const { createOverlays } = require('./overlays');
const { createOverlays } = require('./overlays');
const { createInteractions } = require('./interactions');
const { createInteractions } = require('./interactions');
const { createEffects } = require('./effects');
const { createEffects } = require('./effects');
+const { createModeration } = require('./moderation');
function createDomain({ db, config, outbox, billing, adapters, now = () => Date.now(), log = console }) {
function createDomain({ db, config, outbox, billing, adapters, now = () => Date.now(), log = console }) {
const ctx = { db, config, now, log, outbox, billing, adapters, _after: null };
const ctx = { db, config, now, log, outbox, billing, adapters, _after: null };
@@ -29,10 +32,12 @@ function createDomain({ db, config, outbox, billing, adapters, now = () => Date.
@@ -29,10 +32,12 @@ function createDomain({ db, config, outbox, billing, adapters, now = () => Date.
ctx.outboxKick = () => { try { outbox.kick(); } catch { /* relay off */ } };
ctx.outboxKick = () => { try { outbox.kick(); } catch { /* relay off */ } };
ctx.profiles = createProfiles(ctx);
ctx.profiles = createProfiles(ctx);
+ ctx.view = (i, opts = {}) => publicView(i, { ...opts, filter: ctx.profiles.filterOf(i.creator_subject) });
ctx.goals = createGoals(ctx);
ctx.goals = createGoals(ctx);
ctx.overlays = createOverlays(ctx);
ctx.overlays = createOverlays(ctx);
ctx.interactions = createInteractions(ctx);
ctx.interactions = createInteractions(ctx);
ctx.effects = createEffects(ctx);
ctx.effects = createEffects(ctx);
+ ctx.moderation = createModeration(ctx);
return ctx;
return ctx;
}
}
@@ -40,7 +40,7 @@
@@ -40,7 +40,7 @@
*/
*/
const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');
const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');
const { VOICES } = require('./profiles');
const { VOICES } = require('./profiles');
-const { parsePrivacy, publicName, publicView, privacyOf, isAnonymous } = require('./privacy');
+const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous } = require('./privacy');
const { BillingCallError } = require('../billing-client');
const { BillingCallError } = require('../billing-client');
const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
@@ -247,7 +247,9 @@ function createInteractions(ctx) {
@@ -247,7 +247,9 @@ function createInteractions(ctx) {
const at = iso(ctx.now());
const at = iso(ctx.now());
const test = !!i.test;
const test = !!i.test;
const req = json(i.request, {});
const req = json(i.request, {});
- db.prepare("UPDATE tip_interactions SET payment_state = 'settled', delivery_state = 'queued', settled_at = ?, updated_at = ? WHERE id = ?").run(at, at, i.id);
+ // A tip hidden by a moderator while its payment was pending will deliver nothing.
+ db.prepare(`UPDATE tip_interactions SET payment_state = 'settled', delivery_state = CASE WHEN moderation = 'hidden' THEN 'cancelled' ELSE 'queued' END,
+ settled_at = ?, updated_at = ? WHERE id = ?`).run(at, at, i.id);
i = get(i.id);
i = get(i.id);
if (i.kind === 'paid_message' || i.kind === 'tts') {
if (i.kind === 'paid_message' || i.kind === 'tts') {
@@ -259,12 +261,31 @@ function createInteractions(ctx) {
@@ -259,12 +261,31 @@ function createInteractions(ctx) {
db.prepare(`INSERT OR IGNORE INTO paid_media_requests (id, interaction_id, creator_subject, url, provider, status, test, created_at, updated_at)
db.prepare(`INSERT OR IGNORE INTO paid_media_requests (id, interaction_id, creator_subject, url, provider, status, test, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tmr', ctx.now()), i.id, i.creator_subject, req.media.url, req.media.provider, test ? 1 : 0, at, at);
VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tmr', ctx.now()), i.id, i.creator_subject, req.media.url, req.media.provider, test ? 1 : 0, at, at);
}
}
+ // The creator's word filter runs before anything is shown, posted or read (moderation.js): a
+ // match is masked, or held for review when the creator chose that. History is not screened.
+ const verdict = i.origin !== 'import' && i.moderation === 'visible' ? ctx.moderation.screen(i) : { hit: false };
+ if (verdict.hit) ctx.moderation.markScreened(i, verdict);
+ i = get(i.id);
if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);
if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);
-
- if (i.origin !== 'import') {
- ctx.overlays.addAlert(i);
- addEffect(i, 'overlay_alert', 'overlay', 'delivered');
+ if (i.origin !== 'import' && i.moderation === 'visible') release(i);
+ recomputeDelivery(i.id);
+ if (!test && i.origin !== 'import') {
+ ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));
}
}
+ if (verdict.hit) ctx.moderation.recordScreened(get(i.id), verdict);
+ ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });
+ return get(i.id);
+ }
+
+ /**
+ * Inside a transaction: show a settled interaction — the overlay alert and the chat, TTS and media
+ * deliveries. Settlement calls it, and moderation's restore() for one the filter held. Idempotent:
+ * nothing that exists (delivered or cancelled) is created again.
+ */
+ function release(i) {
+ const test = !!i.test;
+ ctx.overlays.addAlert(i);
+ addEffect(i, 'overlay_alert', 'overlay', 'delivered');
// Chat effects: never for imports (history), never twice for a donation another product already
// Chat effects: never for imports (history), never twice for a donation another product already
// announced (origin billing), never to a real chat room for a simulation.
// announced (origin billing), never to a real chat room for a simulation.
const adapter = test ? 'test' : config.chat.adapter;
const adapter = test ? 'test' : config.chat.adapter;
@@ -275,11 +296,7 @@ function createInteractions(ctx) {
@@ -275,11 +296,7 @@ function createInteractions(ctx) {
if (i.kind === 'media_request') addEffect(i, 'media_request', adapter);
if (i.kind === 'media_request') addEffect(i, 'media_request', adapter);
}
}
recomputeDelivery(i.id);
recomputeDelivery(i.id);
- if (!test && i.origin !== 'import') {
- ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));
- }
- ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });
- return get(i.id);
+ ctx.afterCommit(() => ctx.effects.kick());
}
}
function addEffect(i, effect, adapter, state = 'queued') {
function addEffect(i, effect, adapter, state = 'queued') {
@@ -362,7 +379,16 @@ function createInteractions(ctx) {
@@ -362,7 +379,16 @@ function createInteractions(ctx) {
.run(JSON.stringify(ids), take, at, at, i.id);
.run(JSON.stringify(ids), take, at, at, i.id);
if (take > 0 && !i.test) ctx.goals.reverse(i, take);
if (take > 0 && !i.test) ctx.goals.reverse(i, take);
// Undelivered effects are cancelled; a delivery that already happened stays on record.
// Undelivered effects are cancelled; a delivery that already happened stays on record.
- const cancelled = cancelQueued(get(i.id), 'payment_reversed');
+ let cancelled = cancelQueued(get(i.id), 'payment_reversed');
+ const now = get(i.id);
+ if (!cancelled && now.moderation === 'held' && now.delivery_state === 'queued') {
+ // Held by the filter and never shown: it will not be released now.
+ db.prepare("UPDATE tip_interactions SET delivery_state = 'cancelled', updated_at = ? WHERE id = ?").run(at, i.id);
+ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);
+ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);
+ if (!now.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason: 'payment_reversed' });
+ cancelled = true;
+ }
return cancelled ? 'reversed_cancelled' : 'reversed';
return cancelled ? 'reversed_cancelled' : 'reversed';
}
}
@@ -383,6 +409,7 @@ function createInteractions(ctx) {
@@ -383,6 +409,7 @@ function createInteractions(ctx) {
const i = get(id);
const i = get(id);
if (!i || i.delivery_state === 'cancelled' || i.delivery_state === 'awaiting_payment') return i;
if (!i || i.delivery_state === 'cancelled' || i.delivery_state === 'awaiting_payment') return i;
const effects = db.prepare('SELECT state FROM interaction_effects WHERE interaction_id = ?').all(id).map((e) => e.state);
const effects = db.prepare('SELECT state FROM interaction_effects WHERE interaction_id = ?').all(id).map((e) => e.state);
+ if (i.moderation === 'held' && !effects.length) return i; // waiting for a moderator: still to deliver
let next = 'queued';
let next = 'queued';
if (effects.includes('failed') && !effects.includes('queued')) next = 'failed';
if (effects.includes('failed') && !effects.includes('queued')) next = 'failed';
else if (!effects.includes('queued')) next = 'delivered';
else if (!effects.includes('queued')) next = 'delivered';
@@ -490,7 +517,8 @@ function createInteractions(ctx) {
@@ -490,7 +517,8 @@ function createInteractions(ctx) {
if (g) {
if (g) {
const view = ctx.goals.present(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)) };
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.hide_amount ? null : publicName(i), dedupe: `goal:${g.id}:sim:${i.id}`, test: true });
+ const shown = get(i.id);
+ ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: shown.hide_amount || shown.moderation !== 'visible' ? null : ctx.view(shown).supporter_name, dedupe: `goal:${g.id}:sim:${i.id}`, test: true });
}
}
return out;
return out;
});
});
@@ -522,7 +550,8 @@ function createInteractions(ctx) {
@@ -522,7 +550,8 @@ function createInteractions(ctx) {
supporter_name: named ? (i.supporter_name || null) : publicName(i), kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || 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,
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,
funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,
- privacy: privacyOf(i), public: publicView(i), erased_at: i.erased_at || null,
+ privacy: privacyOf(i), public: ctx.view(i), erased_at: i.erased_at || null,
+ moderation: { state: i.moderation, filtered: !!i.filtered, at: i.moderated_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 },
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 },
delivery: { state: i.delivery_state, delivered_at: i.delivered_at || null },
created_at: i.created_at, updated_at: i.updated_at,
created_at: i.created_at, updated_at: i.updated_at,
@@ -554,7 +583,8 @@ function createInteractions(ctx) {
@@ -554,7 +583,8 @@ function createInteractions(ctx) {
// Only settled, non-test interactions of supporters who kept both their name and their amount
// 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).
// 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
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`;
+ AND ${t}.payment_state IN ('settled', 'reversed') AND ${t}.anonymous = 0 AND ${t}.hide_amount = 0 AND ${t}.erased_at IS NULL
+ AND ${t}.moderation = 'visible'`;
/** Top supporters: [{ rank, name, total, tips }] (name = the latest name they tipped under). */
/** Top supporters: [{ rank, name, total, tips }] (name = the latest name they tipped under). */
function leaderboard(creator, { limit = 20 } = {}) {
function leaderboard(creator, { limit = 20 } = {}) {
@@ -563,14 +593,15 @@ function createInteractions(ctx) {
@@ -563,14 +593,15 @@ function createInteractions(ctx) {
ORDER BY j.created_at DESC LIMIT 1) AS name
ORDER BY j.created_at DESC LIMIT 1) AS name
FROM tip_interactions t WHERE ${leaderboardWhere('t')}
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)) });
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 }));
+ const settings = ctx.profiles.filterOf(creator);
+ return rows.map((r, k) => ({ rank: k + 1, name: shownName({ supporter_name: r.name }, settings), total: r.total, tips: r.tips }));
}
}
/** Recent settled interactions with a public message, as publicView(). */
/** Recent settled interactions with a public message, as publicView(). */
function recentPublic(creator, { limit = 20 } = {}) {
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
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));
+ AND erased_at IS NULL AND moderation = 'visible' AND message IS NOT NULL ORDER BY settled_at DESC, id DESC LIMIT ?`).all(creator, Math.min(100, Math.max(1, limit)))
+ .map((i) => ctx.view(i));
}
}
// ── A supporter's own data ───────────────────────────────
// ── A supporter's own data ───────────────────────────────
@@ -664,7 +695,7 @@ function createInteractions(ctx) {
@@ -664,7 +695,7 @@ function createInteractions(ctx) {
return {
return {
get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
onBillingSettled, onBillingReversed, onBillingExternal, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
onBillingSettled, onBillingReversed, onBillingExternal, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
- leaderboard, recentPublic, exportFor, erase,
+ leaderboard, recentPublic, exportFor, erase, release,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+'use strict';
+
+/**
+ * Moderation of what supporters paid to show: a paid message, a TTS text, a media request, and the
+ * name that comes with a tip. The money is never touched here: hiding a paid message does not refund
+ * it, and a hidden tip still counts toward its goal and the creator's totals (Billing's books).
+ *
+ * moderation visible shown as the supporter allowed (privacy.js), through the creator's filter
+ * held the creator's filter (action hold) matched at settlement: nothing was shown,
+ * posted or read yet; restore() releases it (overlay alert, chat, TTS, media)
+ * hidden a creator, moderator or service hid it: queued chat/TTS/media deliveries are
+ * cancelled, the overlay alert is retracted (connected overlays drop it, replays
+ * and /state skip it) and public pages leave it out; restore() shows it again on
+ * pages and overlay state, without replaying what was cancelled
+ *
+ * Who may moderate a creator's interactions: the creator, their moderators (tip_moderators: added by
+ * the creator through a show-once invitation link, or by a service), and services holding
+ * tips.interaction.moderate. Every outcome is recorded in tip_moderation_log and published as
+ * tips.interaction.moderated (never for simulations): { interaction_id, creator, action, by,
+ * moderation_state, cancelled_effects }. The payload names no supporter, message or moderator.
+ */
+const crypto = require('crypto');
+const { fail, iso, prefixedId, sha256, userSubject, displayName, text } = require('../util');
+const filter = require('./filter');
+
+const INVITE_RE = /^tmin_[A-Za-z0-9_-]{43}$/;
+const INVITE_TTL_MS = 7 * 24 * 3600 * 1000;
+const MAX_MODERATORS = 50;
+const CHAT_EFFECTS = ['chat_line', 'paid_message', 'tts', 'media_request'];
+
+function createModeration(ctx) {
+ const { db } = ctx;
+
+ // ── Moderators ───────────────────────────────────────────
+ const isModerator = (creator, subject) => !!(subject && db.prepare('SELECT 1 FROM tip_moderators WHERE creator_subject = ? AND moderator_subject = ? AND removed_at IS NULL').get(creator, subject));
+ const listModerators = (creator) => db.prepare('SELECT * FROM tip_moderators WHERE creator_subject = ? AND removed_at IS NULL ORDER BY created_at').all(creator);
+ /** Creators whose interactions this person moderates (with their handles). */
+ const moderatedBy = (subject) => db.prepare(`SELECT p.creator_subject, p.handle, p.display_name FROM tip_moderators m
+ JOIN creator_tip_profiles p ON p.creator_subject = m.creator_subject WHERE m.moderator_subject = ? AND m.removed_at IS NULL ORDER BY p.handle`).all(subject);
+
+ function addModerator(creator, moderator, { name, addedBy }) {
+ const subject = userSubject(moderator, 'moderator');
+ if (subject === creator) fail(422, 'tips.invalid_input', 'the creator moderates their own page already');
+ const count = db.prepare('SELECT COUNT(*) AS n FROM tip_moderators WHERE creator_subject = ? AND removed_at IS NULL').get(creator).n;
+ if (!isModerator(creator, subject) && count >= MAX_MODERATORS) fail(409, 'tips.too_many_moderators', `remove a moderator first (${MAX_MODERATORS} at most)`);
+ db.prepare(`INSERT INTO tip_moderators (creator_subject, moderator_subject, name, added_by, created_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (creator_subject, moderator_subject) DO UPDATE SET removed_at = NULL, name = COALESCE(excluded.name, name), added_by = excluded.added_by`)
+ .run(creator, subject, displayName(name), addedBy, iso(ctx.now()));
+ return presentModerator(db.prepare('SELECT * FROM tip_moderators WHERE creator_subject = ? AND moderator_subject = ?').get(creator, subject));
+ }
+
+ function removeModerator(creator, moderator) {
+ const subject = userSubject(moderator, 'moderator');
+ const r = db.prepare('UPDATE tip_moderators SET removed_at = ? WHERE creator_subject = ? AND moderator_subject = ? AND removed_at IS NULL').run(iso(ctx.now()), creator, subject);
+ return { removed: r.changes > 0 };
+ }
+
+ const presentModerator = (m) => (m ? { moderator: { type: 'user', id: m.moderator_subject }, name: m.name || null, added_at: m.created_at } : null);
+
+ // ── Invitations (a show-once link the creator hands to a moderator) ──
+ function createInvite(creator, { createdBy }) {
+ const open = db.prepare('SELECT COUNT(*) AS n FROM tip_moderator_invites WHERE creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?').get(creator, iso(ctx.now())).n;
+ if (open >= 10) fail(409, 'tips.too_many_invites', 'revoke an open invitation first (10 at most)');
+ const secret = `tmin_${crypto.randomBytes(32).toString('base64url')}`;
+ const id = prefixedId('tmin', ctx.now());
+ const at = ctx.now();
+ db.prepare('INSERT INTO tip_moderator_invites (id, creator_subject, token_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(id, creator, sha256(secret), createdBy, iso(at), iso(at + INVITE_TTL_MS));
+ return { id, url: `${ctx.config.baseUrl}/moderate/invite/${secret}`, expires_at: iso(at + INVITE_TTL_MS) };
+ }
+
+ /** The open invitation for a presented secret, or null (unknown, used, revoked or expired). */
+ function findInvite(secret) {
+ if (!INVITE_RE.test(String(secret || ''))) return null;
+ const row = db.prepare('SELECT * FROM tip_moderator_invites WHERE token_hash = ?').get(sha256(secret));
+ if (!row || row.used_at || row.revoked_at || row.expires_at <= iso(ctx.now())) return null;
+ return row;
+ }
+
+ function acceptInvite(secret, { subject, name }) {
+ return ctx.tx(() => {
+ const inv = findInvite(secret);
+ if (!inv) fail(404, 'tips.invite_not_found', 'this invitation is not valid (it may have been used, revoked or expired)');
+ if (inv.creator_subject === subject) fail(422, 'tips.invalid_input', 'this is your own page');
+ db.prepare('UPDATE tip_moderator_invites SET used_at = ?, used_by = ? WHERE id = ? AND used_at IS NULL').run(iso(ctx.now()), subject, inv.id);
+ return { creator: inv.creator_subject, moderator: addModerator(inv.creator_subject, subject, { name, addedBy: `invite:${inv.id}` }) };
+ });
+ }
+
+ const openInvites = (creator) => db.prepare('SELECT id, created_at, expires_at FROM tip_moderator_invites WHERE creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ? ORDER BY created_at').all(creator, iso(ctx.now()));
+ function revokeInvite(creator, id) {
+ return { revoked: db.prepare('UPDATE tip_moderator_invites SET revoked_at = ? WHERE id = ? AND creator_subject = ? AND used_at IS NULL AND revoked_at IS NULL').run(iso(ctx.now()), String(id || ''), creator).changes > 0 };
+ }
+
+ // ── The filter at settlement ─────────────────────────────
+ /**
+ * Inside the settlement transaction, before anything is shown: does the creator's filter match the
+ * name or what will be shown or read? Returns { hit, hold }.
+ */
+ function screen(i) {
+ const settings = ctx.profiles.filterOf(i.creator_subject);
+ if (!settings || !settings.words.length) return { hit: false, hold: false };
+ const req = JSON.parse(i.request || '{}');
+ const texts = [i.anonymous ? null : i.supporter_name, i.private_message ? null : i.message, req.tts && req.tts.text];
+ const hit = filter.hits(settings, ...texts);
+ return { hit, hold: hit && settings.action === 'hold' };
+ }
+
+ // ── Outcomes ─────────────────────────────────────────────
+ function record(i, action, { role, actor = null, reason = null, cancelled = [] }) {
+ db.prepare('INSERT INTO tip_moderation_log (creator_subject, interaction_id, action, by_role, actor, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(i.creator_subject, i.id, action, role, actor, reason, iso(ctx.now()));
+ if (!i.test) {
+ const now = ctx.interactions.get(i.id);
+ ctx.outbox.emit('tips.interaction.moderated', { type: 'interaction', id: i.id }, {
+ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, action, by: role,
+ moderation_state: now.moderation, cancelled_effects: cancelled,
+ });
+ ctx.afterCommit(() => ctx.outboxKick());
+ }
+ }
+
+ /**
+ * Settlement found a filter match (inside its transaction). markScreened() sets the state before
+ * anything is shown (held, or only filtered = masked); recordScreened() logs and publishes it once
+ * the settlement's own event is out.
+ */
+ function markScreened(i, verdict) {
+ const at = iso(ctx.now());
+ if (verdict.hold) db.prepare("UPDATE tip_interactions SET filtered = 1, moderation = 'held', moderated_at = ?, moderated_by = 'filter', updated_at = ? WHERE id = ?").run(at, at, i.id);
+ else db.prepare('UPDATE tip_interactions SET filtered = 1, updated_at = ? WHERE id = ?').run(at, i.id);
+ }
+ function recordScreened(i, verdict) {
+ record(i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });
+ }
+
+ /** Who is acting: { role, actor } for the creator, a moderator or a granted service; else 403. */
+ function actorFor(principal, creator, granted) {
+ if (principal.kind === 'service' && granted) return { role: 'service', actor: principal.sub };
+ if (principal.kind === 'user' && principal.subject === creator) return { role: 'creator', actor: principal.subject };
+ if (principal.kind === 'user' && isModerator(creator, principal.subject)) return { role: 'moderator', actor: principal.subject };
+ return null;
+ }
+
+ /** Hide from overlays, chat/TTS still to come and public pages. The money is untouched. */
+ function hide(i, { role, actor, reason }) {
+ const note = text(reason, 'reason', 200);
+ return ctx.tx(() => {
+ const cur = ctx.interactions.get(i.id);
+ if (cur.moderation === 'hidden') return { interaction: cur, changed: false };
+ const at = iso(ctx.now());
+ const cancelled = db.prepare(`SELECT effect FROM interaction_effects WHERE interaction_id = ? AND state = 'queued' AND effect IN (${CHAT_EFFECTS.map(() => '?').join(',')})`)
+ .all(cur.id, ...CHAT_EFFECTS).map((e) => e.effect);
+ if (cancelled.length) {
+ db.prepare(`UPDATE interaction_effects SET state = 'cancelled', last_error = 'hidden by moderation', updated_at = ? WHERE interaction_id = ? AND state = 'queued'`).run(at, cur.id);
+ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, cur.id);
+ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, cur.id);
+ }
+ const undelivered = cancelled.length > 0 || cur.moderation === 'held' || cur.payment_state === 'pending';
+ db.prepare(`UPDATE tip_interactions SET moderation = 'hidden', moderated_at = ?, moderated_by = ?,
+ delivery_state = CASE WHEN ? AND delivery_state IN ('queued', 'awaiting_payment') THEN 'cancelled' ELSE delivery_state END, updated_at = ? WHERE id = ?`)
+ .run(at, actor || role, undelivered ? 1 : 0, at, cur.id);
+ const now = ctx.interactions.get(cur.id);
+ ctx.overlays.retract(now);
+ record(now, 'hidden', { role, actor, reason: note, cancelled });
+ return { interaction: ctx.interactions.get(cur.id), changed: true, cancelled_effects: cancelled };
+ });
+ }
+
+ /**
+ * Show it again. Held (never shown) and settled: released now, as settlement would have (overlay
+ * alert, chat, TTS, media). Hidden after it was shown: back on pages and in overlay state; what the
+ * hide cancelled stays cancelled.
+ */
+ function restore(i, { role, actor, reason }) {
+ const note = text(reason, 'reason', 200);
+ return ctx.tx(() => {
+ const cur = ctx.interactions.get(i.id);
+ if (cur.moderation === 'visible') return { interaction: cur, changed: false };
+ const at = iso(ctx.now());
+ // Never shown (held, or hidden before it was): released now, its delivery starting over.
+ const neverShown = !db.prepare("SELECT 1 FROM overlay_deliveries WHERE interaction_id = ? AND kind = 'alert'").get(cur.id);
+ const release = cur.payment_state === 'settled' && cur.origin !== 'import' && neverShown;
+ db.prepare(`UPDATE tip_interactions SET moderation = 'visible', moderated_at = ?, moderated_by = ?,
+ delivery_state = CASE WHEN ? THEN 'queued' ELSE delivery_state END, updated_at = ? WHERE id = ?`).run(at, actor || role, release ? 1 : 0, at, cur.id);
+ const now = ctx.interactions.get(cur.id);
+ ctx.overlays.unretract(now);
+ if (release) ctx.interactions.release(now);
+ record(now, 'restored', { role, actor, reason: note });
+ return { interaction: ctx.interactions.get(cur.id), changed: true };
+ });
+ }
+
+ /** The moderators' view: what was written (not private things), and what the public sees. */
+ function present(i) {
+ const req = JSON.parse(i.request || '{}');
+ return {
+ id: i.id, kind: i.kind, created_at: i.created_at, settled_at: i.settled_at || null, payment_state: i.payment_state, test: !!i.test,
+ moderation: { state: i.moderation, at: i.moderated_at || null, filtered: !!i.filtered },
+ supporter_name: i.anonymous || i.erased_at ? 'Anonymous' : (i.supporter_name || 'Someone'),
+ amount: i.hide_amount ? null : i.amount,
+ message: i.private_message || i.erased_at ? null : (i.message || null),
+ tts_text: req.tts && !i.erased_at ? req.tts.text : null,
+ media_url: req.media && !i.erased_at ? req.media.url : null,
+ public: ctx.view(i),
+ };
+ }
+
+ /** A creator's interactions for review, newest first. state: held | hidden | visible | all. */
+ function queue(creator, { state = 'all', cursor, limit = 50 } = {}) {
+ if (!['held', 'hidden', 'visible', 'all'].includes(state)) fail(422, 'tips.invalid_input', 'state must be held, hidden, visible or all');
+ const n = Math.min(200, Math.max(1, Number(limit) || 50));
+ let cur = null;
+ if (cursor) { try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { fail(422, 'tips.invalid_input', 'bad cursor'); } }
+ const rows = db.prepare(`SELECT * FROM tip_interactions WHERE creator_subject = @c AND payment_state IN ('settled', 'reversed', 'pending')
+ AND (@s = 'all' OR moderation = @s) AND (@ca IS NULL OR created_at < @ca OR (created_at = @ca AND id < @ci))
+ ORDER BY created_at DESC, id DESC LIMIT @n`).all({ c: creator, s: state, ca: cur ? cur[0] : null, ci: cur ? cur[1] : null, n: n + 1 });
+ const page = rows.slice(0, n);
+ return { rows: page.map(present), next_cursor: rows.length > n ? Buffer.from(JSON.stringify([page[n - 1].created_at, page[n - 1].id])).toString('base64url') : null };
+ }
+
+ const log = (creator, limit = 100) => db.prepare('SELECT interaction_id, action, by_role, actor, reason, created_at FROM tip_moderation_log WHERE creator_subject = ? ORDER BY id DESC LIMIT ?').all(creator, limit);
+
+ return {
+ isModerator, listModerators, moderatedBy, addModerator, removeModerator, presentModerator,
+ createInvite, findInvite, acceptInvite, openInvites, revokeInvite,
+ screen, markScreened, recordScreened, actorFor, hide, restore, present, queue, log,
+ };
+}
+
+module.exports = { createModeration, INVITE_RE };
@@ -16,14 +16,15 @@
@@ -16,14 +16,15 @@
* re-counts a goal, never emits another event. Simulated (test) rows are flagged and emit nothing.
* 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
* 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.
+ * "Anonymous", a hidden amount is null, a private message is absent, blocked words are starred out.
+ * refreshInteraction() rewrites the stored payloads when that view changes (an erasure), so a replay
+ * or /state never shows the old one. retract() (a moderator hid it) marks the alert hidden — never
+ * sent, replayed or listed again — and tells connected overlays to drop it (`retract` event).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { fail, iso, prefixedId, sha256, json, text } = require('../util');
const { fail, iso, prefixedId, sha256, json, text } = require('../util');
const { safeUrl } = require('./profiles');
const { safeUrl } = require('./profiles');
-const { publicView, publicName } = require('./privacy');
+const { isHidden } = require('./privacy');
const SCOPES = ['alerts', 'goals'];
const SCOPES = ['alerts', 'goals'];
const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;
const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;
@@ -162,7 +163,7 @@ function createOverlays(ctx) {
@@ -162,7 +163,7 @@ function createOverlays(ctx) {
/** Inside the settlement transaction (or a simulation). */
/** Inside the settlement transaction (or a simulation). */
function addAlert(interaction) {
function addAlert(interaction) {
- const payload = publicView(interaction, { at: iso(ctx.now()) });
+ const payload = ctx.view(interaction, { at: iso(ctx.now()) });
return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
}
}
@@ -171,13 +172,33 @@ function createOverlays(ctx) {
@@ -171,13 +172,33 @@ function createOverlays(ctx) {
* the goal updates it caused no longer name anyone.
* the goal updates it caused no longer name anyone.
*/
*/
function refreshInteraction(interaction) {
function refreshInteraction(interaction) {
+ const view = ctx.view(interaction);
for (const row of db.prepare('SELECT seq, kind, payload FROM overlay_deliveries WHERE interaction_id = ?').all(interaction.id)) {
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 old = json(row.payload, {});
- const payload = row.kind === 'alert' ? publicView(interaction, { at: old.at }) : { ...old, by: interaction.hide_amount ? null : publicName(interaction) };
+ const payload = row.kind === 'alert' ? ctx.view(interaction, { at: old.at }) : { ...old, by: interaction.hide_amount || isHidden(interaction) ? null : view.supporter_name };
db.prepare('UPDATE overlay_deliveries SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
db.prepare('UPDATE overlay_deliveries SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
}
}
}
}
+ /** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */
+ function retract(interaction) {
+ db.prepare("UPDATE overlay_deliveries SET hidden = 1 WHERE interaction_id = ? AND kind = 'alert'").run(interaction.id);
+ refreshInteraction(interaction);
+ const rows = db.prepare("SELECT id, seq FROM overlay_deliveries WHERE interaction_id = ? AND kind = 'alert'").all(interaction.id);
+ ctx.afterCommit(() => {
+ for (const c of clients.get(interaction.creator_subject) || []) {
+ if (!c.scopes.includes('alerts')) continue;
+ for (const r of rows) write(c, 'retract', null, { interaction_id: interaction.id, delivery_id: r.id, seq: r.seq });
+ }
+ });
+ }
+
+ /** Inside a transaction: shown again. Back in /state and replays; open overlays are not re-alerted. */
+ function unretract(interaction) {
+ db.prepare("UPDATE overlay_deliveries SET hidden = 0 WHERE interaction_id = ? AND kind = 'alert'").run(interaction.id);
+ refreshInteraction(interaction);
+ }
+
function addGoalDelivery(creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
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 });
return insertDelivery(creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
}
}
@@ -198,7 +219,7 @@ function createOverlays(ctx) {
@@ -198,7 +219,7 @@ function createOverlays(ctx) {
/** Pending rows older than the window → failed (tips.overlay.failed). Returns how many. */
/** Pending rows older than the window → failed (tips.overlay.failed). Returns how many. */
function sweepFailed() {
function sweepFailed() {
const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND hidden = 0 AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);
if (!rows.length) return 0;
if (!rows.length) return 0;
ctx.tx(() => {
ctx.tx(() => {
for (const row of rows) {
for (const row of rows) {
@@ -228,7 +249,7 @@ function createOverlays(ctx) {
@@ -228,7 +249,7 @@ function createOverlays(ctx) {
function send(client, row) {
function send(client, row) {
client.lastSeq = Math.max(client.lastSeq, row.seq);
client.lastSeq = Math.max(client.lastSeq, row.seq);
- if (!client.scopes.includes(scopeOf(row.kind))) return;
+ if (row.hidden || !client.scopes.includes(scopeOf(row.kind))) return;
const payload = json(row.payload, {});
const payload = json(row.payload, {});
if (row.kind === 'alert' && client.minAmount && !row.test) {
if (row.kind === 'alert' && client.minAmount && !row.test) {
// A hidden amount is not in the payload; the threshold still applies to the real one.
// A hidden amount is not in the payload; the threshold still applies to the real one.
@@ -241,7 +262,7 @@ function createOverlays(ctx) {
@@ -241,7 +262,7 @@ function createOverlays(ctx) {
}
}
function pushNew(client) {
function pushNew(client) {
- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' AND hidden = 0 ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);
for (const row of rows) send(client, row);
for (const row of rows) send(client, row);
}
}
@@ -272,13 +293,13 @@ function createOverlays(ctx) {
@@ -272,13 +293,13 @@ function createOverlays(ctx) {
const last = Number(lastEventId);
const last = Number(lastEventId);
if (lastEventId != null && Number.isInteger(last) && last >= 0) {
if (lastEventId != null && Number.isInteger(last) && last >= 0) {
const limit = ctx.config.overlays.replayLimit;
const limit = ctx.config.overlays.replayLimit;
- const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' AND hidden = 0 ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();
client.lastSeq = rows.length ? rows[0].seq - 1 : maxSeq;
client.lastSeq = rows.length ? rows[0].seq - 1 : maxSeq;
for (const row of rows) send(client, row);
for (const row of rows) send(client, row);
client.lastSeq = Math.max(client.lastSeq, maxSeq);
client.lastSeq = Math.max(client.lastSeq, maxSeq);
} else {
} else {
const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
- const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);
+ const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND hidden = 0 AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);
for (const row of pending) send(client, row);
for (const row of pending) send(client, row);
client.lastSeq = Math.max(client.lastSeq, maxSeq);
client.lastSeq = Math.max(client.lastSeq, maxSeq);
}
}
@@ -311,13 +332,13 @@ function createOverlays(ctx) {
@@ -311,13 +332,13 @@ function createOverlays(ctx) {
function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }
function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }
function recentDeliveries(creator, limit = 50) {
function recentDeliveries(creator, limit = 50) {
- return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);
+ return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, hidden, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);
}
}
return {
return {
SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
getConfig, listConfigs, createConfig, updateConfig, presentConfig,
getConfig, listConfigs, createConfig, updateConfig, presentConfig,
- addAlert, refreshInteraction, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
+ addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
};
};
}
}
@@ -20,9 +20,13 @@
@@ -20,9 +20,13 @@
* An interaction the supporter erased (erased_at) reads like an anonymous one with no message.
* 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
* 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.
+ * supporters and goals pages, and the `public` block of API answers. It also applies moderation
+ * (moderation.js): a held or hidden interaction shows nothing but that it is hidden, and the creator's
+ * word filter (filter.js) stars blocked words out of the name and message and drops them from what
+ * text-to-speech reads.
*/
*/
const { fail, json } = require('../util');
const { fail, json } = require('../util');
+const filter = require('./filter');
const ANONYMOUS = 'Anonymous';
const ANONYMOUS = 'Anonymous';
const FLAGS = ['anonymous', 'hide_amount', 'private_message'];
const FLAGS = ['anonymous', 'hide_amount', 'private_message'];
@@ -50,27 +54,42 @@ function publicName(i) {
@@ -50,27 +54,42 @@ function publicName(i) {
return i.supporter_name || 'Someone';
return i.supporter_name || 'Someone';
}
}
+const isHidden = (i) => !!i.moderation && i.moderation !== 'visible';
+
+/** The name as the public sees it, through the creator's filter (`settings` from profiles.filterOf). */
+function shownName(i, settings) {
+ if (isAnonymous(i)) return ANONYMOUS;
+ return filter.apply(i.supporter_name || 'Someone', settings).text;
+}
+
/**
/**
* The public shape of an interaction. `at` is kept from an earlier overlay payload when given, so a
* The public shape of an interaction. `at` is kept from an earlier overlay payload when given, so a
- * rewritten alert keeps its time.
+ * rewritten alert keeps its time; `filter` is the creator's filter settings.
*/
*/
-function publicView(i, { at } = {}) {
+function publicView(i, { at, filter: settings = null } = {}) {
const req = json(i.request, {});
const req = json(i.request, {});
const privateMsg = !!i.private_message || !!i.erased_at;
const privateMsg = !!i.private_message || !!i.erased_at;
- return {
+ const base = {
interaction_id: i.id,
interaction_id: i.id,
kind: i.kind,
kind: i.kind,
amount: i.hide_amount ? null : i.amount,
amount: i.hide_amount ? null : i.amount,
amount_hidden: !!i.hide_amount,
amount_hidden: !!i.hide_amount,
currency: i.currency,
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,
settlement: i.settlement,
test: !!i.test,
test: !!i.test,
at: at || i.settled_at || i.created_at,
at: at || i.settled_at || i.created_at,
};
};
+ if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, hidden: true };
+ const message = privateMsg || !i.message ? null : filter.apply(i.message, settings).text;
+ const tts = i.kind === 'tts' && req.tts && !i.erased_at ? { text: filter.apply(req.tts.text, settings, 'speech').text, voice: req.tts.voice } : null;
+ return {
+ ...base,
+ supporter_name: shownName(i, settings),
+ message,
+ tts,
+ media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,
+ hidden: false,
+ };
}
}
-module.exports = { ANONYMOUS, FLAGS, parsePrivacy, publicName, publicView, privacyOf, isAnonymous, truthy };
+module.exports = { ANONYMOUS, FLAGS, parsePrivacy, publicName, shownName, publicView, privacyOf, isAnonymous, isHidden, truthy };
@@ -15,13 +15,18 @@
@@ -15,13 +15,18 @@
* supporters_page openvibe.tips/<handle>/supporters: the top supporters
* supporters_page openvibe.tips/<handle>/supporters: the top supporters
* supporters_amounts each top supporter's total, and amounts in the recent list
* supporters_amounts each top supporter's total, and amounts in the recent list
* supporters_messages recent public messages on the supporters page
* supporters_messages recent public messages on the supporters page
+ *
+ * filter_words / filter_action / filter_links are the creator's word filter for paid messages
+ * (filter.js; `filter` in the API): filterOf(subject) is what every public view applies.
*/
*/
const { fail, iso, text, json } = require('../util');
const { fail, iso, text, json } = require('../util');
+const filterLib = require('./filter');
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
// Paths the site itself uses; no creator page can take them.
const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
- 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www']);
+ 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',
+ 'moderate', 'supporters', 'metrics']);
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
function normalizeHandle(v) {
function normalizeHandle(v) {
@@ -41,7 +46,11 @@ function pageSettings(raw) {
@@ -41,7 +46,11 @@ function pageSettings(raw) {
function parse(row) {
function parse(row) {
if (!row) return null;
if (!row) return null;
const b = (k) => !!row[k];
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'), page: pageSettings(row.page_settings) };
+ 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),
+ filter: { words: json(row.filter_words, []), action: filterLib.ACTIONS.includes(row.filter_action) ? row.filter_action : 'mask', links: row.filter_links !== 0 },
+ };
}
}
function createProfiles(ctx) {
function createProfiles(ctx) {
@@ -114,6 +123,18 @@ function createProfiles(ctx) {
@@ -114,6 +123,18 @@ function createProfiles(ctx) {
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;
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);
set.page_settings = JSON.stringify(next);
}
}
+ if (patch.filter !== undefined) {
+ const f = patch.filter;
+ if (!f || typeof f !== 'object' || Array.isArray(f)) fail(422, 'tips.invalid_input', 'filter must be an object { words, action, links }');
+ const unknown = Object.keys(f).filter((k) => !['words', 'action', 'links'].includes(k));
+ if (unknown.length) fail(422, 'tips.invalid_input', `filter has no setting ${unknown[0]}`);
+ if (f.words !== undefined) set.filter_words = JSON.stringify(filterLib.normalizeWords(f.words));
+ if (f.action !== undefined) {
+ if (!filterLib.ACTIONS.includes(f.action)) fail(422, 'tips.invalid_input', `filter.action must be one of ${filterLib.ACTIONS.join(', ')}`);
+ set.filter_action = f.action;
+ }
+ if (f.links !== undefined) set.filter_links = f.links === true || f.links === 'on' || f.links === '1' || f.links === 1 ? 1 : 0;
+ }
if (patch.display_name !== undefined) {
if (patch.display_name !== undefined) {
const n = text(patch.display_name, 'display_name', 80);
const n = text(patch.display_name, 'display_name', 80);
if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
@@ -139,11 +160,18 @@ function createProfiles(ctx) {
@@ -139,11 +160,18 @@ function createProfiles(ctx) {
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
currency: 'vibes-bits',
currency: 'vibes-bits',
};
};
- if (owner) Object.assign(out, { revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
+ if (owner) Object.assign(out, { filter: p.filter, revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
return out;
return out;
}
}
- return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle };
+ /** The creator's filter settings for publicView() (null when they have no profile). */
+ function filterOf(subject) {
+ const row = db.prepare('SELECT filter_words, filter_action, filter_links FROM creator_tip_profiles WHERE creator_subject = ?').get(subject);
+ if (!row) return null;
+ return { words: json(row.filter_words, []), action: row.filter_action === 'hold' ? 'hold' : 'mask', links: row.filter_links !== 0 };
+ }
+
+ return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle, filterOf };
}
}
function safeUrl(u) {
function safeUrl(u) {
@@ -167,6 +167,7 @@ ${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was c
@@ -167,6 +167,7 @@ ${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was c
<dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</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>
<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>` : ''}
${privacyLine(i.privacy) ? `<dt>Privacy</dt><dd>${privacyLine(i.privacy)}</dd>` : ''}
+ ${i.moderation && i.moderation.state !== 'visible' ? `<dt>Moderation</dt><dd>${esc(MOD_LABEL[i.moderation.state])} by the creator's moderation; the payment is not affected</dd>` : ''}
<dt>Amount</dt><dd>${n(i.amount)} Vibes</dd>
<dt>Amount</dt><dd>${n(i.amount)} Vibes</dd>
${i.message ? `<dt>Message</dt><dd>${esc(i.message)}</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>` : ''}
${i.tts ? `<dt>Read out</dt><dd>${esc(i.tts.text)} <small>(${esc(i.tts.voice)})</small></dd>` : ''}
@@ -181,7 +182,7 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url
@@ -181,7 +182,7 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url
<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;
<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;
}
}
-function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter }) {
+function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {} }) {
const p = profile;
const p = profile;
const f = (k) => (p[k] ? ' checked' : '');
const f = (k) => (p[k] ? ' checked' : '');
const alertCfg = configs.find((c) => c.kind === 'alerts');
const alertCfg = configs.find((c) => c.kind === 'alerts');
@@ -256,6 +257,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -256,6 +257,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<button class="btn ghost" type="submit">Save alert settings</button>
<button class="btn ghost" type="submit">Save alert settings</button>
</form>` : ''}
</form>` : ''}
</section>
</section>
+${moderationCard({ p, csrf, idem, mod })}
<section class="card"><h2>Test your setup</h2>
<section class="card"><h2>Test your setup</h2>
<p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>
<p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>
<form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}
<form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}
@@ -273,6 +275,71 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -273,6 +275,71 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</section>`;
</section>`;
}
}
+const MOD_LABEL = { visible: 'Shown', held: 'Held for review', hidden: 'Hidden' };
+const ROLE_LABEL = { filter: 'word filter', creator: 'you', moderator: 'a moderator', service: 'a service' };
+
+/** The dashboard's moderation card: the word filter, moderators and invitations, the latest outcomes. */
+function moderationCard({ p, csrf, idem, mod }) {
+ const fl = p.filter;
+ const moderators = mod.moderators || [];
+ const invites = mod.invites || [];
+ return `<section class="card" id="moderation"><h2>Moderation</h2>
+ <p>${mod.held ? `<b>${n(mod.held)}</b> paid message${mod.held === 1 ? ' is' : 's are'} held for review. ` : ''}<a href="/moderate/${esc(p.handle)}">Review paid messages</a> — hide one from overlays and your pages, or show a held one. Hiding never refunds or uncounts a payment.</p>
+ <form method="post" action="/dashboard/filter">${hidden({ csrf, idem: `${idem}-f` })}
+ <h3>Word filter</h3>
+ <label>Blocked words or phrases, one per line <small>(matched as whole words, any letter case; only you see this list)</small><textarea name="words" rows="4" maxlength="12000">${esc(fl.words.join('\n'))}</textarea></label>
+ <label>When a paid message or a name contains one <select name="action"><option value="mask"${fl.action === 'mask' ? ' selected' : ''}>star the words out (and skip them in text-to-speech)</option><option value="hold"${fl.action === 'hold' ? ' selected' : ''}>hold it until you or a moderator reviews it</option></select></label>
+ <label><input type="checkbox" name="links" value="1"${fl.links ? ' checked' : ''}> Replace links in paid messages with [link]</label>
+ <button class="btn ghost" type="submit">Save the filter</button>
+ </form>
+ <h3>Moderators</h3>
+ ${moderators.length ? `<ul class="plain">${moderators.map((m) => `<li>${esc(m.name || m.moderator.id)} <small>since ${when(m.added_at)}</small> <form class="inline" method="post" action="/dashboard/moderators/${esc(m.moderator.id)}/remove">${hidden({ csrf, idem: `${idem}-m-${m.moderator.id}` })}<button class="btn ghost" type="submit">Remove</button></form></li>`).join('')}</ul>` : '<p>No moderators yet.</p>'}
+ ${invites.length ? `<p class="fine">Open invitations: ${invites.map((x) => `created ${when(x.created_at)}, expires ${when(x.expires_at)} <form class="inline" method="post" action="/dashboard/moderator-invites/${esc(x.id)}/revoke">${hidden({ csrf, idem: `${idem}-ri-${x.id}` })}<button class="btn ghost" type="submit">Revoke</button></form>`).join(' · ')}</p>` : ''}
+ <form method="post" action="/dashboard/moderator-invites">${hidden({ csrf, idem: `${idem}-i` })}<button class="btn ghost" type="submit">Create a moderator invitation link</button></form>
+ ${mod.log && mod.log.length ? `<details><summary>Latest moderation</summary><table class="list"><thead><tr><th>When</th><th>What</th><th>By</th><th>Receipt</th></tr></thead><tbody>${mod.log.map((l) => `<tr><td>${when(l.created_at)}</td><td>${esc(l.action)}</td><td>${esc(ROLE_LABEL[l.by_role] || l.by_role)}</td><td><a href="/receipts/${esc(l.interaction_id)}"><code>${esc(l.interaction_id.slice(-8))}</code></a></td></tr>`).join('')}</tbody></table></details>` : ''}
+ ${mod.moderating && mod.moderating.length ? `<p class="fine">You moderate: ${mod.moderating.map((c) => `<a href="/moderate/${esc(c.handle)}">${esc(c.display_name)}</a>`).join(', ')}</p>` : ''}
+</section>`;
+}
+
+/** /moderate/<handle>: paid messages for the creator and their moderators to review. */
+function moderatePage({ profile, rows, state, next, csrf, idem, flash, error, isCreator }) {
+ const tab = (k, label) => `<a href="/moderate/${esc(profile.handle)}?state=${k}"${state === k ? ' aria-current="page"' : ''}>${label}</a>`;
+ const item = (r) => {
+ const what = [r.message && `<p class="msg">${esc(r.message)}</p>`, r.tts_text && `<p class="msg"><small>Read out:</small> ${esc(r.tts_text)}</p>`, r.media_url && `<p class="msg"><small>Media:</small> ${esc(r.media_url)}</p>`].filter(Boolean).join('');
+ const act = r.moderation.state === 'visible' ? 'hide' : 'restore';
+ const btn = r.moderation.state === 'held' ? 'Show it now' : act === 'hide' ? 'Hide' : 'Show again';
+ return `<li class="mod-item ${esc(r.moderation.state)}${r.test ? ' test' : ''}">
+ <div><b>${esc(r.supporter_name)}</b> · ${esc(KIND_LABEL[r.kind])}${r.amount != null ? ` · ${n(r.amount)} Vibes` : ''} · <small>${when(r.created_at)}${r.test ? ' · simulation' : ''}${r.payment_state === 'pending' ? ' · payment pending' : ''}</small>
+ <span class="pill">${esc(MOD_LABEL[r.moderation.state])}${r.moderation.filtered ? ' · filtered' : ''}</span></div>
+ ${what || '<p class="msg"><small>No message.</small></p>'}
+ <form class="inline" method="post" action="/moderate/${esc(profile.handle)}/${esc(r.id)}/${act}">${hidden({ csrf, idem: `${idem}-${r.id}` })}
+ ${act === 'hide' ? '<label>Note <small>(optional, only in your log)</small> <input type="text" name="reason" maxlength="200"></label>' : ''}
+ <button class="btn${act === 'hide' ? ' ghost' : ''}" type="submit">${btn}</button></form>
+</li>`;
+ };
+ return `<h1>Paid messages — ${esc(profile.display_name)}</h1>
+${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
+<p class="fine">${isCreator ? 'You and your moderators' : `You moderate ${esc(profile.display_name)}'s page. You`} can hide a paid message from overlays, chat still to come and the public pages, or show one the word filter held. The payment is never touched. Private messages and hidden amounts stay private here too.</p>
+<nav class="tabs">${tab('held', 'Held')} ${tab('visible', 'Shown')} ${tab('hidden', 'Hidden')} ${tab('all', 'All')}</nav>
+${rows.length ? `<ul class="mod-list">${rows.map(item).join('')}</ul>` : '<p>Nothing here.</p>'}
+${next ? `<p><a href="/moderate/${esc(profile.handle)}?state=${esc(state)}&cursor=${esc(next)}">Older</a></p>` : ''}`;
+}
+
+function invitePage({ profile, csrf, idem, secret, isSelf }) {
+ return `<h1>Moderate ${esc(profile.display_name)}</h1>
+<section class="card">
+ ${isSelf ? '<p>This invitation is for your own page: you moderate it already.</p>' : `<p>${esc(profile.display_name)} invited you to moderate the paid messages on their OpenVibe.Tips page: you will be able to hide a paid message from their overlays and pages, and show one their word filter held. You will not see payments, private messages or hidden amounts.</p>
+ <form method="post" action="/moderate/invite/${esc(secret)}">${hidden({ csrf, idem })}<button class="btn" type="submit">Become a moderator</button></form>`}
+</section>`;
+}
+
+function inviteCreated({ out }) {
+ return `<h1>Moderator invitation</h1>
+<p class="notice">Copy it now: this is the only time it is shown. Whoever opens it signed in becomes one of your moderators, so send it only to them. It works once and expires ${when(out.expires_at)}.</p>
+<p><label>Invitation link<input class="copy" type="text" readonly value="${esc(out.url)}" onclick="this.select()"></label></p>
+<p><a class="btn" href="/dashboard#moderation">Back to the dashboard</a></p>`;
+}
+
function tokenCreated({ out }) {
function tokenCreated({ out }) {
return `<h1>Overlay link created</h1>
return `<h1>Overlay link created</h1>
<p class="notice">Copy it now: this is the only time it is shown. Anyone with the link can see your alerts and goals (nothing else), so treat it like a password and revoke it if it leaks.</p>
<p class="notice">Copy it now: this is the only time it is shown. Anyone with the link can see your alerts and goals (nothing else), so treat it like a password and revoke it if it leaks.</p>
@@ -284,4 +351,4 @@ function errorPage({ status, title, message }) {
@@ -284,4 +351,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} -->`;
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, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };
+module.exports = { home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
* GET /:handle creator page: goals + tip form (indexable only when the page is on)
* GET /:handle creator page: goals + tip form (indexable only when the page is on)
* GET /:handle/goals goals (as the creator chose to show them)
* 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
* GET /:handle/supporters top supporters and recent public messages, when the creator shows them
+ * GET /moderate/:handle paid messages to review (the creator and their moderators)
+ * POST /moderate/:handle/:id/hide|restore
+ * GET|POST /moderate/invite/:token accept a moderator invitation (show-once link, never logged by nginx)
* POST /:handle/tip the no-JS tip form → Billing (credit or checkout)
* 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 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)
* GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)
@@ -26,12 +29,14 @@ const pages = require('./pages');
@@ -26,12 +29,14 @@ const pages = require('./pages');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
const { PAGE_DEFAULTS } = require('../domain/profiles');
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 FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay',
+ 'Filter saved', 'Moderator removed', 'Invitation revoked']);
+const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
function createWebRoutes({ domain, config, layout, userAuth }) {
function createWebRoutes({ domain, config, layout, userAuth }) {
const r = express.Router();
const r = express.Router();
- const { profiles, goals, interactions, overlays, db } = domain;
+ const { profiles, goals, interactions, overlays, moderation, db } = domain;
const withViewer = viewerMiddleware(userAuth);
const withViewer = viewerMiddleware(userAuth);
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
@@ -62,7 +67,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -62,7 +67,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
html(res, pageFor(req, { active: 'home', canonicalPath: '/', body: pages.home({ creators }) }));
html(res, pageFor(req, { active: 'home', canonicalPath: '/', body: pages.home({ creators }) }));
});
});
r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(
r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(
- `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
+ `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /moderate/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
r.get('/sitemap.xml', (req, res) => {
r.get('/sitemap.xml', (req, res) => {
const rows = db.prepare('SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY handle').all();
const rows = db.prepare('SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY handle').all();
const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];
const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];
@@ -133,6 +138,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -133,6 +138,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
configs: overlays.listConfigs(p.creator_subject).map(overlays.presentConfig), totals: interactions.totals(p.creator_subject), recent,
configs: overlays.listConfigs(p.creator_subject).map(overlays.presentConfig), totals: interactions.totals(p.creator_subject), recent,
deliveries: overlays.recentDeliveries(p.creator_subject, 20), csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,
deliveries: overlays.recentDeliveries(p.creator_subject, 20), csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,
connected: overlays.connected(p.creator_subject), chatAdapter: config.chat.adapter,
connected: overlays.connected(p.creator_subject), chatAdapter: config.chat.adapter,
+ mod: {
+ held: db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE creator_subject = ? AND moderation = 'held' AND payment_state = 'settled'").get(p.creator_subject).n,
+ moderators: moderation.listModerators(p.creator_subject).map(moderation.presentModerator), invites: moderation.openInvites(p.creator_subject),
+ log: moderation.log(p.creator_subject, 20), moderating: moderation.moderatedBy(req.viewer.subject),
+ },
}),
}),
}), status);
}), status);
}
}
@@ -195,6 +205,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -195,6 +205,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
overlays.updateConfig(c, { ...b, show_message: bool(b.show_message), speak_message: bool(b.speak_message), show_amount: bool(b.show_amount) });
overlays.updateConfig(c, { ...b, show_message: bool(b.show_message), speak_message: bool(b.speak_message), show_amount: bool(b.show_amount) });
return { done: 'Alert settings saved' };
return { done: 'Alert settings saved' };
});
});
+ action('/dashboard/filter', (req, p) => {
+ profiles.update(p.creator_subject, { filter: { words: req.body.words || '', action: req.body.action, links: bool(req.body.links) } });
+ return { done: 'Filter saved' };
+ });
+ action('/dashboard/moderators/:subject/remove', (req, p) => { moderation.removeModerator(p.creator_subject, req.params.subject); return { done: 'Moderator removed' }; });
+ action('/dashboard/moderator-invites', (req, p) => {
+ const out = moderation.createInvite(p.creator_subject, { createdBy: req.viewer.subject });
+ return { render: (res) => html(res, pageFor(req, { title: 'Moderator invitation', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', body: pages.inviteCreated({ out }) }), 201, { 'Cache-Control': 'no-store' }) };
+ });
+ action('/dashboard/moderator-invites/:id/revoke', (req, p) => { moderation.revokeInvite(p.creator_subject, req.params.id); return { done: 'Invitation revoked' }; });
action('/dashboard/simulate', (req, p) => {
action('/dashboard/simulate', (req, p) => {
const b = req.body;
const b = req.body;
interactions.simulate(p, {
interactions.simulate(p, {
@@ -238,12 +258,84 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -238,12 +258,84 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const out = { creator: { type: 'user', id: t.creator_subject }, scopes: t.scopes };
const out = { creator: { type: 'user', id: t.creator_subject }, scopes: t.scopes };
if (t.scopes.includes('goals')) out.goals = goals.list(t.creator_subject, { status: 'active' }).map((g) => goals.present(g));
if (t.scopes.includes('goals')) out.goals = goals.list(t.creator_subject, { status: 'active' }).map((g) => goals.present(g));
if (t.scopes.includes('alerts')) {
if (t.scopes.includes('alerts')) {
- out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' ORDER BY seq DESC LIMIT 20")
+ out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' AND hidden = 0 ORDER BY seq DESC LIMIT 20")
.all(t.creator_subject).map((d) => ({ seq: d.seq, ...parseJson(d.payload, {}), test: !!d.test, created_at: d.created_at }));
.all(t.creator_subject).map((d) => ({ seq: d.seq, ...parseJson(d.payload, {}), test: !!d.test, created_at: d.created_at }));
}
}
res.set(overlayHeaders).json(out);
res.set(overlayHeaders).json(out);
});
});
+ // ── Moderation (the creator and their moderators) ─────────
+ // Invitation links carry a secret in the path, like overlay links: no-store, no referrer, noindex.
+ const inviteHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };
+ function inviteOr404(req, res) {
+ const inv = moderation.findInvite(req.params.token);
+ const profile = inv && profiles.bySubject(inv.creator_subject);
+ if (!inv || !profile) {
+ html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: 404, title: 'This invitation is not valid', message: 'It may have been used, revoked or expired. Ask the creator for a new one.' }) }), 404, inviteHeaders);
+ return null;
+ }
+ return { inv, profile };
+ }
+ r.get('/moderate/invite/:token', withViewer, (req, res) => {
+ if (!req.viewer) return res.set(inviteHeaders).redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const found = inviteOr404(req, res);
+ if (!found) return undefined;
+ return html(res, pageFor(req, {
+ title: `Moderate ${found.profile.display_name}`, robots: 'noindex,nofollow', canonicalPath: '/',
+ body: pages.invitePage({ profile: found.profile, csrf: csrfFor(req.viewer.subject), idem: idem(), secret: req.params.token, isSelf: found.profile.creator_subject === req.viewer.subject }),
+ }), 200, inviteHeaders);
+ });
+ r.post('/moderate/invite/:token', withViewer, form, (req, res) => {
+ if (!req.viewer) return res.set(inviteHeaders).redirect(303, '/auth/login');
+ if (!csrfOk(req)) return html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: 403, title: 'That form expired', message: 'Open the invitation link again.' }) }), 403, inviteHeaders);
+ try {
+ const out = moderation.acceptInvite(req.params.token, { subject: req.viewer.subject, name: req.viewer.name || req.viewer.username });
+ const profile = profiles.bySubject(out.creator);
+ return res.set(inviteHeaders).redirect(303, `/moderate/${encodeURIComponent(profile.handle)}?done=${encodeURIComponent('You are now a moderator')}`);
+ } catch (e) {
+ if (e instanceof TipsError) return html(res, pageFor(req, { title: 'Invitation', robots: 'noindex,nofollow', body: pages.errorPage({ status: e.status, title: 'This invitation is not valid', message: e.detail || e.message }) }), e.status, inviteHeaders);
+ throw e;
+ }
+ });
+
+ /** The creator or one of their moderators, for /moderate/:handle; else null (the page is a 404). */
+ function modAccess(req) {
+ const p = profiles.byHandle(req.params.handle);
+ if (!p || !req.viewer) return { p };
+ const actor = moderation.actorFor(req.viewer, p.creator_subject, false);
+ return { p, actor };
+ }
+ function renderModerate(req, res, p, { error, status = 200 } = {}) {
+ const state = ['held', 'hidden', 'visible', 'all'].includes(req.query.state) ? req.query.state : 'all';
+ const out = moderation.queue(p.creator_subject, { state, cursor: req.query.cursor, limit: 50 });
+ const flash = MOD_FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null;
+ html(res, pageFor(req, {
+ title: `Paid messages — ${p.display_name}`, robots: 'noindex,nofollow', canonicalPath: `/moderate/${p.handle}`,
+ body: pages.moderatePage({ profile: p, rows: out.rows, state, next: out.next_cursor, csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error, isCreator: req.viewer.subject === p.creator_subject }),
+ }), status);
+ }
+ r.get('/moderate/:handle', withViewer, (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const { p, actor } = modAccess(req);
+ if (!p || !actor) return notFound(req, res);
+ return renderModerate(req, res, p);
+ });
+ r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, (req, res) => {
+ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);
+ const { p, actor } = modAccess(req);
+ if (!p || !actor) return notFound(req, res);
+ if (!csrfOk(req)) return renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });
+ const i = interactions.get(req.params.id);
+ if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);
+ try {
+ moderation[req.params.act](i, { ...actor, reason: req.body.reason });
+ } catch (e) {
+ if (e instanceof TipsError) return renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });
+ throw e;
+ }
+ return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);
+ });
+
// ── Creator pages (last: /:handle catches everything else) ──
// ── Creator pages (last: /:handle catches everything else) ──
const providers = config.billing.providers;
const providers = config.billing.providers;
function creatorView(req, res, { values, error, status = 200 } = {}) {
function creatorView(req, res, { values, error, status = 200 } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,271 @@
@@ -0,0 +1,271 @@
+'use strict';
+// Moderation: the creator's word filter (mask or hold) before anything is shown or read; the creator,
+// their moderators (invitation links) and granted services hide or show paid messages on overlays,
+// chat still to come and public pages; the money is never touched; outcomes are logged and published
+// as tips.interaction.moderated.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const registry = require('openvibe-contracts/lib/registry');
+const { boot, check, done } = require('./helpers/app');
+
+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 || [] });
+}
+const validModerated = payloadValidator('tips.interaction.moderated');
+
+(async () => {
+ const t = await boot();
+ const { domain, billing } = t;
+ const alex = await t.creator('alex', { settings: { tts_enabled: true, page: { supporters_page: true, supporters_messages: true } } });
+ const viewer = t.network.newUser('viewer');
+ const mod = t.network.newUser('modder');
+ const stranger = t.network.newUser('stranger');
+ billing.fund(viewer.subject, 100_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 pay = (kind, body) => t.call('POST', { tip: '/api/v1/checkout', paid_message: '/api/v1/paid-messages', tts: '/api/v1/tts-requests' }[kind], { user: viewer, body: { creator: 'alex', ...body } });
+ const state = async () => (await t.call('GET', `/overlay/${secret}/state`, { token: null })).json.alerts;
+ const jobsOf = (id) => t.adapters.test.jobs.filter((j) => j.interaction.id === id);
+ const moderated = (id) => t.outboxRows('tips.interaction.moderated').filter((e) => e.subject.id === id).map((e) => e.payload);
+ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
+ const get = (p, u) => fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));
+ const post = (p, u, form) => fetch(t.base + p, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie(u) }, body: new URLSearchParams(form).toString() })
+ .then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));
+ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };
+
+ await check('the filter settings are the creator\'s, validated, and never public', async () => {
+ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { action: 'nuke' } } });
+ assert.strictEqual(bad.status, 422);
+ const long = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: ['x'.repeat(61)] } } });
+ assert.strictEqual(long.status, 422);
+ const ok = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: 'Badword\ntwo words, badword', action: 'mask' } } });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.deepStrictEqual(ok.json.profile.filter, { words: ['badword', 'two words'], action: 'mask', links: true });
+ const pub = await t.call('GET', '/api/v1/profiles/alex', { token: null });
+ assert.strictEqual(pub.json.profile.filter, undefined, 'the blocklist is private');
+ });
+
+ await check('mask: blocked words are starred out of overlays, chat and pages, dropped from TTS; tricks do not get through', async () => {
+ const r = await pay('paid_message', { amount: 200, supporter_name: 'badword fan', message: 'you BADWORD, two words and badword and badword https://phish.example/x ok' });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json.interaction.id;
+ const a = (await state()).find((x) => x.interaction_id === id);
+ assert.strictEqual(a.supporter_name, '******* fan');
+ assert.strictEqual(a.message, 'you *******, ********** and ******* and ******* [link] ok');
+ await domain.effects.drain();
+ assert.match(jobsOf(id)[0].text, /^\*\*\*\*\*\*\* fan tipped 200 Vibes: you \*\*\*\*\*\*\*,/);
+ assert.ok(!jobsOf(id)[0].text.includes('phish'));
+ const own = (await t.call('GET', `/api/v1/interactions/${id}`, { user: alex })).json.interaction;
+ assert.match(own.message, /BADWORD/, 'the creator reads the original');
+ assert.strictEqual(own.moderation.filtered, true);
+ assert.strictEqual(own.moderation.state, 'visible');
+ const [ev] = moderated(id);
+ assert.deepStrictEqual(ev, { interaction_id: id, creator: { type: 'user', id: alex.subject }, action: 'filtered', by: 'filter', moderation_state: 'visible', cancelled_effects: [] });
+ const tts = await pay('tts', { amount: 150, tts: { text: 'say badword now please' } });
+ await domain.effects.drain();
+ assert.strictEqual(jobsOf(tts.json.interaction.id).find((j) => j.effect === 'tts').tts.text, 'say now please');
+ const page = await get('/alex/supporters');
+ assert.ok(!/badword/i.test(page.text.replace(/<style[\s\S]*?<\/style>/g, '')), 'the supporters page shows no blocked word');
+ });
+
+ let heldId;
+ await check('hold: nothing is shown, posted or read until someone reviews it; the money counts at once', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { action: 'hold' } } });
+ const goalBefore = domain.goals.present(domain.goals.get(goal.id)).current_amount;
+ const s = await t.sse(`/overlay/${secret}/events`);
+ await s.waitFor((e) => e.event === 'hello');
+ const r = await pay('tts', { amount: 300, supporter_name: 'Viewer', tts: { text: 'read this badword aloud' } });
+ heldId = r.json.interaction.id;
+ await domain.effects.drain();
+ await new Promise((ok) => setTimeout(ok, 50));
+ assert.ok(!s.events.some((e) => e.event === 'alert' && e.data.interaction_id === heldId), 'no overlay alert');
+ s.close();
+ assert.ok(!(await state()).some((x) => x.interaction_id === heldId));
+ assert.strictEqual(jobsOf(heldId).length, 0, 'no chat line, no TTS');
+ const i = domain.interactions.get(heldId);
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(i.moderation, 'held');
+ assert.strictEqual(i.delivery_state, 'queued', 'waiting for review, not "delivered"');
+ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, goalBefore + 300);
+ assert.strictEqual(t.outboxRows('tips.interaction.ready').filter((e) => e.subject.id === heldId).length, 1);
+ assert.deepStrictEqual(moderated(heldId).map((e) => [e.action, e.by, e.moderation_state]), [['held', 'filter', 'held']]);
+ const q = await t.call('GET', '/api/v1/moderation?creator=alex&state=held', { user: alex });
+ assert.deepStrictEqual(q.json.interactions.map((x) => x.id), [heldId]);
+ assert.strictEqual(q.json.interactions[0].tts_text, 'read this badword aloud', 'reviewers read what was written');
+ assert.strictEqual(q.json.interactions[0].public.hidden, true);
+ });
+
+ await check('moderators: a show-once invitation link, accepted signed in; only the creator manages them', async () => {
+ const dash = await get('/dashboard', alex);
+ assert.match(dash.text, /Word filter/);
+ const csrf = field(dash.text, 'csrf');
+ const inv = await post('/dashboard/moderator-invites', alex, { csrf, idem: 'x' });
+ assert.strictEqual(inv.status, 201);
+ assert.strictEqual(inv.headers.get('cache-control'), 'no-store');
+ const url = inv.text.match(/value="(http:\/\/tips\.test\/moderate\/invite\/tmin_[^"]+)"/)[1];
+ const path = url.replace('http://tips.test', '');
+ assert.ok(!(await get('/dashboard', alex)).text.includes(url), 'shown once');
+ assert.ok(!JSON.stringify(t.db.prepare('SELECT * FROM tip_moderator_invites').all()).includes(path.split('/').pop()), 'stored hashed');
+ assert.match((await get(path)).headers.get('location'), /^\/auth\/login/);
+ const page = await get(path, mod);
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(page.headers.get('referrer-policy'), 'no-referrer');
+ assert.match(page.text, /Become a moderator/);
+ const forged = await post(path, mod, { csrf: 'nope', idem: 'x' });
+ assert.strictEqual(forged.status, 403);
+ const ok = await post(path, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });
+ assert.strictEqual(ok.status, 303);
+ assert.match(ok.headers.get('location'), /^\/moderate\/alex\?done=/);
+ assert.strictEqual((await post(path, stranger, { csrf: field((await get('/alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');
+ assert.strictEqual((await get('/api/v1/moderators?creator=alex', null)).status, 401);
+ const list = await t.call('GET', '/api/v1/moderators', { user: alex });
+ assert.deepStrictEqual(list.json.moderators.map((m) => m.moderator.id), [mod.subject]);
+ assert.strictEqual((await t.call('GET', '/api/v1/moderators?creator=alex', { user: mod })).status, 403, 'moderators do not manage moderators');
+ });
+
+ await check('a moderator shows a held message: it is released as settlement would have released it', async () => {
+ const page = await get('/moderate/alex?state=held', mod);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /read this badword aloud/);
+ const s = await t.sse(`/overlay/${secret}/events`);
+ await s.waitFor((e) => e.event === 'hello');
+ const r = await post(`/moderate/alex/${heldId}/restore`, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });
+ assert.strictEqual(r.status, 303);
+ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === heldId);
+ s.close();
+ assert.strictEqual(alert.data.tts.text, 'read this aloud', 'blocked words stay out of what is read');
+ await domain.effects.drain();
+ assert.deepStrictEqual(jobsOf(heldId).map((j) => j.effect).sort(), ['chat_line', 'tts']);
+ const i = domain.interactions.get(heldId);
+ assert.strictEqual(i.moderation, 'visible');
+ assert.strictEqual(i.delivery_state, 'delivered');
+ assert.deepStrictEqual(moderated(heldId).map((e) => [e.action, e.by, e.moderation_state]), [['held', 'filter', 'held'], ['restored', 'moderator', 'visible']]);
+ });
+
+ let hiddenId;
+ await check('hide: queued chat/TTS cancelled, overlay alert retracted (live, replay, /state), pages drop it; the money untouched', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: [] } } });
+ const payableBefore = billing.payable.get(alex.subject);
+ const s = await t.sse(`/overlay/${secret}/events`);
+ await s.waitFor((e) => e.event === 'hello');
+ const r = await pay('tts', { amount: 500, supporter_name: 'Viewer', message: 'nasty but not blocked', tts: { text: 'nasty but not blocked' } });
+ hiddenId = r.json.interaction.id;
+ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === hiddenId);
+ const goalBefore = domain.goals.present(domain.goals.get(goal.id)).current_amount;
+ const h = await t.call('POST', `/api/v1/interactions/${hiddenId}/hide`, { user: mod, body: { reason: 'abuse' } });
+ assert.strictEqual(h.status, 200, h.text);
+ assert.strictEqual(h.json.changed, true);
+ assert.deepStrictEqual(h.json.cancelled_effects.sort(), ['chat_line', 'tts']);
+ const retract = await s.waitFor((e) => e.event === 'retract' && e.data.interaction_id === hiddenId);
+ assert.strictEqual(retract.data.delivery_id, alert.data.delivery_id);
+ s.close();
+ await domain.effects.drain();
+ assert.strictEqual(jobsOf(hiddenId).length, 0, 'nothing was posted or read');
+ const i = domain.interactions.get(hiddenId);
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(i.delivery_state, 'cancelled');
+ assert.strictEqual(billing.payable.get(alex.subject), payableBefore + 500, 'the payment stands');
+ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, goalBefore, 'the goal keeps it');
+ assert.ok(!(await state()).some((x) => x.interaction_id === hiddenId));
+ const replay = await t.sse(`/overlay/${secret}/events`, { lastEventId: alert.id - 1 });
+ await replay.waitFor((e) => e.event === 'hello');
+ await new Promise((ok) => setTimeout(ok, 50));
+ assert.ok(!replay.events.some((e) => e.event === 'alert' && e.data.interaction_id === hiddenId), 'a replay skips it');
+ replay.close();
+ const sup = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;
+ assert.ok(!sup.recent.some((v) => v.interaction_id === hiddenId));
+ assert.strictEqual(t.outboxRows('tips.interaction.cancelled').filter((e) => e.subject.id === hiddenId).length, 0, 'not a payment cancellation');
+ const [ev] = moderated(hiddenId);
+ assert.deepStrictEqual({ ...ev, cancelled_effects: ev.cancelled_effects.sort() }, { interaction_id: hiddenId, creator: { type: 'user', id: alex.subject }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['chat_line', 'tts'] });
+ assert.ok(!JSON.stringify(ev).includes('abuse') && !JSON.stringify(ev).includes(mod.subject), 'the event carries no note and no moderator');
+ const again = await t.call('POST', `/api/v1/interactions/${hiddenId}/hide`, { user: alex });
+ assert.strictEqual(again.json.changed, false);
+ assert.strictEqual(moderated(hiddenId).length, 1, 'hiding twice publishes once');
+ const receipt = (await t.call('GET', `/api/v1/interactions/${hiddenId}`, { user: viewer })).json.interaction;
+ assert.strictEqual(receipt.moderation.state, 'hidden');
+ assert.strictEqual(receipt.payment.state, 'settled');
+ });
+
+ await check('restore after a hide: back on pages and overlay state; what was cancelled stays cancelled', async () => {
+ const r = await t.call('POST', `/api/v1/interactions/${hiddenId}/restore`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok((await state()).some((x) => x.interaction_id === hiddenId));
+ await domain.effects.drain();
+ assert.strictEqual(jobsOf(hiddenId).length, 0, 'no late TTS');
+ assert.ok((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json.recent.some((v) => v.interaction_id === hiddenId));
+ assert.deepStrictEqual(moderated(hiddenId).map((e) => [e.action, e.by]), [['hidden', 'moderator'], ['restored', 'creator']]);
+ });
+
+ await check('who may moderate: the creator, their moderators, services with tips.interaction.moderate; nobody else', async () => {
+ const r = await pay('paid_message', { amount: 200, message: 'hello' });
+ const id = r.json.interaction.id;
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: stranger })).status, 404);
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: viewer })).status, 404, 'the supporter cannot hide it either');
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { token: null })).status, 401);
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { cap: ['tips.interaction.get'] })).status, 403);
+ const svc = await t.call('POST', `/api/v1/interactions/${id}/hide`, { cap: ['tips.interaction.moderate'], sub: 'svc:chat' });
+ assert.strictEqual(svc.status, 200, svc.text);
+ assert.strictEqual(moderated(id)[0].by, 'service');
+ assert.strictEqual((await get('/moderate/alex', stranger)).status, 404);
+ assert.strictEqual((await t.call('GET', '/api/v1/moderation?creator=alex', { user: stranger })).status, 403);
+ const log = await t.call('GET', '/api/v1/moderation/log?creator=alex', { user: alex });
+ assert.ok(log.json.log.some((l) => l.interaction_id === id && l.by_role === 'service' && l.actor === 'svc:chat'));
+ assert.strictEqual((await t.call('GET', '/api/v1/moderation/log?creator=alex', { user: mod })).status, 403);
+ // Removed: the moderator loses access at once.
+ const dash = await get('/dashboard', alex);
+ const rm = await post(`/dashboard/moderators/${mod.subject}/remove`, alex, { csrf: field(dash.text, 'csrf'), idem: 'x' });
+ assert.strictEqual(rm.status, 303);
+ assert.strictEqual((await get('/moderate/alex', mod)).status, 404);
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/restore`, { user: mod })).status, 404);
+ });
+
+ await check('a tip hidden while its payment is pending delivers nothing once paid', async () => {
+ const buyer = t.network.newUser('buyer');
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, message: 'later', pay_with: 'checkout', provider: 'stripe' } });
+ const id = r.json.interaction.id;
+ assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: alex })).status, 200);
+ await t.deliver(billing.settlePurchase(domain.interactions.get(id).billing_intent_id));
+ await new Promise((ok) => setTimeout(ok, 50));
+ await domain.interactions.processDueTransfers();
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(i.delivery_state, 'cancelled');
+ await domain.effects.drain();
+ assert.strictEqual(jobsOf(id).length, 0);
+ assert.ok(!(await state()).some((x) => x.interaction_id === id));
+ });
+
+ await check('simulations run through the filter too, and publish nothing', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { filter: { words: ['badword'], action: 'hold' } } });
+ const r = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 200, message: 'a badword test' } });
+ assert.strictEqual(r.json.interaction.moderation.state, 'held');
+ assert.strictEqual(moderated(r.json.interaction.id).length, 0);
+ const shown = await t.call('POST', `/api/v1/interactions/${r.json.interaction.id}/restore`, { user: alex });
+ assert.strictEqual(shown.json.interaction.moderation.state, 'visible');
+ assert.strictEqual((await state()).find((x) => x.interaction_id === r.json.interaction.id).message, 'a ******* test');
+ // Held, then hidden, then shown: released once, and its delivery state follows.
+ const r2 = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 200, message: 'another badword' } });
+ const id2 = r2.json.interaction.id;
+ await t.call('POST', `/api/v1/interactions/${id2}/hide`, { user: alex });
+ assert.strictEqual(domain.interactions.get(id2).delivery_state, 'cancelled');
+ await t.call('POST', `/api/v1/interactions/${id2}/restore`, { user: alex });
+ await domain.effects.drain();
+ assert.strictEqual(domain.interactions.get(id2).delivery_state, 'delivered');
+ assert.strictEqual(jobsOf(id2).length, 1);
+ assert.ok((await state()).some((x) => x.interaction_id === id2));
+ });
+
+ 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 = e.event_type === 'tips.interaction.moderated' ? validModerated(e.payload) : payloadValidator(e.event_type)(e.payload);
+ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)} ${JSON.stringify(e.payload)}`);
+ }
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });