openvibers/openvibe.tips Commits

Other ways to support: a creator links Ko-fi, PayPal.me, Patreon and eight more services (Tips builds each link from a handle); the tip page shows them as paid outside OpenVibe (#67)

OP OpenVibers committed 27 days ago · 1bfc56c5a4 · 8 files, +200 −2

Changes

ddiff --git a/README.md b/README.md
iindex 1c1a11b..a51e6cd 100644
---- a/README.md
++++ b/README.md
@@@ -351,6 +351,14 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tips
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network
`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
++**Other ways to support** (since 2026-10-17, openvibe-contracts 0.202.0 `support_links`; owner 10-08: Billing is provider
++agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me
++a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most
++8) from the dashboard or `PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}`. The tip page shows them
++as paid outside OpenVibe (never held, never counted toward goals), `rel="nofollow noopener ugc"`. A link is a known
++service plus a handle, and Tips builds the URL ([server/domain/support-links.js](server/domain/support-links.js)): a
++pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page.
++
## Security
Reporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holds
ddiff --git a/migrations/0006_support_links.sql b/migrations/0006_support_links.sql
nnew file mode 100644
iindex 0000000..b27ce01
---- /dev/null
++++ b/migrations/0006_support_links.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- Other ways to support a creator, paid outside OpenVibe (openvibe-contracts 0.202.0 support_links;
++-- server/domain/support-links.js): [{ provider, handle }], one per known service. Tips builds each URL; none is stored.
++ALTER TABLE creator_tip_profiles ADD COLUMN support_links jsonb NOT NULL DEFAULT '[]'::jsonb;
ddiff --git a/public/css/tips.css b/public/css/tips.css
iindex cb5ff4e..0a82ba5 100644
---- a/public/css/tips.css
++++ b/public/css/tips.css
@@@ -63,6 +63,9 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }
.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sounds audio { height: 32px; max-width: 100%; }
++.support-list { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.support-form { display: grid; gap: 4px 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); align-items: end; }
++.support-form button { justify-self: start; }
.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
ol.leaderboard { padding-left: 1.6em; }
ol.leaderboard li, ul.supporters li { margin: 4px 0; }
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex bb4db5f..fac49fb 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -22,6 +22,7 @@
const { sql } = require('openvibe-sdk/db');
const { fail, iso, text } = require('../util');
const filterLib = require('./filter');
++const supportLinks = require('./support-links');
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
@@@ -165,6 +166,7 @@ function createProfiles(ctx) {
}
if (f.links !== undefined) set.filter_links = on(f.links);
}
++ if (patch.support_links !== undefined) set.support_links = sql.json(supportLinks.normalize(patch.support_links));
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');
@@@ -205,6 +207,8 @@ function createProfiles(ctx) {
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
// The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).
sounds: { enabled: !!p.sounds_enabled },
++ // Other ways to support, paid outside OpenVibe (support-links.js): Tips builds each link.
++ support_links: supportLinks.present(p.support_links),
page: p.page,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
currency: 'vibes-bits',
ddiff --git a/server/domain/support-links.js b/server/domain/support-links.js
nnew file mode 100644
iindex 0000000..987d35c
---- /dev/null
++++ b/server/domain/support-links.js
@@@ -0,0 +1,73 @@
++'use strict';
++
++/**
++ * Other ways to support a creator, paid outside OpenVibe (owner 2026-10-08: "our billing system shouldnt be so powerchat
++ * dependent… the user can setup powerchat and such on those sites or other platforms… externally"; openvibe-contracts
++ * 0.202.0 `support_links`). OpenVibe never sees or holds these payments: the tip page only links to them.
++ *
++ * A link is a known service plus the creator's handle there, and Tips builds the URL itself. A free-form URL is never
++ * stored, so a profile cannot carry a look-alike payment page. A creator may paste their link instead of the handle
++ * (https://ko-fi.com/name): the handle is read from it, and only from that service's own host.
++ */
++const { fail } = require('../util');
++
++const PROVIDERS = {
++ kofi: { label: 'Ko-fi', hosts: ['ko-fi.com'], path: /^\/([A-Za-z0-9_]{2,40})\/?$/, handle: /^[A-Za-z0-9_]{2,40}$/, url: (h) => `https://ko-fi.com/${h}` },
++ paypal: { label: 'PayPal.me', hosts: ['paypal.me', 'paypal.com'], path: /^\/(?:paypalme\/)?([A-Za-z0-9]{1,20})\/?$/, handle: /^[A-Za-z0-9]{1,20}$/, url: (h) => `https://paypal.me/${h}` },
++ patreon: { label: 'Patreon', hosts: ['patreon.com'], path: /^\/(?:c\/)?([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://www.patreon.com/${h}` },
++ buymeacoffee: { label: 'Buy Me a Coffee', hosts: ['buymeacoffee.com'], path: /^\/([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://buymeacoffee.com/${h}` },
++ cashapp: { label: 'Cash App', hosts: ['cash.app'], path: /^\/\$([A-Za-z][A-Za-z0-9_-]{0,19})\/?$/, handle: /^[A-Za-z][A-Za-z0-9_-]{0,19}$/, url: (h) => `https://cash.app/$${h}` },
++ venmo: { label: 'Venmo', hosts: ['venmo.com', 'account.venmo.com'], path: /^\/(?:u\/)?([A-Za-z0-9_-]{5,30})\/?$/, handle: /^[A-Za-z0-9_-]{5,30}$/, url: (h) => `https://venmo.com/u/${h}` },
++ github: { label: 'GitHub Sponsors', hosts: ['github.com'], path: /^\/sponsors\/([A-Za-z0-9-]{1,39})\/?$/, handle: /^[A-Za-z0-9-]{1,39}$/, url: (h) => `https://github.com/sponsors/${h}` },
++ liberapay: { label: 'Liberapay', hosts: ['liberapay.com'], path: /^\/([A-Za-z0-9_.-]{1,32})\/?$/, handle: /^[A-Za-z0-9_.-]{1,32}$/, url: (h) => `https://liberapay.com/${h}` },
++ opencollective: { label: 'Open Collective', hosts: ['opencollective.com'], path: /^\/([a-z0-9-]{1,64})\/?$/, handle: /^[a-z0-9-]{1,64}$/, url: (h) => `https://opencollective.com/${h}` },
++ streamlabs: { label: 'Streamlabs', hosts: ['streamlabs.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamlabs.com/${h}/tip` },
++ streamelements: { label: 'StreamElements', hosts: ['streamelements.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamelements.com/${h}/tip` },
++};
++const MAX_LINKS = 8;
++
++/** The handle from what the creator typed: a bare handle (an @ or $ in front is dropped) or their link on that service. */
++function handleOf(provider, value) {
++ const p = PROVIDERS[provider];
++ const raw = String(value == null ? '' : value).trim();
++ if (!raw) return null;
++ if (/^https?:\/\//i.test(raw) || /^(?:www\.)?[a-z0-9.-]+\.[a-z]{2,}\//i.test(raw)) {
++ let u;
++ try { u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return undefined; }
++ const host = u.hostname.toLowerCase().replace(/^www\./, '');
++ if (!p.hosts.includes(host)) return undefined;
++ const m = p.path.exec(u.pathname);
++ return m ? m[1] : undefined;
++ }
++ const h = raw.replace(/^[@$]/, '');
++ return p.handle.test(h) ? h : undefined;
++}
++
++/** A request's support_links → the stored list ([{ provider, handle }], one per provider, in the order given). */
++function normalize(input) {
++ if (!Array.isArray(input)) fail(422, 'tips.invalid_input', 'support_links must be a list of { provider, handle }');
++ const out = [];
++ for (const item of input) {
++ if (!item || typeof item !== 'object') fail(422, 'tips.invalid_input', 'each support link is { provider, handle }');
++ const provider = String(item.provider || '');
++ if (!PROVIDERS[provider]) fail(422, 'tips.invalid_input', `support link provider must be one of ${Object.keys(PROVIDERS).join(', ')}`);
++ const handle = handleOf(provider, item.handle);
++ if (handle === null) continue; // left blank: no link for that service
++ if (handle === undefined) fail(422, 'tips.invalid_input', `that is not a ${PROVIDERS[provider].label} name or link`);
++ if (out.some((l) => l.provider === provider)) fail(422, 'tips.invalid_input', `one ${PROVIDERS[provider].label} link only`);
++ out.push({ provider, handle });
++ }
++ if (out.length > MAX_LINKS) fail(422, 'tips.invalid_input', `at most ${MAX_LINKS} other ways to support you`);
++ return out;
++}
++
++/** Stored list → what pages and the API show (unknown or invalid rows are skipped, never shown). */
++function present(stored) {
++ return (Array.isArray(stored) ? stored : []).map((l) => {
++ const p = l && PROVIDERS[l.provider];
++ if (!p || !p.handle.test(String(l.handle || ''))) return null;
++ return { provider: l.provider, label: p.label, handle: l.handle, url: p.url(l.handle) };
++ }).filter(Boolean).slice(0, MAX_LINKS);
++}
++
++module.exports = { PROVIDERS, MAX_LINKS, handleOf, normalize, present };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 58adb2a..24857fa 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -8,6 +8,7 @@
const showcase = require('openvibe-shared/showcase');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
++const supportLinks = require('../domain/support-links');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
@@@ -128,6 +129,16 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
</form>`;
}
++/** Other ways to support, paid outside OpenVibe (domain/support-links.js builds every link; none is user-written). */
++function supportLinksSection(profile) {
++ const links = supportLinks.present(profile.support_links);
++ if (!links.length) return '';
++ return `<section class="card support-links"><h2>Other ways to support ${esc(profile.display_name)}</h2>
++ <ul class="support-list">${links.map((l) => `<li><a class="btn ghost" href="${esc(l.url)}" rel="nofollow noopener ugc" target="_blank">${esc(l.label)}</a></li>`).join('')}</ul>
++ <p class="fine">These are paid outside OpenVibe: OpenVibe never sees or holds them, and they do not count toward goals.</p>
++</section>`;
++}
++
function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {
const active = goals.filter((g) => g.status === 'active');
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
@@@ -137,7 +148,8 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
</section>
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
--${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}`;
++${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
++${supportLinksSection(profile)}`;
}
function goalsPage({ profile, goals }) {
@@@ -236,6 +248,19 @@ function soundsCard({ profile: p, sounds, csrf, idem }) {
</section>`;
}
++/** The dashboard's other ways to support: one field per known service (a name, or the creator's link there). */
++function supportLinksCard({ profile: p, csrf, idem }) {
++ const have = new Map(supportLinks.present(p.support_links).map((l) => [l.provider, l.handle]));
++ const hint = { kofi: 'ko-fi.com/…', paypal: 'paypal.me/…', patreon: 'patreon.com/…', buymeacoffee: 'buymeacoffee.com/…', cashapp: 'cash.app/$…', venmo: 'venmo.com/u/…', github: 'github.com/sponsors/…', liberapay: 'liberapay.com/…', opencollective: 'opencollective.com/…', streamlabs: 'streamlabs.com/…/tip', streamelements: 'streamelements.com/…/tip' };
++ return `<section class="card" id="support-links"><h2>Other ways to support you</h2>
++ <p>Your tip page can link to where else people pay you. Type your name on each service you use, or paste your link there; leave the rest blank. Up to ${supportLinks.MAX_LINKS}. These payments happen outside OpenVibe and do not count toward goals.</p>
++ <form method="post" action="/dashboard/support-links" class="support-form">${hidden({ csrf, idem: `${idem}-sl` })}
++ ${Object.entries(supportLinks.PROVIDERS).map(([k, v]) => `<label>${esc(v.label)} <input type="text" name="link_${k}" maxlength="200" autocomplete="off" spellcheck="false" placeholder="${esc(hint[k] || '')}" value="${esc(have.get(k) || '')}"></label>`).join('\n ')}
++ <button class="btn" type="submit">Save links</button>
++ </form>
++</section>`;
++}
++
function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {
const p = profile;
const f = (k) => (p[k] ? ' checked' : '');
@@@ -247,7 +272,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<div class="card"><h2>Totals</h2>
<p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p>
<p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>
-- <p class="fine">Simulations (${n(totals.simulations)}) are never counted. Your balance and cash-outs are on OpenVibe.Live until Billing takes over.</p>
++ <p class="fine">Simulations (${n(totals.simulations)}) are never counted. OpenVibe.Billing keeps your Vibes: your balance and cash-outs are in <a href="https://openvibe.live/#vibes">your Vibes</a>.</p>
</div>
<div class="card"><h2>Your page</h2>
<p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>
@@@ -280,6 +305,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</div>
</section>
${soundsCard({ profile: p, sounds, csrf, idem })}
++${supportLinksCard({ profile: p, csrf, idem })}
<section class="card"><h2>Goals</h2>
${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex c1d347f..1a68dce 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -36,6 +36,7 @@ const pages = require('./pages');
const { asset } = require('./layout');
const { PAGE_DEFAULTS } = require('../domain/profiles');
const multipart = require('./multipart');
++const supportLinks = require('../domain/support-links');
// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
const BOOT_AT = new Date().toISOString();
@@@ -259,6 +260,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
return { done: 'Page settings saved' };
});
action('/dashboard/goals', async (req, p) => { await goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });
++ action('/dashboard/support-links', async (req, p) => {
++ const links = Object.keys(supportLinks.PROVIDERS).map((provider) => ({ provider, handle: req.body[`link_${provider}`] }));
++ await profiles.update(p.creator_subject, { support_links: links });
++ return { done: 'Links saved' };
++ });
const ownGoal = async (req, p) => {
const g = await goals.get(db, req.params.id);
if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');
ddiff --git a/test/support-links.test.js b/test/support-links.test.js
nnew file mode 100644
iindex 0000000..04a52b0
---- /dev/null
++++ b/test/support-links.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++// Other ways to support a creator, paid outside OpenVibe (owner 10-08: Billing is provider agnostic; creators bring
++// Ko-fi, PayPal.me, Patreon and other links). A link is a known service plus the creator's handle, and Tips builds the
++// URL: a pasted link counts only on that service's own host, so no look-alike page can be stored.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const links = require('../server/domain/support-links');
++
++(async () => {
++ await check('a handle or the link on that service; any other host or a bad name is refused', async () => {
++ assert.strictEqual(links.handleOf('kofi', 'tierra_tv'), 'tierra_tv');
++ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com/tierra_tv'), 'tierra_tv');
++ assert.strictEqual(links.handleOf('kofi', 'ko-fi.com/tierra_tv/'), 'tierra_tv');
++ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com.evil.example/tierra'), undefined, 'a look-alike host');
++ assert.strictEqual(links.handleOf('kofi', 'https://evil.example/ko-fi.com/tierra'), undefined);
++ assert.strictEqual(links.handleOf('paypal', 'https://www.paypal.com/paypalme/Tierra'), 'Tierra');
++ assert.strictEqual(links.handleOf('cashapp', '$tierra'), 'tierra');
++ assert.strictEqual(links.handleOf('cashapp', 'https://cash.app/$tierra'), 'tierra');
++ assert.strictEqual(links.handleOf('github', 'https://github.com/sponsors/tierra'), 'tierra');
++ assert.strictEqual(links.handleOf('github', 'https://github.com/tierra'), undefined, 'only the Sponsors page');
++ assert.strictEqual(links.handleOf('streamlabs', 'streamlabs.com/tierra/tip'), 'tierra');
++ assert.strictEqual(links.handleOf('patreon', 'javascript:alert(1)'), undefined);
++ assert.strictEqual(links.handleOf('kofi', ' '), null, 'blank means no link');
++ assert.deepStrictEqual(links.present([{ provider: 'kofi', handle: 'x"><script>' }, { provider: 'myspace', handle: 'a' }, { provider: 'liberapay', handle: 'tierra' }]),
++ [{ provider: 'liberapay', label: 'Liberapay', handle: 'tierra', url: 'https://liberapay.com/tierra' }], 'stored rows that are not valid are never shown');
++ });
++
++ const t = await boot();
++ const tierra = await t.creator('tierra');
++ const viewer = t.network.newUser('viewer');
++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
++ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, 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 API sets them (a pasted link becomes the handle); the public profile carries the links Tips built', async () => {
++ const r = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://ko-fi.com/tierra_tv' }, { provider: 'paypal', handle: 'TierraTV' }] } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json.profile.support_links, [
++ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra_tv', url: 'https://ko-fi.com/tierra_tv' },
++ { provider: 'paypal', label: 'PayPal.me', handle: 'TierraTV', url: 'https://paypal.me/TierraTV' },
++ ]);
++ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });
++ assert.strictEqual(pub.json.profile.support_links.length, 2);
++ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://k0-fi.com/tierra' }] } });
++ assert.strictEqual(bad.status, 422);
++ const twice = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'a1' }, { provider: 'kofi', handle: 'b2' }] } });
++ assert.strictEqual(twice.status, 422);
++ const other = await t.call('PATCH', '/api/v1/profiles/tierra', { user: viewer, body: { support_links: [] } });
++ assert.strictEqual(other.status, 403, 'only the creator (or a granted service) changes them');
++ });
++
++ await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {
++ const page = await get('/tierra');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Other ways to support/);
++ assert.match(page.text, /<a class="btn ghost" href="https:\/\/ko-fi\.com\/tierra_tv" rel="nofollow noopener ugc" target="_blank">Ko-fi<\/a>/);
++ assert.match(page.text, /paid outside OpenVibe: OpenVibe never sees or holds them/);
++ });
++
++ await check('the dashboard form saves one field per service; blank ones are removed', async () => {
++ const d = await get('/dashboard', tierra);
++ assert.match(d.text, /id="support-links"/);
++ assert.match(d.text, /name="link_kofi"[^>]*value="tierra_tv"/);
++ const body = new URLSearchParams({ csrf: field(d.text, 'csrf'), idem: `sl-${Date.now()}`, link_kofi: '', link_patreon: 'https://www.patreon.com/tierra', link_cashapp: '$tierra' });
++ const r = await fetch(`${t.base}/dashboard/support-links`, { method: 'POST', body, redirect: 'manual', headers: { Cookie: cookie(tierra), 'Content-Type': 'application/x-www-form-urlencoded' } });
++ const txt = await r.text();
++ assert.strictEqual(r.status, 303, (/class="error[^"]*"[^>]*>([\s\S]{0,200})/.exec(txt) || [])[1] || String(r.status));
++ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });
++ assert.deepStrictEqual(pub.json.profile.support_links.map((l) => l.url), ['https://www.patreon.com/tierra', 'https://cash.app/$tierra']);
++ });
++
++ await t.close();
++ done();
++})();