openvibers/openvibe.vip Commits

Creator pages at /@name; the root /username 301s there for the 301 window (#206)

OP OpenVibers committed 6 days ago · 9e576b7716 · 16 files, +126 −75

Changes

ddiff --git a/README.md b/README.md
iindex 267e933..8b5772b 100644
---- a/README.md
++++ b/README.md
@@@ -25,7 +25,7 @@ The nine authority tables (VIP is their only writer):
| Table | Holds |
|---|---|
--| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself |
++| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/@username`; the old `/:username` 301s there) or the network itself |
| `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |
| `vip_plan_versions` | **immutable** plan terms, one row per edit (database triggers refuse UPDATE/DELETE except the one-time publish stamp) |
| `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other |
@@@ -364,9 +364,9 @@ memberships* card gives the creator the badge and widget HTML and the card URL.
## Pages (server-rendered, useful without JavaScript, OpenVibe Frame)
--`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,
++`/` directory of creators with published plans · `/@username` a creator's plans (terms, perks,
Billing's price and period from `GET /api/v1/rates`, join form) and their members-only posts (the
--title and the tier are public, the text only for a member of that tier) · `/:username/plans/:slug` public terms
++title and the tier are public, the text only for a member of that tier) · `/@username/plans/:slug` public terms
history · `/me` your memberships, the version you joined under, cancel, badge preference ·
`/dashboard` plans, versions, perks and bindings, members, members-only resources, the members-only
posts (write, hide) and, for staff, the moderation list, the badge and
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex fb1f47b..2d5e56f 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -44,7 +44,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
const name = c.display_name || c.username;
return {
html: layout.page({
-- title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, robots: 'index,follow',
++ title: `${name} memberships`, active: 'creator', canonicalPath: `/@${c.username}`, robots: 'index,follow',
description: c.bio || `Membership plans from ${name} on OpenVibe.`, viewer: null,
body: pages.creatorPage({
creator: creators.present(c), plans: list, viewer: null, member: null, rates: chargeRates,
@@@ -58,7 +58,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
const p = String(path || '/').replace(/\/+$/, '') || '/';
if (p === '/') return home();
if (p === '/updates') return updates();
-- const m = /^\/([^/]+)$/.exec(p);
++ const m = /^\/@([^/]+)$/.exec(p);
if (!m) return null;
return creator(decodeURIComponent(m[1]));
}
ddiff --git a/server/app.js b/server/app.js
iindex 74afad9..f053369 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -44,6 +44,9 @@ const pages = require('./web/pages');
const VERSION = require('../package.json').version;
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++// Names the site's own routes use; an old /<name> link for one of these is never a creator page.
++const RESERVED_CREATOR = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'llms.txt', 'llms-full.txt', 'release.json', 'metrics', 'favicon.ico', 'embed']);
++
async function createApp(opts = {}) {
const config = opts.config || loadConfig();
const log0 = opts.log || console;
@@@ -160,6 +163,20 @@ async function createApp(opts = {}) {
},
}));
const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });
++ // Old root creator links (and their .md twins) move to /@name with one 301, before the agent layer, which
++ // would otherwise sign /name.md with the old path as its canonical. Reserved names and unknown creators
++ // fall through (404), and only GET/HEAD move.
++ app.use(async (req, res, next) => {
++ if (!['GET', 'HEAD'].includes(req.method)) return next();
++ const m = /^\/([^/@][^/]*)(\/plans\/[^/]+)?(\.md)?$/.exec(req.path);
++ if (!m) return next();
++ if (RESERVED_CREATOR.has(String(m[1]).toLowerCase())) return next();
++ let c;
++ try { c = await domain.creators.byUsername(m[1]); } catch { return next(); }
++ if (!c || c.status !== 'active') return next();
++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
++ return res.redirect(301, `/@${encodeURIComponent(c.username)}${m[2] || ''}${m[3] || ''}${q}`);
++ });
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
// public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's member
// state, join form and anti-forgery token are theirs — so only the manifest and twin requests reach
ddiff --git a/server/domain/cards.js b/server/domain/cards.js
iindex 3338cd4..ab96eeb 100644
---- a/server/domain/cards.js
++++ b/server/domain/cards.js
@@@ -81,7 +81,7 @@ function createCards({ db, config, creators, plans, perks, billing, prices = nul
const urls = (c) => {
const u = encodeURIComponent(c.username);
return {
-- page: `${config.baseUrl}/${u}`, badge: `${config.baseUrl}/embed/${u}/badge.svg`,
++ page: `${config.baseUrl}/@${u}`, badge: `${config.baseUrl}/embed/${u}/badge.svg`,
widget: `${config.baseUrl}/embed/${u}/widget`, card: `${config.baseUrl}/embed/${u}/card.json`,
};
};
ddiff --git a/server/domain/plans.js b/server/domain/plans.js
iindex 0994023..acc2176 100644
---- a/server/domain/plans.js
++++ b/server/domain/plans.js
@@@ -38,7 +38,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
if (!indexnow || !indexnow.enabled || !baseUrl || !plan) return;
const creator = await creators.byId(plan.creator_id);
if (!creator || !creator.username) return; // network plans have no public page
-- const page = `${baseUrl}/${encodeURIComponent(creator.username)}`;
++ const page = `${baseUrl}/@${encodeURIComponent(creator.username)}`;
indexnow.pingSoon([page, `${page}/plans/${encodeURIComponent(plan.slug)}`, `${baseUrl}/sitemap.xml`]);
} catch { /* a ping never fails a publish */ }
}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 6040556..e20f20a 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -95,10 +95,10 @@ function home({ creators, live = [], networkUrl = 'https://openvibe.network' })
const liveSection = liveCreators.length ? `
<section class="sc-sec" id="live" aria-labelledby="live-h"><h2 id="live-h">Live now</h2><p class="sc-lede">Creators with a membership who are streaming on OpenVibe.Live right now.</p>
--<ul class="vip-live">${liveCreators.map(({ c, s }) => `<li>${avatar(networkUrl, c.username, 48)}<div class="vip-live-main"><p class="vip-live-name"><b>${esc(c.display_name || c.username)}</b> <span class="vip-live-pill">Live</span></p>${s.title ? `<p class="vip-live-title">${esc(s.title)}</p>` : ''}<p class="muted small">${s.category ? `${esc(s.category)} · ` : ''}${plural(s.viewers, 'viewer', 'viewers')}</p><p class="vip-live-links"><a href="${esc(s.url)}" rel="noopener">Watch</a><a href="/${encodeURIComponent(c.username)}">See the plan</a></p></div></li>`).join('')}</ul></section>` : '';
++<ul class="vip-live">${liveCreators.map(({ c, s }) => `<li>${avatar(networkUrl, c.username, 48)}<div class="vip-live-main"><p class="vip-live-name"><b>${esc(c.display_name || c.username)}</b> <span class="vip-live-pill">Live</span></p>${s.title ? `<p class="vip-live-title">${esc(s.title)}</p>` : ''}<p class="muted small">${s.category ? `${esc(s.category)} · ` : ''}${plural(s.viewers, 'viewer', 'viewers')}</p><p class="vip-live-links"><a href="${esc(s.url)}" rel="noopener">Watch</a><a href="/@${encodeURIComponent(c.username)}">See the plan</a></p></div></li>`).join('')}</ul></section>` : '';
const list = ordered.length
-- ? `<ul class="creator-list">${ordered.map((c) => `<li><a href="/${encodeURIComponent(c.username)}">${avatar(networkUrl, c.username, 40)}<span class="vip-creator-text"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></span>${onAir.has(c.username.toLowerCase()) ? '<span class="vip-live-pill">Live</span>' : ''}</a></li>`).join('')}</ul>`
++ ? `<ul class="creator-list">${ordered.map((c) => `<li><a href="/@${encodeURIComponent(c.username)}">${avatar(networkUrl, c.username, 40)}<span class="vip-creator-text"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></span>${onAir.has(c.username.toLowerCase()) ? '<span class="vip-live-pill">Live</span>' : ''}</a></li>`).join('')}</ul>`
: '<p class="muted">No creator has published a plan here yet.</p>';
const body = showcase.hero({
@@@ -240,11 +240,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
: member && member.status === 'active' ? ''
: !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
: viewer
-- ? `<form method="post" action="/${encodeURIComponent(creator.username)}/join" class="join">${csrf(token)}<input type="hidden" name="plan_id" value="${esc(p.id)}">
++ ? `<form method="post" action="/@${encodeURIComponent(creator.username)}/join" class="join">${csrf(token)}<input type="hidden" name="plan_id" value="${esc(p.id)}">
<label>Pay with <select name="provider">${providers.map((x) => `<option value="${esc(x)}">${esc(x === 'credit' ? 'your Vibes credit' : x === 'powerchat' ? 'PowerChat' : x)}</option>`).join('')}</select></label>
<label class="check"><input type="checkbox" name="auto_renew" value="1" checked> Renew automatically</label>
<button type="submit">Join ${esc(v.name)}</button></form>`
-- : `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;
++ : `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/@${creator.username}`)}">Sign in to join</a></p>`;
return `<article class="card plan" id="plan-${esc(p.slug)}">
<h2>${esc(v.name)}${plans.filter((x) => x.billing_kind).length > 1 && p.tier ? ` <span class="pill">Tier ${esc(p.tier)}</span>` : ''}</h2>
${v.description ? `<p>${esc(v.description)}</p>` : ''}
@@@ -252,7 +252,7 @@ ${benefitList(v.benefits)}
${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
${p.purchasable ? priceLine(rates, p.price_cents) : ''}
${join}
--<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>
++<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/@${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>
</article>`;
}).join('') : '<p class="muted">No plan is published yet.</p>';
return `<section class="creator-head"><p class="eyebrow">Membership</p><h1>${esc(name)}</h1><p class="muted">@${esc(creator.username)}</p>
@@@ -265,13 +265,13 @@ ${postsSection(posts)}`;
/** Public terms history of one plan: every published version, unchanged. */
function planHistory({ creator, plan, versions }) {
-- return `<section><p class="eyebrow"><a href="/${encodeURIComponent(creator.username)}">${esc(creator.display_name || creator.username)}</a></p>
++ return `<section><p class="eyebrow"><a href="/@${encodeURIComponent(creator.username)}">${esc(creator.display_name || creator.username)}</a></p>
<h1>Terms history</h1>
<p class="muted">Each edit of a plan is a new version. Members keep the version they joined under; earlier versions are never rewritten.</p>
${versions.map((v) => `<article class="card version"><h2>${esc(v.name)} <span class="muted">v${esc(v.version)}</span></h2>
<p class="muted small">Published ${fmtDate(v.published_at)}${v.change_note ? ` · ${esc(v.change_note)}` : ''}</p>
${v.description ? `<p>${esc(v.description)}</p>` : ''}${benefitList(v.benefits)}${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}</article>`).join('')}
--<p><a href="/${encodeURIComponent(creator.username)}#plan-${esc(plan.slug)}">Back to the plan</a></p></section>`;
++<p><a href="/@${encodeURIComponent(creator.username)}#plan-${esc(plan.slug)}">Back to the plan</a></p></section>`;
}
/** The member's own status page. */
@@@ -281,7 +281,7 @@ function memberPage({ viewer, rows, token, query }) {
<p><a class="button" href="/auth/login?next=%2Fme">Sign in</a></p></section>`;
}
const body = rows.length ? rows.map(({ membership: m, creator: c, entitlement: e, preferences: pref }) => {
-- const who = c ? (c.username ? `<a href="/${encodeURIComponent(c.username)}">${esc(c.display_name || c.username)}</a>` : esc(c.display_name || 'a creator')) : 'a creator';
++ const who = c ? (c.username ? `<a href="/@${encodeURIComponent(c.username)}">${esc(c.display_name || c.username)}</a>` : esc(c.display_name || 'a creator')) : 'a creator';
const state = !e ? '<span class="pill">unknown</span>'
: e.status === 'active' ? `<span class="pill pill-on">active</span> ${e.cancel_at_period_end ? `ends ${fmtDate(e.expires_at)}` : `paid through ${fmtDate(e.expires_at)}`}`
: e.status === 'inactive' ? `<span class="pill">ended</span>${e.expires_at ? ` ${fmtDate(e.expires_at)}` : ''}`
@@@ -360,7 +360,7 @@ function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPe
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
}
const title = isNetwork ? 'Network plans' : 'Your membership plans';
-- const pageLink = !isNetwork && creator.username ? `<p class="muted">Your public page: <a href="/${encodeURIComponent(creator.username)}">openvibe.vip/${esc(creator.username)}</a></p>` : '';
++ const pageLink = !isNetwork && creator.username ? `<p class="muted">Your public page: <a href="/@${encodeURIComponent(creator.username)}">openvibe.vip/@${esc(creator.username)}</a></p>` : '';
const planOptions = plans.filter((p) => p.status !== 'archived').map((p) => `<option value="${esc(p.id)}">${esc((p.current_version || p.draft_version || {}).name || p.slug)}</option>`).join('');
return `<section><h1>${title}</h1>${isStaff ? `<p class="muted small">${isNetwork ? '<a href="/dashboard">Your own plans</a>' : '<a href="/dashboard?as=network">Network plans (staff)</a>'}</p>` : ''}${pageLink}${notice(query)}</section>
${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" action="${base}/profile">${csrf(token)}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 5d77b97..ffebdf0 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -126,7 +126,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
disallow: ['/me', '/dashboard', '/auth/', '/api/', '/embed/'],
})));
router.get('/sitemap.xml', async (req, res) => {
-- const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...(await creators.listPublic({ limit: 5000 })).map((c) => ({ loc: `${config.baseUrl}/${encodeURIComponent(c.username)}`, lastmod: BOOT_AT }))];
++ const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...(await creators.listPublic({ limit: 5000 })).map((c) => ({ loc: `${config.baseUrl}/@${encodeURIComponent(c.username)}`, lastmod: BOOT_AT }))];
res.type('application/xml').send(seo.sitemapXml(urls));
});
// /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.
@@@ -174,14 +174,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const v = p.current_version;
about.push(`Plan ${v.name} (version ${v.version})${v.description ? `: ${v.description}` : ''}`);
}
-- creatorPages.push({ title: `${name} memberships`, url: `/${c.username}`, text: about.join('\n\n') });
++ creatorPages.push({ title: `${name} memberships`, url: `/@${c.username}`, text: about.join('\n\n') });
for (const p of planRows) {
const body = [`The published terms of ${name}'s ${p.current_version.name} plan on OpenVibe.VIP. Each edit is a new version; a member keeps the version they joined under.`];
for (const v of (p.versions || []).filter((x) => x.published_at)) {
const terms = termsText(v);
body.push(`Version ${v.version}${v.published_at ? ` (published ${String(v.published_at).slice(0, 10)})` : ''}${terms ? `\n${terms}` : ''}`);
}
-- planPages.push({ title: `${name}: ${p.current_version.name} terms`, url: `/${c.username}/plans/${p.slug}`, text: body.join('\n\n') });
++ planPages.push({ title: `${name}: ${p.current_version.name} terms`, url: `/@${c.username}/plans/${p.slug}`, text: body.join('\n\n') });
}
}
return seo.llmsFull({
@@@ -420,12 +420,12 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
}, { backTo: '/dashboard' });
// ── Creator pages ────────────────────────────────────────
-- router.get('/:username', async (req, res, next) => {
++ router.get('/@:username', async (req, res, next) => {
try {
if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
-- if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
++ if (c.username !== req.params.username) return res.redirect(301, `/@${encodeURIComponent(c.username)}`);
const list = await prices.withPrices(c.subject, (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))));
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
@@@ -449,29 +449,51 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
id: row.id, title: row.title, tiers, published_at: row.published_at,
image: row.image_id ? { id: row.image_id, url: row.image_url } : null,
unlocked: !!access.unlocked, body_html: access.unlocked ? row.body_html : null,
-- join_url: target ? `/${encodeURIComponent(c.username)}#plan-${encodeURIComponent(target.slug)}` : null,
++ join_url: target ? `/@${encodeURIComponent(c.username)}#plan-${encodeURIComponent(target.slug)}` : null,
});
}
send(res, 200, {
-- title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,
++ title: `${name} memberships`, active: 'creator', canonicalPath: `/@${c.username}`, viewer: req.viewer,
description: c.bio || `Membership plans from ${name} on OpenVibe.`,
robots: list.length ? 'index,follow' : 'noindex,follow',
body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner, posts }),
});
} catch (e) { next(e); }
});
-- router.get('/:username/plans/:slug', async (req, res) => {
++ router.get('/@:username/plans/:slug', async (req, res) => {
const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
const plan = (await plans.list({ creatorId: c.id, includeArchived: true })).find((p) => p.slug === req.params.slug);
if (!plan) return notFound(req, res);
const versions = (await Promise.all((await plans.versions(plan.id)).filter((v) => v.published_at).map(plans.presentVersion)));
return send(res, 200, {
-- title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',
++ title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/@${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',
body: pages.planHistory({ creator: creators.present(c), plan, versions }),
});
});
-- post('/:username/join', async (req, res, to) => {
++ // The canonical creator address is /@<username>. The root /<username> form is kept only as a 301 from old
++ // links (query kept); a name that is not an active creator still 404s.
++ const legacyCreator = (suffix) => async (req, res, next) => {
++ try {
++ if (RESERVED.has(String(req.params.username).toLowerCase())) return notFound(req, res);
++ const c = await creators.byUsername(req.params.username);
++ if (!c || c.status !== 'active') return notFound(req, res);
++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
++ return res.redirect(301, `/@${encodeURIComponent(c.username)}${suffix}${q}`);
++ } catch (e) { next(e); }
++ };
++ router.get('/:username', legacyCreator(''));
++ router.get('/:username/plans/:slug', async (req, res, next) => {
++ try {
++ if (RESERVED.has(String(req.params.username).toLowerCase())) return notFound(req, res);
++ const c = await creators.byUsername(req.params.username);
++ if (!c || c.status !== 'active') return notFound(req, res);
++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
++ return res.redirect(301, `/@${encodeURIComponent(c.username)}/plans/${encodeURIComponent(req.params.slug)}${q}`);
++ } catch (e) { next(e); }
++ });
++
++ post('/@:username/join', async (req, res, to) => {
const c = await creators.byUsername(req.params.username);
if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
const plan = await plans.byId(String(req.body.plan_id || ''));
@@@ -483,7 +505,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
if (out.membership_started) return back(res, '/me', 'ok', `Welcome — you are now a member of ${c.display_name || c.username}.`);
if (out.checkout_url && !out.checkout_ref) return res.redirect(303, out.checkout_url);
return send(res, 200, { title: 'Finish joining', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.checkoutPage({ creator: creators.present(c), out }) });
-- }, { backTo: (req) => `/${req.params.username}` });
++ }, { backTo: (req) => `/@${req.params.username}` });
++
++ // An old in-flight join form: 308 keeps the method and body while moving it to the canonical address.
++ router.post('/:username/join', async (req, res, next) => {
++ if (String(req.params.username).startsWith('@')) return next(); // the canonical /@:username/join route above
++ try {
++ const c = await creators.byUsername(req.params.username);
++ if (!c || c.status !== 'active') return notFound(req, res);
++ return res.redirect(308, `/@${encodeURIComponent(c.username)}/join`);
++ } catch (e) { next(e); }
++ });
return router;
}
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 209e349..fdb3026 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -160,35 +160,36 @@ const { test, run } = harness('api');
});
test('public creator page works without JavaScript and without an account', async () => {
-- const p = await t.page('/lena');
++ const p = await t.page('/@lena');
assert.strictEqual(p.status, 200);
assert.match(p.text, /Front row/);
assert.match(p.text, /Monthly stream/);
assert.match(p.text, /\$4\.99<\/b> every 30 days/);
assert.match(p.text, /Sign in to join/);
-- assert.match(p.text, /<link rel="canonical" href="http:\/\/vip\.test\/lena">/);
++ assert.match(p.text, /<link rel="canonical" href="http:\/\/vip\.test\/@lena">/);
assert.match(p.text, /\/shared\/navbar\.js\?v=[0-9a-f]{12}/);
assert.match(p.text, /<noscript>/);
assert.match(p.text, /<meta name="ov-boost" content="vip@[^"]+">/);
assert.match(p.text, /\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer>/);
assert.match(p.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
assert.doesNotMatch(p.text, /\bfree\b|\$0|no ads/i);
-- const hist = await t.page(`/lena/plans/${plan.slug}`);
++ const hist = await t.page(`/@lena/plans/${plan.slug}`);
assert.strictEqual(hist.status, 200);
assert.match(hist.text, /v1/);
assert.match(hist.text, /v2/);
-- assert.strictEqual((await t.page('/LENA')).location, '/lena');
++ assert.strictEqual((await t.page('/LENA')).location, '/@lena');
++ assert.deepStrictEqual([(await t.page('/lena?from=old')).status, (await t.page('/lena?from=old')).location], [301, '/@lena?from=old']);
assert.strictEqual((await t.page('/nobody-here')).status, 404);
const sm = await t.page('/sitemap.xml');
-- assert.match(sm.text, /http:\/\/vip\.test\/lena/);
++ assert.match(sm.text, /http:\/\/vip\.test\/@lena/);
});
test('the head comes from openvibe-shared/shell and keeps every tag and script of the site', async () => {
-- const p = await t.page('/lena');
++ const p = await t.page('/@lena');
const head = p.text.slice(0, p.text.indexOf('</head>'));
assert.strictEqual(head.match(/<title>/g).length, 1, 'exactly one <title>');
assert.match(head, /<title>Lena memberships · OpenVibe\.VIP<\/title>/);
-- assert.match(head, /<link rel="canonical" href="http:\/\/vip\.test\/lena">/);
++ assert.match(head, /<link rel="canonical" href="http:\/\/vip\.test\/@lena">/);
assert.match(head, /<meta name="robots" content="index,follow">/);
assert.match(head, /<meta property="og:title" content="Lena memberships · OpenVibe\.VIP">/);
assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
@@@ -215,7 +216,7 @@ const { test, run } = harness('api');
assert.match(home.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);
assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('id="creators"'), 'the creators follow the showcase');
-- assert.match(home.text, /href="\/lena"/, 'a creator with a plan is listed');
++ assert.match(home.text, /href="\/@lena"/, 'a creator with a plan is listed');
assert.ok(!/sc-tiers|sc-price/.test(home.text), 'no price on the home: Billing prices each plan on its creator page');
assert.ok(!p.text.includes('showcase.css'), 'a creator page does not link the kit');
});
@@@ -230,7 +231,8 @@ const { test, run } = harness('api');
assert.strictEqual(sm.status, 200);
assert.match(sm.headers.get('content-type'), /^application\/xml/);
assert.match(sm.text, /<loc>http:\/\/vip\.test\/<\/loc>/, 'the sitemap lists the home URL');
-- assert.match(sm.text, /<loc>http:\/\/vip\.test\/lena<\/loc>/, 'the sitemap lists creator pages');
++ assert.match(sm.text, /<loc>http:\/\/vip\.test\/@lena<\/loc>/, 'the sitemap lists creator pages');
++ assert.ok(!sm.text.includes('<loc>http://vip.test/lena</loc>'), 'the old root form is not a sitemap page');
assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');
const llms = await t.call('GET', '/llms.txt', { token: null });
assert.strictEqual(llms.status, 200);
@@@ -246,14 +248,14 @@ const { test, run } = harness('api');
assert.strictEqual(full.headers.get('cache-control'), 'public, max-age=3600', 'llms-full.txt is cached for an hour');
assert.match(full.text, /^# OpenVibe\.VIP/, 'llms-full.txt starts with the llms.txt header');
assert.match(full.text, /URL: http:\/\/vip\.test\/\n/, 'the home page');
-- assert.match(full.text, /URL: http:\/\/vip\.test\/lena\n/, 'the public creator page');
-- assert.match(full.text, new RegExp(`URL: http:\\/\\/vip\\.test\\/lena\\/plans\\/${plan.slug}\\n`), 'the plan terms page');
++ assert.match(full.text, /URL: http:\/\/vip\.test\/@lena\n/, 'the public creator page');
++ assert.match(full.text, new RegExp(`URL: http:\\/\\/vip\\.test\\/@lena\\/plans\\/${plan.slug}\\n`), 'the plan terms page');
assert.match(full.text, /Front row/, 'the full plan text');
assert.match(full.text, /Monthly stream/, 'a benefit of the current version');
});
test('every rendered page carries the boost marker, boost script (data-main) and the {path} sign-in template', async () => {
-- const pages = [['/', null], ['/lena', null], ['/updates', null], ['/me', buyer], ['/dashboard', creator]];
++ const pages = [['/', null], ['/@lena', null], ['/updates', null], ['/me', buyer], ['/dashboard', creator]];
for (const [p, user] of pages) {
const r = await t.page(p, user ? { user } : undefined);
assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
@@@ -267,19 +269,19 @@ const { test, run } = harness('api');
test('joining from the page: form with anti-forgery token → Billing credit → member page', async () => {
const x = t.network.newUser('rita');
t.billing.state.credit.set(x.subject, 600);
-- const p = await t.page('/lena', { user: x });
++ const p = await t.page('/@lena', { user: x });
const token = t.csrf(p.text);
assert.ok(token);
-- const forged = await t.page('/lena/join', { user: x, method: 'POST', form: { plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
++ const forged = await t.page('/@lena/join', { user: x, method: 'POST', form: { plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
assert.strictEqual(forged.status, 303);
assert.match(forged.location, /error=/);
-- const ok = await t.page('/lena/join', { user: x, method: 'POST', form: { _csrf: token, plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
++ const ok = await t.page('/@lena/join', { user: x, method: 'POST', form: { _csrf: token, plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
assert.strictEqual(ok.status, 303);
assert.match(ok.location, /^\/me\?ok=/);
const me = await t.page('/me', { user: x });
assert.match(me.text, /Front row<\/b> v2/);
assert.match(me.text, /Cancel at period end/);
-- const handoff = await t.page('/lena/join', { user: t.network.newUser('sam'), method: 'POST', form: { _csrf: 'x'.repeat(32), plan_id: plan.id, provider: 'stripe' } });
++ const handoff = await t.page('/@lena/join', { user: t.network.newUser('sam'), method: 'POST', form: { _csrf: 'x'.repeat(32), plan_id: plan.id, provider: 'stripe' } });
assert.match(handoff.location, /error=/);
});
@@@ -319,7 +321,7 @@ const { test, run } = harness('api');
try {
const c = t2.network.newUser('uma', { role: 'streamer' });
const p = (await t2.call('POST', '/api/v1/plans', { user: c, body: { name: 'Club', publish: true } })).json.plan;
-- const page = await t2.page('/uma', { user: t2.network.newUser('vic') });
++ const page = await t2.page('/@uma', { user: t2.network.newUser('vic') });
assert.match(page.text, /Joining through OpenVibe\.Billing is not open yet/);
assert.doesNotMatch(page.text, /<select name="provider">/);
const r = await t2.call('POST', '/api/v1/checkout', { user: t2.network.newUser('wes'), body: { plan_id: p.id, provider: 'powerchat' } });
ddiff --git a/test/embeds.test.js b/test/embeds.test.js
iindex 773bd6f..0db5df8 100644
---- a/test/embeds.test.js
++++ b/test/embeds.test.js
@@@ -43,12 +43,12 @@ const { test, run } = harness('embeds');
assert.match(r.headers.get('cache-control'), /^public/);
assert.strictEqual(r.headers.get('set-cookie'), null);
const card = JSON.parse(r.text);
-- assert.deepStrictEqual(card.creator, { username: 'lena', display_name: 'Lena', url: 'http://vip.test/lena' });
++ assert.deepStrictEqual(card.creator, { username: 'lena', display_name: 'Lena', url: 'http://vip.test/@lena' });
assert.deepStrictEqual(card.plans.map((p) => p.name), ['Club'], 'no drafts, no archived plans');
const club = card.plans[0];
assert.deepStrictEqual(club.perks.map((p) => [p.key, p.kind]), [['supporter', 'badge'], ['posts', 'gated_content']]);
-- assert.strictEqual(club.join_url, 'http://vip.test/lena#plan-club');
-- assert.strictEqual(club.terms_url, 'http://vip.test/lena/plans/club');
++ assert.strictEqual(club.join_url, 'http://vip.test/@lena#plan-club');
++ assert.strictEqual(club.terms_url, 'http://vip.test/@lena/plans/club');
assert.strictEqual(card.joining_open, true);
assert.deepStrictEqual(card.price, { price_cents: 499, period_days: 30, charged_by: 'OpenVibe.Billing' });
assert.deepStrictEqual(card.members, { count: 2, shown: true, stale: false });
@@@ -110,7 +110,7 @@ const { test, run } = harness('embeds');
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.ok(!/<script|\son\w+=/i.test(r.text), 'no script or handler');
assert.match(r.text, /<b>2<\/b> members/);
-- assert.match(r.text, /<a class="b" href="http:\/\/vip\.test\/lena" target="_blank" rel="noopener noreferrer">Join Lena<\/a>/);
++ assert.match(r.text, /<a class="b" href="http:\/\/vip\.test\/@lena" target="_blank" rel="noopener noreferrer">Join Lena<\/a>/);
assert.match(r.text, /Club · Supporter badge, Members-only posts/);
for (const m of members) assert.ok(!r.text.includes(m.subject) && !r.text.includes(m.username));
const signedIn = await get('/embed/lena/widget', { Cookie: cookie });
@@@ -119,7 +119,7 @@ const { test, run } = harness('embeds');
const darkStyle = dark.text.match(/<style>([\s\S]*?)<\/style>/)[1];
assert.notStrictEqual(darkStyle, style);
assert.ok(dark.headers.get('content-security-policy').includes(crypto.createHash('sha256').update(darkStyle).digest('base64')));
-- const page = await get('/lena');
++ const page = await get('/@lena');
assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'self'/, 'the plan page itself cannot be framed');
});
ddiff --git a/test/home-live.test.js b/test/home-live.test.js
iindex 490c7f3..3b7f8d8 100644
---- a/test/home-live.test.js
++++ b/test/home-live.test.js
@@@ -25,7 +25,7 @@ assert.match(html, /12 viewers/);
assert.match(html, /href="https:\/\/openvibe\.live\/@cyd" rel="noopener">Watch/);
assert.ok(!html.includes('No plan here'), 'a channel without a plan here is not on the home');
const grid = html.slice(html.indexOf('id="creators"'));
--assert.ok(grid.indexOf('href="/Cyd"') < grid.indexOf('href="/alma"'), 'live creators come first in the grid');
++assert.ok(grid.indexOf('href="/@Cyd"') < grid.indexOf('href="/@alma"'), 'live creators come first in the grid');
assert.match(html, /src="https:\/\/openvibe\.network\/avatar\/alma\?s=40" width="40" height="40" alt="" loading="lazy"/, 'avatars from Network, sized');
assert.match(html, /<dt>3 creators<\/dt><dd>offer a membership<\/dd>/);
assert.match(html, /<dt>1 creator<\/dt><dd>live on OpenVibe\.Live now<\/dd>/);
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 660935b..a8c6c1e 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -21,7 +21,7 @@ assert.ok(html.includes('document.getElementById("questions-fold")'), 'the fold
assert.match(html, /What is a membership\?/);
assert.ok(html.includes('id="questions"'), 'the questions keep their section');
// Every creator stays in the page.
--for (const c of creators) assert.ok(html.includes(`href="/${c.username}"`), `${c.username} is on the page`);
++for (const c of creators) assert.ok(html.includes(`href="/@${c.username}"`), `${c.username} is on the page`);
// The CSS guard: a fold is a 44 px target and the creators are one row on a phone.
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'vip.css'), 'utf8');
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
iindex 79ec71e..8961012 100644
---- a/test/indexnow.test.js
++++ b/test/indexnow.test.js
@@@ -53,8 +53,8 @@ const KEY = 'k'.repeat(32);
test('publishing pings the creator page, the terms page and the sitemap', async () => {
const res = await t.call('POST', `/api/v1/plans/${plan.id}/publish`, { user: creator });
assert.strictEqual(res.status, 200, res.text);
-- assert.ok(pings.includes('http://vip.test/alice'), JSON.stringify(pings));
-- assert.ok(pings.includes(`http://vip.test/alice/plans/${plan.slug}`), JSON.stringify(pings));
++ assert.ok(pings.includes('http://vip.test/@alice'), JSON.stringify(pings));
++ assert.ok(pings.includes(`http://vip.test/@alice/plans/${plan.slug}`), JSON.stringify(pings));
assert.ok(pings.includes('http://vip.test/sitemap.xml'), JSON.stringify(pings));
});
@@@ -64,15 +64,15 @@ const KEY = 'k'.repeat(32);
assert.deepStrictEqual(pings, [], 'an unchanged edit is not a change');
const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { description: 'Now with more.' } });
assert.strictEqual(res.status, 200, res.text);
-- assert.ok(pings.includes('http://vip.test/alice'), JSON.stringify(pings));
-- assert.ok(pings.includes(`http://vip.test/alice/plans/${plan.slug}`), JSON.stringify(pings));
++ assert.ok(pings.includes('http://vip.test/@alice'), JSON.stringify(pings));
++ assert.ok(pings.includes(`http://vip.test/@alice/plans/${plan.slug}`), JSON.stringify(pings));
});
test('archiving pings the creator page and the sitemap', async () => {
pings.length = 0;
const res = await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator });
assert.strictEqual(res.status, 200, res.text);
-- assert.ok(pings.includes('http://vip.test/alice'), JSON.stringify(pings));
++ assert.ok(pings.includes('http://vip.test/@alice'), JSON.stringify(pings));
assert.ok(pings.includes('http://vip.test/sitemap.xml'), JSON.stringify(pings));
});
ddiff --git a/test/llms-full.test.js b/test/llms-full.test.js
iindex 7c30b7e..1a4ad99 100644
---- a/test/llms-full.test.js
++++ b/test/llms-full.test.js
@@@ -26,8 +26,8 @@ const { test, run } = harness('llms-full');
assert.match(full.text, /## Creators/);
assert.match(full.text, /## Plans/);
assert.match(full.text, /URL: http:\/\/vip\.test\/\n/, 'the home page');
-- assert.match(full.text, /URL: http:\/\/vip\.test\/dana\n/, 'the public creator page');
-- assert.match(full.text, new RegExp(`URL: http:\\/\\/vip\\.test\\/dana\\/plans\\/${plan.slug}\\n`), 'the plan terms page');
++ assert.match(full.text, /URL: http:\/\/vip\.test\/@dana\n/, 'the public creator page');
++ assert.match(full.text, new RegExp(`URL: http:\\/\\/vip\\.test\\/@dana\\/plans\\/${plan.slug}\\n`), 'the plan terms page');
assert.match(full.text, /First description/, 'the full plan text, never clipped');
assert.match(full.text, /@dana/);
});
ddiff --git a/test/member-posts.test.js b/test/member-posts.test.js
iindex f843187..5ba9df6 100644
---- a/test/member-posts.test.js
++++ b/test/member-posts.test.js
@@@ -68,7 +68,7 @@ test('a non-member and a signed-out visitor see the title, the tier and a way to
await publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
-- const out = await t.page('/lena');
++ const out = await t.page('/@lena');
assert.strictEqual(out.status, 200);
assert.ok(out.text.includes('Studio notes'), 'the title is public');
assert.ok(out.text.includes('Tier 1'), 'the tier it needs is shown');
@@@ -104,7 +104,7 @@ test('a member of the tier reads the text, on the page and through the API', asy
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
-- const out = await t.page('/lena', { user: member });
++ const out = await t.page('/@lena', { user: member });
assert.ok(out.text.includes('Studio notes'));
assert.ok(out.text.includes('60% patience'), 'a member reads the text');
@@@ -129,7 +129,7 @@ test('a member of another tier does not read the text', async () => {
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
-- const out = await t.page('/lena', { user: member });
++ const out = await t.page('/@lena', { user: member });
assert.ok(out.text.includes('Backstage'), 'the title is public');
assert.ok(!out.text.includes('Only for the second row.'), 'the text is not');
@@@ -148,7 +148,7 @@ test('the creator reads their own post, on the page and on the dashboard', async
await publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
-- const page = await t.page('/lena', { user: creator });
++ const page = await t.page('/@lena', { user: creator });
assert.ok(page.text.includes('60% patience'), 'the creator reads their own text');
const dash = await t.page('/dashboard', { user: creator });
assert.ok(dash.text.includes('Studio notes'), 'the dashboard lists the post');
@@@ -174,7 +174,7 @@ test('staff hide a post: it is gone from the page and from the API, and the crea
assert.strictEqual(hidden.status, 200);
assert.strictEqual(hidden.json.post.status, 'hidden');
-- const page = await t.page('/lena', { user: member });
++ const page = await t.page('/@lena', { user: member });
assert.ok(!page.text.includes('Studio notes'), 'a hidden post is not listed');
assert.ok(!page.text.includes('60% patience'));
const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + id + '/body', { user: member });
@@@ -190,7 +190,7 @@ test('staff hide a post: it is gone from the page and from the API, and the crea
const again = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: staff });
assert.strictEqual(again.status, 200);
assert.strictEqual(again.json.post.status, 'published');
-- const shown = await t.page('/lena', { user: member });
++ const shown = await t.page('/@lena', { user: member });
assert.ok(shown.text.includes('60% patience'), 'the post is readable again');
} finally { await t.close(); }
});
@@@ -217,7 +217,7 @@ test('an image on a post goes to Media and is shown to the members of the tier',
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
-- const page = await t.page('/lena', { user: member });
++ const page = await t.page('/@lena', { user: member });
assert.ok(page.text.includes(`https://media.test/o/${object.id}`), 'the image is on the page');
assert.ok(page.text.includes('The sleeve.'));
} finally { await t.close(); await media.close(); }
@@@ -229,7 +229,7 @@ test('the Markdown a creator writes is rendered safely', async () => {
const creator = t.network.newUser('lena');
await publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Markup', body: '**bold** <script>alert(1)</script>', tiers: ['1'] });
-- const out = await t.page('/lena', { user: creator });
++ const out = await t.page('/@lena', { user: creator });
assert.ok(out.text.includes('<strong>bold</strong>'), 'the marks are rendered');
assert.ok(!out.text.includes('<script>alert(1)</script>'), 'no markup is carried through');
} finally { await t.close(); }
@@@ -247,12 +247,12 @@ test('the renderer escapes everything but the marks, and the page shows a locked
assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '), 'and never becomes a link');
// The page: a locked post shows its title, its tier and the way to join — and no text.
-- const locked = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: '/lena#plan-front-row' }]);
++ const locked = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: '/@lena#plan-front-row' }]);
assert.ok(locked.includes('Studio notes'));
assert.ok(locked.includes('Tier 1'));
assert.ok(locked.includes('Become a member'));
assert.ok(!locked.includes('post-body'), 'a locked post carries no text');
-- const open = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: true, body_html: '<p>The mix.</p>', join_url: '/lena' }]);
++ const open = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: true, body_html: '<p>The mix.</p>', join_url: '/@lena' }]);
assert.ok(open.includes('<p>The mix.</p>'), 'a member reads the text');
// A locked post whose tier cannot be joined: the line, no button.
const closed = pages.postsSection([{ title: 'Studio notes', tiers: [2], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: null }]);
@@@ -305,13 +305,13 @@ test('a locked post offers a way to join only when a tier that reads it can be j
await writePost(t, creator, { title: 'Joinable', body: 'For the first row.', tiers: ['1'] });
await writePost(t, creator, { title: 'Not joinable', body: 'For the second row.', tiers: ['2'] });
-- const out = await t.page('/lena', { user: stranger });
++ const out = await t.page('/@lena', { user: stranger });
// The tier-1 post: the button, and it points at the plan that opens it.
assert.strictEqual((out.text.match(/Become a member/g) || []).length, 1, 'only the joinable post offers a way in');
-- assert.ok(out.text.includes('/lena#plan-tier-1-plan'), 'the link points at the plan that reads it');
++ assert.ok(out.text.includes('/@lena#plan-tier-1-plan'), 'the link points at the plan that reads it');
// The tier-2 post: no button, and the line says why.
assert.ok(out.text.includes('This post is for the members of Tier 2, which is not open to join yet.'));
-- assert.ok(!out.text.includes('/lena#plan-tier-2-plan'), 'no link to a plan that cannot be joined');
++ assert.ok(!out.text.includes('/@lena#plan-tier-2-plan'), 'no link to a plan that cannot be joined');
assert.ok(!out.text.includes('For the first row.'), 'no text of a locked post, either way');
assert.ok(!out.text.includes('For the second row.'));
// The lock glyph before the line, drawn by the shared glyph set and hidden from a screen reader.
ddiff --git a/test/security.test.js b/test/security.test.js
iindex e8f5aeb..ce1ef42 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -46,7 +46,7 @@ const { test, run } = harness('security');
test('a crafted ?ok= / ?error= link shows nothing', async () => {
const spoof = 'Your membership was suspended — send 5000 Vibes to @scammer to restore it';
for (const kind of ['ok', 'error']) {
-- const p = await t.page(`/sec_creator?${kind}=${encodeURIComponent(spoof)}`);
++ const p = await t.page(`/@sec_creator?${kind}=${encodeURIComponent(spoof)}`);
assert.strictEqual(p.status, 200);
assert.ok(!p.text.includes('send 5000 Vibes'), `${kind} notice rendered from the query string`);
const forged = await t.page(`/me?${kind}=${encodeURIComponent(spoof)}&ns=AAAAAAAAAAAAAAAAAAAAAA`, { user: buyer });
@@@ -55,7 +55,7 @@ const { test, run } = harness('security');
});
test('VIP\'s own notices still render', async () => {
-- const r = await t.page('/sec_creator/join', { user: buyer, method: 'POST', form: { plan_id: plan.id, provider: 'stripe', _csrf: 'stale' } });
++ const r = await t.page('/@sec_creator/join', { user: buyer, method: 'POST', form: { plan_id: plan.id, provider: 'stripe', _csrf: 'stale' } });
assert.strictEqual(r.status, 303);
const shown = await t.page(r.location, { user: buyer });
assert.match(shown.text, /That form expired/);
ddiff --git a/test/tiers.test.js b/test/tiers.test.js
iindex 14e20eb..224d64c 100644
---- a/test/tiers.test.js
++++ b/test/tiers.test.js
@@@ -48,7 +48,7 @@ const { test, run } = harness('tiers');
});
test('the creator page and the card show each tier and its price', async () => {
-- const page = await t.page(`/${creator.username}`);
++ const page = await t.page(`/@${creator.username}`);
assert.match(page.text, /Supporter <span class="pill">Tier 1<\/span>/);
assert.match(page.text, /<b>\$3\.00<\/b> every/);
assert.match(page.text, /<b>\$15\.00<\/b> every/);
@@@ -85,7 +85,7 @@ const { test, run } = harness('tiers');
test('a tier with no price cannot be joined, and says so', async () => {
const three = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Patron', tier: 3, publish: true } });
assert.strictEqual(three.status, 201, three.text);
-- const page = await t.page(`/${creator.username}`);
++ const page = await t.page(`/@${creator.username}`);
assert.match(page.text, /This plan has no price yet, so it cannot be joined\./);
const co = await t.call('POST', '/api/v1/checkout', { user: t.network.newUser('early'), body: { plan_id: three.json.plan.id, provider: 'credit' } });
assert.strictEqual(co.status, 409);