openvibers/openvibe.vip Commits
Creator pages at /@name; the root /username 301s there for the 301 window (#206)
Changes
@@@ -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 termshistory · `/me` your memberships, the version you joined under, cancel, badge preference ·`/dashboard` plans, versions, perks and bindings, members, members-only resources, the members-onlyposts (write, hide) and, for staff, the moderation list, the badge and@@@ -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]));}@@@ -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@@@ -81,7 +81,7 @@ function createCards({ db, config, creators, plans, perks, billing, prices = nulconst 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`,};};@@@ -38,7 +38,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnowif (!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 */ }}@@@ -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)}@@@ -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;}@@@ -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' } });@@@ -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');});@@@ -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>/);@@@ -21,7 +21,7 @@ assert.ok(html.includes('document.getElementById("questions-fold")'), 'the foldassert.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');@@@ -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));});@@@ -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/);});@@@ -68,7 +68,7 @@ test('a non-member and a signed-out visitor see the title, the tier and a way toawait 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', asyconst 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', asyncawait 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 creaassert.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 creaconst 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 lockedassert.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 jawait 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.@@@ -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/);@@@ -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);
@@ -25,7 +25,7 @@ The nine authority tables (VIP is their only writer):
@@ -25,7 +25,7 @@ The nine authority tables (VIP is their only writer):
| Table | Holds |
| 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_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_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 |
| `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.
@@ -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)
## 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
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 ·
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
`/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
posts (write, hide) and, for staff, the moderation list, the badge and
@@ -44,7 +44,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
@@ -44,7 +44,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
const name = c.display_name || c.username;
const name = c.display_name || c.username;
return {
return {
html: layout.page({
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,
description: c.bio || `Membership plans from ${name} on OpenVibe.`, viewer: null,
body: pages.creatorPage({
body: pages.creatorPage({
creator: creators.present(c), plans: list, viewer: null, member: null, rates: chargeRates,
creator: creators.present(c), plans: list, viewer: null, member: null, rates: chargeRates,
@@ -58,7 +58,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
@@ -58,7 +58,7 @@ function createAgentPages({ domain, config, layout, liveNow }) {
const p = String(path || '/').replace(/\/+$/, '') || '/';
const p = String(path || '/').replace(/\/+$/, '') || '/';
if (p === '/') return home();
if (p === '/') return home();
if (p === '/updates') return updates();
if (p === '/updates') return updates();
- const m = /^\/([^/]+)$/.exec(p);
+ const m = /^\/@([^/]+)$/.exec(p);
if (!m) return null;
if (!m) return null;
return creator(decodeURIComponent(m[1]));
return creator(decodeURIComponent(m[1]));
}
}
@@ -44,6 +44,9 @@ const pages = require('./web/pages');
@@ -44,6 +44,9 @@ const pages = require('./web/pages');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
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 = {}) {
async function createApp(opts = {}) {
const config = opts.config || loadConfig();
const config = opts.config || loadConfig();
const log0 = opts.log || console;
const log0 = opts.log || console;
@@ -160,6 +163,20 @@ async function createApp(opts = {}) {
@@ -160,6 +163,20 @@ async function createApp(opts = {}) {
},
},
}));
}));
const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });
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
// 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
// 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
// state, join form and anti-forgery token are theirs — so only the manifest and twin requests reach
@@ -81,7 +81,7 @@ function createCards({ db, config, creators, plans, perks, billing, prices = nul
@@ -81,7 +81,7 @@ function createCards({ db, config, creators, plans, perks, billing, prices = nul
const urls = (c) => {
const urls = (c) => {
const u = encodeURIComponent(c.username);
const u = encodeURIComponent(c.username);
return {
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`,
widget: `${config.baseUrl}/embed/${u}/widget`, card: `${config.baseUrl}/embed/${u}/card.json`,
};
};
};
};
@@ -38,7 +38,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -38,7 +38,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
if (!indexnow || !indexnow.enabled || !baseUrl || !plan) return;
if (!indexnow || !indexnow.enabled || !baseUrl || !plan) return;
const creator = await creators.byId(plan.creator_id);
const creator = await creators.byId(plan.creator_id);
if (!creator || !creator.username) return; // network plans have no public page
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`]);
indexnow.pingSoon([page, `${page}/plans/${encodeURIComponent(plan.slug)}`, `${baseUrl}/sitemap.xml`]);
} catch { /* a ping never fails a publish */ }
} catch { /* a ping never fails a publish */ }
}
}
@@ -95,10 +95,10 @@ function home({ creators, live = [], networkUrl = 'https://openvibe.network' })
@@ -95,10 +95,10 @@ function home({ creators, live = [], networkUrl = 'https://openvibe.network' })
const liveSection = liveCreators.length ? `
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>
<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
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>';
: '<p class="muted">No creator has published a plan here yet.</p>';
const body = showcase.hero({
const body = showcase.hero({
@@ -240,11 +240,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
@@ -240,11 +240,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
: member && member.status === 'active' ? ''
: member && member.status === 'active' ? ''
: !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
: !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
: viewer
: 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>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>
<label class="check"><input type="checkbox" name="auto_renew" value="1" checked> Renew automatically</label>
<button type="submit">Join ${esc(v.name)}</button></form>`
<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)}">
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>
<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>` : ''}
${v.description ? `<p>${esc(v.description)}</p>` : ''}
@@ -252,7 +252,7 @@ ${benefitList(v.benefits)}
@@ -252,7 +252,7 @@ ${benefitList(v.benefits)}
${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
${p.purchasable ? priceLine(rates, p.price_cents) : ''}
${p.purchasable ? priceLine(rates, p.price_cents) : ''}
${join}
${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>`;
</article>`;
}).join('') : '<p class="muted">No plan is published yet.</p>';
}).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>
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)}`;
@@ -265,13 +265,13 @@ ${postsSection(posts)}`;
/** Public terms history of one plan: every published version, unchanged. */
/** Public terms history of one plan: every published version, unchanged. */
function planHistory({ creator, plan, versions }) {
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>
<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>
<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>
${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>
<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('')}
${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. */
/** The member's own status page. */
@@ -281,7 +281,7 @@ function memberPage({ viewer, rows, token, query }) {
@@ -281,7 +281,7 @@ function memberPage({ viewer, rows, token, query }) {
<p><a class="button" href="/auth/login?next=%2Fme">Sign in</a></p></section>`;
<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 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>'
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 === '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)}` : ''}`
: 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
@@ -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>`;
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
}
}
const title = isNetwork ? 'Network plans' : 'Your membership plans';
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('');
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>
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)}
${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" action="${base}/profile">${csrf(token)}
@@ -126,7 +126,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -126,7 +126,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
disallow: ['/me', '/dashboard', '/auth/', '/api/', '/embed/'],
disallow: ['/me', '/dashboard', '/auth/', '/api/', '/embed/'],
})));
})));
router.get('/sitemap.xml', async (req, res) => {
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));
res.type('application/xml').send(seo.sitemapXml(urls));
});
});
// /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.
// /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 }) {
@@ -174,14 +174,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const v = p.current_version;
const v = p.current_version;
about.push(`Plan ${v.name} (version ${v.version})${v.description ? `: ${v.description}` : ''}`);
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) {
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.`];
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)) {
for (const v of (p.versions || []).filter((x) => x.published_at)) {
const terms = termsText(v);
const terms = termsText(v);
body.push(`Version ${v.version}${v.published_at ? ` (published ${String(v.published_at).slice(0, 10)})` : ''}${terms ? `\n${terms}` : ''}`);
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({
return seo.llmsFull({
@@ -420,12 +420,12 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -420,12 +420,12 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
}, { backTo: '/dashboard' });
}, { backTo: '/dashboard' });
// ── Creator pages ────────────────────────────────────────
// ── Creator pages ────────────────────────────────────────
- router.get('/:username', async (req, res, next) => {
+ router.get('/@:username', async (req, res, next) => {
try {
try {
if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
const c = await creators.byUsername(req.params.username);
const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
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 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 isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
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 }) {
@@ -449,29 +449,51 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
id: row.id, title: row.title, tiers, published_at: row.published_at,
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,
image: row.image_id ? { id: row.image_id, url: row.image_url } : null,
unlocked: !!access.unlocked, body_html: access.unlocked ? row.body_html : 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, {
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.`,
description: c.bio || `Membership plans from ${name} on OpenVibe.`,
robots: list.length ? 'index,follow' : 'noindex,follow',
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 }),
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); }
} 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);
const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
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);
const plan = (await plans.list({ creatorId: c.id, includeArchived: true })).find((p) => p.slug === req.params.slug);
if (!plan) return notFound(req, res);
if (!plan) return notFound(req, res);
const versions = (await Promise.all((await plans.versions(plan.id)).filter((v) => v.published_at).map(plans.presentVersion)));
const versions = (await Promise.all((await plans.versions(plan.id)).filter((v) => v.published_at).map(plans.presentVersion)));
return send(res, 200, {
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 }),
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);
const c = await creators.byUsername(req.params.username);
if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
const plan = await plans.byId(String(req.body.plan_id || ''));
const plan = await plans.byId(String(req.body.plan_id || ''));
@@ -483,7 +505,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -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.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);
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 }) });
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;
return router;
}
}
@@ -160,35 +160,36 @@ const { test, run } = harness('api');
@@ -160,35 +160,36 @@ const { test, run } = harness('api');
});
});
test('public creator page works without JavaScript and without an account', async () => {
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.strictEqual(p.status, 200);
assert.match(p.text, /Front row/);
assert.match(p.text, /Front row/);
assert.match(p.text, /Monthly stream/);
assert.match(p.text, /Monthly stream/);
assert.match(p.text, /\$4\.99<\/b> every 30 days/);
assert.match(p.text, /\$4\.99<\/b> every 30 days/);
assert.match(p.text, /Sign in to join/);
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, /\/shared\/navbar\.js\?v=[0-9a-f]{12}/);
assert.match(p.text, /<noscript>/);
assert.match(p.text, /<noscript>/);
assert.match(p.text, /<meta name="ov-boost" content="vip@[^"]+">/);
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, /\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer>/);
assert.match(p.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
assert.match(p.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
assert.doesNotMatch(p.text, /\bfree\b|\$0|no ads/i);
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.strictEqual(hist.status, 200);
assert.match(hist.text, /v1/);
assert.match(hist.text, /v1/);
assert.match(hist.text, /v2/);
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);
assert.strictEqual((await t.page('/nobody-here')).status, 404);
const sm = await t.page('/sitemap.xml');
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 () => {
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>'));
const head = p.text.slice(0, p.text.indexOf('</head>'));
assert.strictEqual(head.match(/<title>/g).length, 1, 'exactly one <title>');
assert.strictEqual(head.match(/<title>/g).length, 1, 'exactly one <title>');
assert.match(head, /<title>Lena memberships · OpenVibe\.VIP<\/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 name="robots" content="index,follow">/);
assert.match(head, /<meta property="og:title" content="Lena memberships · OpenVibe\.VIP">/);
assert.match(head, /<meta property="og:title" content="Lena memberships · OpenVibe\.VIP">/);
assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
@@ -215,7 +216,7 @@ const { test, run } = harness('api');
@@ -215,7 +216,7 @@ const { test, run } = harness('api');
assert.match(home.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);
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.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.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(!/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');
assert.ok(!p.text.includes('showcase.css'), 'a creator page does not link the kit');
});
});
@@ -230,7 +231,8 @@ const { test, run } = harness('api');
@@ -230,7 +231,8 @@ const { test, run } = harness('api');
assert.strictEqual(sm.status, 200);
assert.strictEqual(sm.status, 200);
assert.match(sm.headers.get('content-type'), /^application\/xml/);
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\/<\/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');
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 });
const llms = await t.call('GET', '/llms.txt', { token: null });
assert.strictEqual(llms.status, 200);
assert.strictEqual(llms.status, 200);
@@ -246,14 +248,14 @@ const { test, run } = harness('api');
@@ -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.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, /^# 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\/\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, /Front row/, 'the full plan text');
assert.match(full.text, /Monthly stream/, 'a benefit of the current version');
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 () => {
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) {
for (const [p, user] of pages) {
const r = await t.page(p, user ? { user } : undefined);
const r = await t.page(p, user ? { user } : undefined);
assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
@@ -267,19 +269,19 @@ const { test, run } = harness('api');
@@ -267,19 +269,19 @@ const { test, run } = harness('api');
test('joining from the page: form with anti-forgery token → Billing credit → member page', async () => {
test('joining from the page: form with anti-forgery token → Billing credit → member page', async () => {
const x = t.network.newUser('rita');
const x = t.network.newUser('rita');
t.billing.state.credit.set(x.subject, 600);
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);
const token = t.csrf(p.text);
assert.ok(token);
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.strictEqual(forged.status, 303);
assert.match(forged.location, /error=/);
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.strictEqual(ok.status, 303);
assert.match(ok.location, /^\/me\?ok=/);
assert.match(ok.location, /^\/me\?ok=/);
const me = await t.page('/me', { user: x });
const me = await t.page('/me', { user: x });
assert.match(me.text, /Front row<\/b> v2/);
assert.match(me.text, /Front row<\/b> v2/);
assert.match(me.text, /Cancel at period end/);
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=/);
assert.match(handoff.location, /error=/);
});
});
@@ -319,7 +321,7 @@ const { test, run } = harness('api');
@@ -319,7 +321,7 @@ const { test, run } = harness('api');
try {
try {
const c = t2.network.newUser('uma', { role: 'streamer' });
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 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.match(page.text, /Joining through OpenVibe\.Billing is not open yet/);
assert.doesNotMatch(page.text, /<select name="provider">/);
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' } });
const r = await t2.call('POST', '/api/v1/checkout', { user: t2.network.newUser('wes'), body: { plan_id: p.id, provider: 'powerchat' } });
@@ -43,12 +43,12 @@ const { test, run } = harness('embeds');
@@ -43,12 +43,12 @@ const { test, run } = harness('embeds');
assert.match(r.headers.get('cache-control'), /^public/);
assert.match(r.headers.get('cache-control'), /^public/);
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.strictEqual(r.headers.get('set-cookie'), null);
const card = JSON.parse(r.text);
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');
assert.deepStrictEqual(card.plans.map((p) => p.name), ['Club'], 'no drafts, no archived plans');
const club = card.plans[0];
const club = card.plans[0];
assert.deepStrictEqual(club.perks.map((p) => [p.key, p.kind]), [['supporter', 'badge'], ['posts', 'gated_content']]);
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.strictEqual(card.joining_open, true);
assert.deepStrictEqual(card.price, { price_cents: 499, period_days: 30, charged_by: 'OpenVibe.Billing' });
assert.deepStrictEqual(card.price, { price_cents: 499, period_days: 30, charged_by: 'OpenVibe.Billing' });
assert.deepStrictEqual(card.members, { count: 2, shown: true, stale: false });
assert.deepStrictEqual(card.members, { count: 2, shown: true, stale: false });
@@ -110,7 +110,7 @@ const { test, run } = harness('embeds');
@@ -110,7 +110,7 @@ const { test, run } = harness('embeds');
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.ok(!/<script|\son\w+=/i.test(r.text), 'no script or handler');
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, /<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/);
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));
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 });
const signedIn = await get('/embed/lena/widget', { Cookie: cookie });
@@ -119,7 +119,7 @@ const { test, run } = harness('embeds');
@@ -119,7 +119,7 @@ const { test, run } = harness('embeds');
const darkStyle = dark.text.match(/<style>([\s\S]*?)<\/style>/)[1];
const darkStyle = dark.text.match(/<style>([\s\S]*?)<\/style>/)[1];
assert.notStrictEqual(darkStyle, style);
assert.notStrictEqual(darkStyle, style);
assert.ok(dark.headers.get('content-security-policy').includes(crypto.createHash('sha256').update(darkStyle).digest('base64')));
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');
assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'self'/, 'the plan page itself cannot be framed');
});
});
@@ -25,7 +25,7 @@ assert.match(html, /12 viewers/);
@@ -25,7 +25,7 @@ assert.match(html, /12 viewers/);
assert.match(html, /href="https:\/\/openvibe\.live\/@cyd" rel="noopener">Watch/);
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');
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"'));
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, /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>3 creators<\/dt><dd>offer a membership<\/dd>/);
assert.match(html, /<dt>1 creator<\/dt><dd>live on OpenVibe\.Live now<\/dd>/);
assert.match(html, /<dt>1 creator<\/dt><dd>live on OpenVibe\.Live now<\/dd>/);
@@ -21,7 +21,7 @@ assert.ok(html.includes('document.getElementById("questions-fold")'), 'the fold
@@ -21,7 +21,7 @@ assert.ok(html.includes('document.getElementById("questions-fold")'), 'the fold
assert.match(html, /What is a membership\?/);
assert.match(html, /What is a membership\?/);
assert.ok(html.includes('id="questions"'), 'the questions keep their section');
assert.ok(html.includes('id="questions"'), 'the questions keep their section');
// Every creator stays in the page.
// 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.
// 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');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'vip.css'), 'utf8');
@@ -53,8 +53,8 @@ const KEY = 'k'.repeat(32);
@@ -53,8 +53,8 @@ const KEY = 'k'.repeat(32);
test('publishing pings the creator page, the terms page and the sitemap', async () => {
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 });
const res = await t.call('POST', `/api/v1/plans/${plan.id}/publish`, { user: creator });
assert.strictEqual(res.status, 200, res.text);
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));
assert.ok(pings.includes('http://vip.test/sitemap.xml'), JSON.stringify(pings));
});
});
@@ -64,15 +64,15 @@ const KEY = 'k'.repeat(32);
@@ -64,15 +64,15 @@ const KEY = 'k'.repeat(32);
assert.deepStrictEqual(pings, [], 'an unchanged edit is not a change');
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.' } });
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.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 () => {
test('archiving pings the creator page and the sitemap', async () => {
pings.length = 0;
pings.length = 0;
const res = await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator });
const res = await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator });
assert.strictEqual(res.status, 200, res.text);
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));
assert.ok(pings.includes('http://vip.test/sitemap.xml'), JSON.stringify(pings));
});
});
@@ -26,8 +26,8 @@ const { test, run } = harness('llms-full');
@@ -26,8 +26,8 @@ const { test, run } = harness('llms-full');
assert.match(full.text, /## Creators/);
assert.match(full.text, /## Creators/);
assert.match(full.text, /## Plans/);
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\/\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, /First description/, 'the full plan text, never clipped');
assert.match(full.text, /@dana/);
assert.match(full.text, /@dana/);
});
});
@@ -68,7 +68,7 @@ test('a non-member and a signed-out visitor see the title, the tier and a way to
@@ -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 publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['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.strictEqual(out.status, 200);
assert.ok(out.text.includes('Studio notes'), 'the title is public');
assert.ok(out.text.includes('Studio notes'), 'the title is public');
assert.ok(out.text.includes('Tier 1'), 'the tier it needs is shown');
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
@@ -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 });
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
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('Studio notes'));
assert.ok(out.text.includes('60% patience'), 'a member reads the text');
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 () => {
@@ -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 });
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
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('Backstage'), 'the title is public');
assert.ok(!out.text.includes('Only for the second row.'), 'the text is not');
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
@@ -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 publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['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');
assert.ok(page.text.includes('60% patience'), 'the creator reads their own text');
const dash = await t.page('/dashboard', { user: creator });
const dash = await t.page('/dashboard', { user: creator });
assert.ok(dash.text.includes('Studio notes'), 'the dashboard lists the post');
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
@@ -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.status, 200);
assert.strictEqual(hidden.json.post.status, 'hidden');
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('Studio notes'), 'a hidden post is not listed');
assert.ok(!page.text.includes('60% patience'));
assert.ok(!page.text.includes('60% patience'));
const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + id + '/body', { user: member });
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
@@ -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 });
const again = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: staff });
assert.strictEqual(again.status, 200);
assert.strictEqual(again.status, 200);
assert.strictEqual(again.json.post.status, 'published');
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');
assert.ok(shown.text.includes('60% patience'), 'the post is readable again');
} finally { await t.close(); }
} 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',
@@ -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 });
const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
await t.deliverAll(paid.events);
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(`https://media.test/o/${object.id}`), 'the image is on the page');
assert.ok(page.text.includes('The sleeve.'));
assert.ok(page.text.includes('The sleeve.'));
} finally { await t.close(); await media.close(); }
} finally { await t.close(); await media.close(); }
@@ -229,7 +229,7 @@ test('the Markdown a creator writes is rendered safely', async () => {
@@ -229,7 +229,7 @@ test('the Markdown a creator writes is rendered safely', async () => {
const creator = t.network.newUser('lena');
const creator = t.network.newUser('lena');
await publishTiers(t, creator, [1]);
await publishTiers(t, creator, [1]);
await writePost(t, creator, { title: 'Markup', body: '**bold** <script>alert(1)</script>', tiers: ['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('<strong>bold</strong>'), 'the marks are rendered');
assert.ok(!out.text.includes('<script>alert(1)</script>'), 'no markup is carried through');
assert.ok(!out.text.includes('<script>alert(1)</script>'), 'no markup is carried through');
} finally { await t.close(); }
} finally { await t.close(); }
@@ -247,12 +247,12 @@ test('the renderer escapes everything but the marks, and the page shows a locked
@@ -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');
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.
// 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('Studio notes'));
assert.ok(locked.includes('Tier 1'));
assert.ok(locked.includes('Tier 1'));
assert.ok(locked.includes('Become a member'));
assert.ok(locked.includes('Become a member'));
assert.ok(!locked.includes('post-body'), 'a locked post carries no text');
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');
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.
// 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 }]);
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
@@ -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: 'Joinable', body: 'For the first row.', tiers: ['1'] });
await writePost(t, creator, { title: 'Not joinable', body: 'For the second row.', tiers: ['2'] });
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.
// 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.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.
// 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('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 first row.'), 'no text of a locked post, either way');
assert.ok(!out.text.includes('For the second row.'));
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.
// The lock glyph before the line, drawn by the shared glyph set and hidden from a screen reader.
@@ -46,7 +46,7 @@ const { test, run } = harness('security');
@@ -46,7 +46,7 @@ const { test, run } = harness('security');
test('a crafted ?ok= / ?error= link shows nothing', async () => {
test('a crafted ?ok= / ?error= link shows nothing', async () => {
const spoof = 'Your membership was suspended — send 5000 Vibes to @scammer to restore it';
const spoof = 'Your membership was suspended — send 5000 Vibes to @scammer to restore it';
for (const kind of ['ok', 'error']) {
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.strictEqual(p.status, 200);
assert.ok(!p.text.includes('send 5000 Vibes'), `${kind} notice rendered from the query string`);
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 });
const forged = await t.page(`/me?${kind}=${encodeURIComponent(spoof)}&ns=AAAAAAAAAAAAAAAAAAAAAA`, { user: buyer });
@@ -55,7 +55,7 @@ const { test, run } = harness('security');
@@ -55,7 +55,7 @@ const { test, run } = harness('security');
});
});
test('VIP\'s own notices still render', async () => {
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);
assert.strictEqual(r.status, 303);
const shown = await t.page(r.location, { user: buyer });
const shown = await t.page(r.location, { user: buyer });
assert.match(shown.text, /That form expired/);
assert.match(shown.text, /That form expired/);
@@ -48,7 +48,7 @@ const { test, run } = harness('tiers');
@@ -48,7 +48,7 @@ const { test, run } = harness('tiers');
});
});
test('the creator page and the card show each tier and its price', async () => {
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, /Supporter <span class="pill">Tier 1<\/span>/);
assert.match(page.text, /<b>\$3\.00<\/b> every/);
assert.match(page.text, /<b>\$3\.00<\/b> every/);
assert.match(page.text, /<b>\$15\.00<\/b> every/);
assert.match(page.text, /<b>\$15\.00<\/b> every/);
@@ -85,7 +85,7 @@ const { test, run } = harness('tiers');
@@ -85,7 +85,7 @@ const { test, run } = harness('tiers');
test('a tier with no price cannot be joined, and says so', async () => {
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 } });
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);
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\./);
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' } });
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);
assert.strictEqual(co.status, 409);