openvibers/openvibe.inventory Commits
Move Inventory pages under openvibe.my/inventory (#211)
Changes
@@@ -235,3 +235,44 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [- openvibe-shared: v4.150.0- openvibe-publishing: v1.4.0<!-- versions:end -->++++++### Account hub deployment (review before merging)++++`PUBLIC_URL=https://openvibe.my/inventory` overrides `BASE_URL` for browser URLs and++SSO's default callback. Without it, the existing unprefixed deployment is unchanged.++Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventory/workshop`,++`/inventory/u/<subject>` and `/inventory/updates`. APIs and feeds on the original host stay available.++`INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only,++preserving query strings. Leave it unset until the new deployment is verified.++++Sign-in needs Opus review before either PR merges. SDK 0.94 hardcodes cookie names and paths;++`server/auth/scoped-session.js` adapts its Express boundary. Under the prefix, session and++flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.++The hub's cookies at `/` are untouched. Browser page entry makes a top-level++`prompt=none` round trip, so silent sign-in also works with third-party cookies blocked.++Guests return with `sso=none`; Inventory's own sign-out hint prevents automatic re-entry.++The adapter ignores the hub's root session and flow cookies. OAuth still uses SDK PKCE,++state verification, refresh and error recovery. The original host keeps its existing cookies.++++After review, deploy in this order:++++1. Merge/deploy the companion Network PR with++ `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Network#<N>:network' < /dev/null`.++ Its boot seed merges the new callback into existing clients, keeping old callbacks.++ Install Network's `deploy/nginx/parked-domains.conf` with `sudo ov-vhost-install`++ using the host's installed filename. The `/inventory` proxy preserves the prefix.++2. Back up `/etc/openvibe/inventory.env` with sudo before editing it (never print its contents).++ Set `PUBLIC_URL=https://openvibe.my/inventory`; if `OV_OAUTH_REDIRECT_URI` is set,++ set it to `https://openvibe.my/inventory/auth/callback`. Leave legacy redirects disabled.++ Deploy with `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Inventory#<N>:inventory' < /dev/null`.++3. Verify hub and Inventory sign-in together, silent entry from Network, refresh, sign-out,++ Workshop forms, assets and API callers. Use `networkToken()` from `tools/site-signin.js`++ with the probe password loaded inline on the host, then follow the `/inventory` silent++ flow in a browser with a path-aware cookie jar. That helper's `signIn()` assumes root++ `/auth` and cannot test this prefix or hub cookie coexistence. Never print credentials++ or cookies. The live check is pending++ until the reviewed callback and prefix are deployed. Then enable++ `INVENTORY_REDIRECT_LEGACY=true` and redeploy Inventory to activate the 301 table.++++Local coverage: `npm test -- base-path auth-ops security-session workshop discovery search-index`.@@@ -94,6 +94,10 @@ server {# Events posts to http://127.0.0.1:5030/internal/events directly; never proxied from outside (ADR-033).location ~* ^/internal(/|$) { return 404; }++ # Browser page redirects are app-owned, enabled last with INVENTORY_REDIRECT_LEGACY=true:++ # / -> /inventory; /items -> /inventory/all; /items/:id -> /inventory/:id;++ # /me -> /inventory; /workshop/*, /u/:subject and /updates keep their suffix.++ # /api, /.well-known, /auth, health, metrics, MCP and feeds never redirect.# ── Pages, docs, robots.txt, sitemap, static assets ─────location / {proxy_pass http://127.0.0.1:5030;@@@ -11,6 +11,10 @@ function createAgentPages(ctx, publicViews) {const twin = (page, path) => (page && page.agent === true ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);async function renderPublicPage(path, { query } = {}) {++ const prefix = require('./urls').urls(ctx.config).prefix;++ if (prefix && path.startsWith(prefix)) path = path.slice(prefix.length) || '/';++ if (path === '/all') path = '/items';++ path = path.replace(/^\/(itd_[0-9A-HJKMNP-TV-Z]{26})$/, '/items/$1');if (path === '/') return twin(await publicViews.home({ signed: false }), path);if (path === '/items') return twin(await publicViews.items(query && query.get('kind') ? String(query.get('kind')) : null, { signed: false }), path);if (path === '/updates') return twin(publicViews.updates(), path);@@@ -22,6 +22,7 @@ const contracts = require('openvibe-contracts');const cache = require('openvibe-shared/cache-policy');const configLib = require('./config');++const { urls, legacyPage } = require('./urls');const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');@@@ -95,6 +96,29 @@ async function createApp(opts = {}) {ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });const app = express();++ const url = urls(config);++ app.use((req, res, next) => {++ const scoped = url.prefix && (req.path === `${url.prefix}.md` || req.path === url.prefix || req.path.startsWith(`${url.prefix}/`));++ req.inventoryScoped = !!scoped;++ if (!scoped && url.prefix && config.redirectLegacyPages && ['GET', 'HEAD'].includes(req.method) && legacyPage(req.path)) {++ return res.redirect(301, url.absolute(req.url));++ }++ if (scoped) {++ req.url = req.url.slice(url.prefix.length) || '/';++ if (req.url.startsWith('.md')) req.url = '/index' + req.url;++ if (req.url.startsWith('?')) req.url = '/' + req.url;++ // The existing page handlers stay shared with the original, unprefixed deployment.++ req.url = req.url.replace(/^\/all(?=[?\/]|$)/, '/items')++ .replace(/^\/(itd_[0-9A-HJKMNP-TV-Z]{26})(?=[.?]|$)/, '/items/$1');++ if (/^\/(?:internal|metrics)(?:\/|$)/i.test(req.path)) return res.sendStatus(404);++ }++ const sendBody = res.send.bind(res);++ res.send = (body) => sendBody(typeof body === 'string' && String(res.get('Content-Type')).includes('text/html') ? url.html(body) : body);++ const redirect = res.redirect.bind(res);++ res.redirect = (status, target) => typeof status === 'string'++ ? redirect(url.path(status)) : redirect(status, url.path(target));++ next();++ });// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {@@@ -139,7 +163,7 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ imgSrc: ["'self'", 'data:', 'https:'],// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],frameSrc: ["'self'", 'https://openvibe.network'],@@@ -155,6 +179,7 @@ async function createApp(opts = {}) {referrerPolicy: { policy: 'strict-origin-when-cross-origin' },}));app.use(cookieParser());++ app.use(require('./auth/scoped-session').scopedSession(config));// ── OpenVibe.Events → Inventory (loopback only: nginx answers 404 for /internal/) ──// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,@@@ -171,12 +196,36 @@ async function createApp(opts = {}) {// ── Who is asking (verified offline; refreshed when expired) ──app.use(sso.middleware());++ // A top-level silent authorization sees Network's SameSite cookies even when++ // third-party iframe cookies are blocked. Guests return with sso=none once.++ app.use((req, res, next) => {++ if (req.inventoryScoped && req.method === 'GET' && legacyPage(req.path)++ && String(req.get('accept') || '').includes('text/html')++ && !/bot|crawl|spider|slurp/i.test(String(req.get('user-agent') || ''))++ && req.viewer.kind !== 'user' && req.cookies.ov_sso_hint !== 'guest' && req.query.sso !== 'none') {++ res.set('Cache-Control', 'private, no-store');++ return res.redirect(302, url.path('/auth/login') + '?silent=1&next=' + encodeURIComponent(req.originalUrl));++ }++ next();++ });// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me', standardHeaders: true, legacyHeaders: false }));app.use('/auth', sso.routes());-- { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'inventory', service: 'inventory', host: 'inventory.openvibe.network', name: 'OpenVibe.Inventory', profile: 'ugc' })); }++ {++ const legal = require('openvibe-shared/legal');++ const site = { id: 'inventory', service: 'inventory', host: new URL(config.baseUrl).host, name: 'OpenVibe.Inventory', profile: 'ugc' };++ app.get(legal.PATHS, (req, res, next) => {++ if (!url.prefix) return legal.handler(site)(req, res, next);++ // Shared's standalone legal shell reads root ov_token. Use our scoped++ // navbar and canonical while keeping its legal article unchanged.++ const kind = req.path.replace(/^\/+|\/+$/g, '');++ const doc = legal.build(kind, site);++ return send(res, 200, { config, viewer: req.viewer, path: req.originalUrl,++ title: doc.title, description: doc.description, body: require('./render/html').raw(legal.article(kind, site)) });++ });++ }// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());@@@ -219,17 +268,21 @@ async function createApp(opts = {}) {// manifest and twin requests reach this mount; the pages advertise the twin themselves.const agentLayer = express();require('openvibe-shared/agent-layer').mount(agentLayer, {-- descriptor: require('./agent-descriptor'),++ descriptor: { ...require('./agent-descriptor'), home: url.absolute('/'), site: new URL(config.baseUrl).hostname,++ api: 'https://inventory.openvibe.network/api/v1', openapi: 'https://inventory.openvibe.network/openapi.json',++ llms: url.absolute('/llms.txt'), llmsFull: url.absolute('/llms-full.txt'), sitemap: url.absolute('/sitemap.xml') },render: require('./agent-pages').createAgentPages(ctx, { ...pageRoutes.publicViews, workshop: workshopRoutes.publicViews.workshop }).renderPublicPage,});app.use((req, res, next) => {const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');if (req.path !== '/.well-known/agent.json' && !twin) return next();-- return agentLayer(req, res, next);++ const routedUrl = req.url;++ if (req.inventoryScoped) req.url = req.originalUrl;++ return agentLayer(req, res, (err) => { req.url = routedUrl; next(err); });});app.use(workshopRoutes);app.use(pageRoutes);-- app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));++ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="${url.path('/')}">the home page</a> or <a href="${url.path('/updates')}">the update log</a>.</p>` }));// eslint-disable-next-line no-unused-varsapp.use((err, req, res, _next) => {nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++const { urls } = require('../urls');++++// SDK 0.94 fixes its cookie names and /auth paths. Adapt its Express boundary so++// Inventory never reads or writes the account hub's root session or flow cookies.++function scopedSession(config) {++ const url = urls(config);++ return (req, res, next) => {++ if (!req.inventoryScoped) return next();++ const incoming = req.cookies || {};++ req.cookies = Object.fromEntries(Object.entries(incoming)++ .filter(([name]) => name.startsWith('inventory_ov_'))++ .map(([name, value]) => [name.slice('inventory_'.length), value]));++ const cookie = res.cookie.bind(res);++ const clearCookie = res.clearCookie.bind(res);++ const nameFor = (name) => name.startsWith('ov_') ? `inventory_${name}` : name;++ const options = (opts) => ({ ...opts, domain: undefined, path: url.prefix });++ res.cookie = (name, value, opts) => cookie(nameFor(name), value, options(opts));++ // Express clearCookie calls res.cookie; pass the original name to avoid renaming twice.++ res.clearCookie = (name, opts) => clearCookie(name, options(opts));++ next();++ };++}++module.exports = { scopedSession };@@@ -17,6 +17,7 @@* refresh writes ov_token / ov_refresh and clears them.*/const express = require('express');++const { urls } = require('../urls');const cache = require('openvibe-shared/cache-policy');const { createSsoClient } = require('openvibe-sdk/sso');@@@ -44,6 +45,7 @@ function pkcePair() {function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {const secure = config.cookies.secure;++ const url = urls(config);const sso = createSsoClient({site: 'inventory',siteName: 'OpenVibe.Inventory',@@@ -159,7 +161,12 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat// The shared navbar's silent sign-in sends the whole page address: keep its path before the SDK sees it.r.use((req, _res, next) => {if (req.method === 'GET' && req.path === '/login') {-- const target = sanitizeNext(req.query.next);++ let value = req.query.next;++ // Shared passes an absolute current URL. Accept only this exact origin.++ if (typeof value === 'string' && /^https?:/.test(value)) {++ try { const u = new URL(value); value = u.origin === url.origin ? u.pathname + u.search : '/'; } catch { value = '/'; }++ }++ const target = url.path(sanitizeNext(value));if (target === '/') delete req.query.next; else req.query.next = target;}next();@@@ -179,8 +186,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datres.set('Cache-Control', cache.htmlHeaders({ private: true }));const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`-- + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`-- + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ + `<form method="post" action="${url.path(`/auth/logout${q}`)}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="${url.path('/')}">Cancel</a></form>`);});r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));r.all('/logout', async (req, res) => {@@@ -14,7 +14,7 @@ function load(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';const isProduction = nodeEnv === 'production';const port = int(env.PORT, 5030);-- const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));++ const baseUrl = trim(env.PUBLIC_URL || env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');return {@@@ -24,6 +24,7 @@ function load(env = process.env) {nodeEnv,isProduction,baseUrl,++ redirectLegacyPages: env.INVENTORY_REDIRECT_LEGACY === 'true',// IndexNow (openvibe-shared/indexnow): with a key, each page Search gains, changes or loses is sent to search// engines in one batch (openvibe-publishing/search-feed), and the key is served at /<key>.txt. Unset: off.indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },@@@ -17,6 +17,7 @@ const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Inventory';const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';++const { urls } = require('../urls');const DISALLOW = ['/auth/', '/api/'];// The two routes that need a subject point at the API reference, not at a URL that answers 400.const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';@@@ -42,7 +43,7 @@ function homeJsonLd(config) {return [seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),-- seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: urls(config).absolute('/'), description: DESCRIPTION, siteUrl: site }),];}@@@ -81,14 +82,14 @@ function createDiscoveryRoutes(ctx) {const { config, inv } = ctx;const r = asyncRouter();const site = String(config.baseUrl).replace(/\/+$/, '');-- const abs = (p) => `${site}${p}`;++ const abs = urls(config).absolute;const TEXT = cache.htmlHeaders({ maxAge: 3600 });r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'+ '# Agent manifest: https://inventory.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW.map(urls(config).path) }));});r.get('/llms.txt', async (_req, res) => {@@@ -135,12 +136,12 @@ function createDiscoveryRoutes(ctx) {r.get('/llms-full.txt', async (_req, res) => {const { kinds, defs, byKind } = await catalog(inv);-- const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));++ const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: abs(e.path), title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));// Every published item, one line each (name, rarity, kind and URL), grouped by kind.const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {const items = byKind.get(k.id);-- pages.push({ url: kindPath(k.id), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });-- return { title: k.name, pages: items.map((d) => ({ title: d.name, url: `/items/${d.id}`, text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };++ pages.push({ url: abs(kindPath(k.id)), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });++ return { title: k.name, pages: items.map((d) => ({ title: d.name, url: abs(`/items/${d.id}`), text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };});res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({site: SITE_NAME,@@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const cache = require('openvibe-shared/cache-policy');const express = require('express');++const { urls } = require('../urls');const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { sameOrigin } = require('./principal');@@@ -49,7 +50,7 @@ function art(d, { size = 'md' } = {}) {}const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;--function card(d, { href = `/items/${d.id}`, extra = '', owned = null, worn = false } = {}) {++function renderCard(d, { url, href = url(`/items/${d.id}`), extra = '', owned = null, worn = false } = {}) {return html`<li class="inv-card r-${d.rarity}${worn ? ' is-worn' : ''}"><a class="inv-card-link" href="${href}">${art(d)}<span class="inv-card-body"><span class="inv-card-name">${d.name}</span>${rarityBadge(d.rarity)}</span></a>${d.description ? html`<p class="inv-card-desc">${d.description}</p>` : ''}${owned ? html`<p class="inv-card-meta">${owned}</p>` : ''}${extra}@@@ -92,10 +93,12 @@ function itemDescription(d, k) {function createPageRoutes(ctx) {const { config, inv } = ctx;const r = asyncRouter();++ const url = urls(config).path;++ const card = (d, options = {}) => renderCard(d, { ...options, url });const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);-- const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/items">Browse every item</a>.</p>` }, 404);++ const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="${url(`/items`)}">Browse every item</a>.</p>` }, 404);async function catalog() {const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);@@@ -117,10 +120,10 @@ function createPageRoutes(ctx) {accent: 'on every OpenVibe site.',lede: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and more. Earn them across the network, wear them in chat, on stream overlays and on your profile.',actions: me-- ? [{ label: 'Your inventory', href: '/me', primary: true }, { label: 'Browse every item', href: '/items' }]-- : [{ label: 'Browse every item', href: '/items', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],++ ? [{ label: 'Your inventory', href: url('/me'), primary: true }, { label: 'Browse every item', href: url('/items') }]++ : [{ label: 'Browse every item', href: url('/items'), primary: true }, { label: 'Sign in to see yours', href: url('/auth/login?next=%2Fme') }],note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',-- aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },++ aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="${url(`/items/${d.id}`)}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },});return {agent: true, index: true, cache: me ? null : PUBLIC_CACHE,@@@ -133,7 +136,7 @@ ${raw(showcase.features({// nor counted, so "in 4 kinds" sits over four cards.lede: `${plural(defs.length, 'item', 'items')} in ${plural(kinds.filter((k) => (byKind.get(k.id) || []).length).length, 'kind', 'kinds')} today. Each kind knows where it shows, so a new kind is all it takes for a new site or game to join.`,// Kinds that have items, the biggest first; an empty kind waits until an issuer defines something in it.-- items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: `/items?kind=${encodeURIComponent(k.id)}` })),++ items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: url(`/items?kind=${encodeURIComponent(k.id)}`) })),}))}${raw(showcase.steps({title: 'How items reach you',@@@ -142,14 +145,17 @@ ${raw(showcase.steps({{ title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },{ title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },{ title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },-- { title: 'Or make one', text: 'In the Workshop you make a badge, staff review it, and you give it to the people you choose.', href: '/workshop' },++ { title: 'Or make one', text: 'In the Workshop you make a badge, staff review it, and you give it to the people you choose.', href: url('/workshop') },],}))}--${raw(showcase.network({ service: 'inventory' }))}--${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,++${raw(urls(config).html(showcase.network({ service: 'inventory' })))}++${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: url('/me') }] : [{ label: 'Sign in with OpenVibe', href: url('/auth/login?next=%2Fme') }, { label: 'The API', href: url('/api/v1/kinds') }] }))}`,};}-- r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));++ r.get('/', async (req, res, next) => {++ if (urls(config).prefix && signedIn(req)) return ownPage(req, res);++ return page(req, res, await homePage({ signed: !!signedIn(req) }));++ });// ── The catalog ──────────────────────────────────────────async function itemsPage(only = null, { signed = false } = {}) {@@@ -163,7 +169,7 @@ ${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned somethingdescription: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1><p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>--<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All <span class="muted">${defs.length}</span></a>${kinds.filter((k) => (byKind.get(k.id) || []).length || only === k.id).map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>++<nav class="inv-tabs" aria-label="Kinds"><a href="${url(`/items`)}"${only ? '' : raw(' aria-current="page"')}>All <span class="muted">${defs.length}</span></a>${kinds.filter((k) => (byKind.get(k.id) || []).length || only === k.id).map((k) => html`<a href="${url(`/items?kind=${encodeURIComponent(k.id)}`)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}"><h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">${plural((byKind.get(k.id) || []).length, 'item', 'items')} · shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>${(byKind.get(k.id) || []).length ? tiles(byKind.get(k.id), (d) => tile(d), { first: only ? Infinity : 12, more: `More ${k.name.toLowerCase()} items` }) : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}@@@ -187,7 +193,7 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repltitle: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.description: itemDescription(d, k),-- body: html`<p class="crumbs"><a href="/items">Every item</a> › <a href="/items?kind=${encodeURIComponent(k.id)}">${k.name}</a></p>++ body: html`<p class="crumbs"><a href="${url(`/items`)}">Every item</a> › <a href="${url(`/items?kind=${encodeURIComponent(k.id)}`)}">${k.name}</a></p><div class="inv-detail r-${d.rarity}">${art(d, { size: 'xl' })}<div>@@@ -209,7 +215,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p><h2>Who gives it</h2><p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>--<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,++<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="${url(`/api/v1/definitions/${d.id}`)}">JSON</a></p>`,};}r.get('/items/:id', async (req, res) => {@@@ -228,7 +234,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htmreturn { kinds, byKind, worn, owned, more: !!list.next_cursor };}-- r.get('/me', async (req, res) => {++ async function ownPage(req, res) {const me = signedIn(req);if (!me) return res.redirect(303, '/auth/login?next=%2Fme');const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });@@@ -245,20 +251,21 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-const on = byKind.get(k.id).find(({ i }) => worn.has(i.id));return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;})}</ul></section>` : ''}--<p class="inv-links"><a href="/u/${me}">Your public page</a> · <a href="/items">Every item</a></p>++<p class="inv-links"><a href="${url(`/u/${me}`)}">Your public page</a> · <a href="${url(`/items`)}">Every item</a></p>${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}"><h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {worn: worn.has(i.id),note: i.serial ? `#${i.serial}` : '',-- form: k.slots.length ? (label) => html`<form method="post" action="/me/equip" class="inv-tile-form">++ form: k.slots.length ? (label) => html`<form method="post" action="${url(`/me/equip`)}" class="inv-tile-form"><input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}"><button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}</section>`)}--${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="/api/v1/me/items">/api/v1/me/items</a>.</p>` : ''}`,++${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="${url(`/api/v1/me/items`)}">/api/v1/me/items</a>.</p>` : ''}`,});-- });++ }++ r.get('/me', ownPage);r.post('/me/equip', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {const me = signedIn(req);@@@ -296,7 +303,7 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,const updatesPage = () => ({agent: true, index: true, cache: PUBLIC_CACHE,title: `What shipped on ${SITE_NAME}`,-- body: raw(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),++ body: raw(urls(config).html(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME })) + `<script src="${url(ovServe.url('shipped.js'))}" defer></script>`),});r.get('/updates', (req, res) => page(req, res, updatesPage()));@@@ -43,7 +43,7 @@ function sameOrigin(req, baseUrl) {const site = String(req.get('sec-fetch-site') || '');if (site) return site === 'same-origin';const origin = req.get('origin');-- if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++ if (origin) return origin === new URL(baseUrl).origin || origin === `${req.protocol}://${req.get('host')}`;return false;}@@@ -9,6 +9,7 @@* - the product's own script only where a page offers an in-browser convenience*/const crypto = require('crypto');++const { urls } = require('../urls');const ovServe = require('openvibe-shared/serve');const fs = require('fs');const path = require('path');@@@ -50,32 +51,35 @@ function setRelease(id) { if (id) RELEASE = String(id); }* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]*/function renderPage(o) {++ const { path: url, absolute, html: sharedHtml } = urls(o.config);const viewer = o.viewer || { kind: 'anonymous' };const signedIn = viewer.kind === 'user';const pathNow = o.path || '/';const loginNext = encodeURIComponent(pathNow);-- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const canonical = absolute(pathNow.split('?')[0]);const nav = {service: 'inventory',++ ...(urls(o.config).prefix ? { brand: { href: url('/'), tld: 'inventory', name: 'Inventory' } } : {}),apiBase: NETWORK_URL,-- links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Items', href: '/items', icon: 'fa-tag' }, { label: 'Yours', href: '/me', icon: 'fa-shirt' }, { label: 'Workshop', href: '/workshop', icon: 'fa-screwdriver-wrench' }], // phones: the bottom tab bar (Shared 4.20)++ links: NAV.map((l) => ({ label: l.label, href: url(l.href), ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === url(l.href) || pathNow.startsWith(`${url(l.href)}/`) })),++ appBar: [{ label: 'Home', href: url('/'), icon: 'fa-house' }, { label: 'Items', href: url('/items'), icon: 'fa-tag' }, { label: 'Yours', href: url('/me'), icon: 'fa-shirt' }, { label: 'Workshop', href: url('/workshop'), icon: 'fa-screwdriver-wrench' }], // phones: the bottom tab bar (Shared 4.20)history: { type: 'page', title: o.title || SITE_NAME },-- silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,-- sessionUrl: '/auth/me',-- loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)-- logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too++ silentLogin: urls(o.config).prefix ? null : absolute('/auth/login') + '?silent=1&next={url}',++ sessionUrl: url('/auth/me'),++ fedcmLogin: url('/auth/fedcm'),++ loginUrl: url('/auth/login?next={path}'), // filled from the current page (boost moves between pages)++ logoutUrl: url('/auth/logout?next={path}'), // Sign out in the shared navbar ends this site's session toonotificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)};-- const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };++ const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: url('/updates'), ...(urls(o.config).prefix ? { sitemap: url('/sitemap.xml'), legalBase: absolute('/') } : {}) };const account = signedIn-- ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`-- : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="${url(`/auth/logout?next=${loginNext}`)}">Sign out</a>`++ : html`<a href="${url(`/auth/login?next=${loginNext}`)}">Sign in with OpenVibe</a>`;const crumbs = (o.crumbs || []).length-- ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${url(c.href)}">${c.label}</a>` : c.label}`)}</nav>`: '';-- const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);-- return shell.page({++ const scripts = (o.scripts || []).map((rel) => html`<script src="${url(asset(rel))}" defer></script>`);++ return sharedHtml(shell.page({name: SITE_NAME,lang: 'en',title: o.title || `${SITE_NAME}: ${TAGLINE}`,@@@ -87,34 +91,43 @@ function renderPage(o) {// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself// is server/agent-pages.js.agentLayer: o.agent === true,-- llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,++ llms: absolute('/llms.txt'),jsonLd: (o.jsonLd || []).filter(Boolean),-- home: '/',-- navLinks: NAV,++ home: url('/'),++ navLinks: NAV.map((l) => ({ ...l, href: url(l.href) })),navbar: nav,footer,head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">--${appIcon.headTags({ site: 'inventory' })}--<link rel="stylesheet" href="${asset('css/app.css')}">--${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++${sharedHtml(appIcon.headTags({ site: 'inventory' }))}++<link rel="stylesheet" href="${url(asset('css/app.css'))}">++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(url(ovServe.url(name)))}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="inventory@${esc(RELEASE)}">--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++<script src="${url(ovServe.url('boost.js'))}" data-main="#main" defer></script>`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript><main id="main" class="page">${render(crumbs)}${render(o.body)}--${o.path === '/' ? frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` }) : ''}++${o.path === '/' ? sharedHtml(frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` })) : ''}</main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};document.addEventListener('DOMContentLoaded', function () {try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++${urls(o.config).prefix ? ` // Shared 4.150 hardcodes the footer brand's home and rerenders it after loading++ // Frame data. Keep that link inside this section on each render.++ var footer = document.getElementById('ov-footer');++ var home = ${JSON.stringify(url('/'))};++ if (footer && home !== '/') {++ var fixHome = function () { footer.querySelectorAll('a.ovf-brand').forEach(function (a) { if (a.getAttribute('href') !== home) a.setAttribute('href', home); }); };++ fixHome();++ new MutationObserver(fixHome).observe(footer, { childList: true, subtree: true });++ }` : ''}});</script>`,-- });++ }));}function render(v) { return require('./html').render(v); }@@@ -126,8 +139,7 @@ function send(res, status, o) {if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));res.set('Vary', 'Cookie');if (o.agent) {-- const site = String(o.config.baseUrl).replace(/\/+$/, '');-- res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(urls(o.config).absolute(String(o.path || '/').split('?')[0]), urls(o.config).absolute('/llms.txt')).header);}res.status(status).type('html').send(renderPage(o));}@@@ -48,11 +48,15 @@ function describe(row) {}function createSearchIndex({ config, s, outbox, log = console, indexnow = null }) {++ const url = require('./urls').urls(config);++ // Publishing's origin-based sitemap ping needs the same section prefix as pages.++ const scopedIndexnow = indexnow && { enabled: indexnow.enabled,++ pingSoon: (paths) => indexnow.pingSoon(paths.map((p) => p === `${url.origin}/sitemap.xml` ? url.absolute('/sitemap.xml') : p)) };const feed = createSearchFeed({-- owner: 'inventory', db: s.db, outbox, baseUrl: config.baseUrl, now: s.now, log, indexnow,++ owner: 'inventory', db: s.db, outbox, baseUrl: url.origin, now: s.now, log, indexnow: scopedIndexnow,types: {definition: {-- page: (row) => `/items/${row.id}`,++ page: (row) => url.path(`/items/${row.id}`),document: describe,rows: (after, limit) => s.db.many(`${SELECT} WHERE d.id > $1 ORDER BY d.id LIMIT $2`, [after, limit]),exists: async (ids) => (await s.db.many('SELECT id FROM inventory_definitions WHERE id = ANY($1)', [ids])).map((r) => r.id),nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++++// One translation for browser links, redirects, metadata and discovery. API paths retain their shape.++function urls(config) {++ const base = new URL(config.baseUrl);++ const prefix = base.pathname.replace(/\/+$/, '');++ function path(value = '/') {++ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return value;++ if (prefix && (value === `${prefix}.md` || value === prefix || value.startsWith(`${prefix}/`) || value.startsWith(`${prefix}?`))) return value;++ if (!prefix) return value;++ const match = /^([^?#]*)(.*)$/.exec(value);++ let route = match[1];++ if (route === '/items') route = '/all';++ else if (route.startsWith('/items/')) route = route.slice(6);++ else if (route === '/me') route = '/';++ return prefix + (route === '/' ? '' : route) + match[2];++ }++ const absolute = (value = '/') => new URL(path(value), base.origin).href;++ // Shared components return HTML, not link objects. Translate only their URL attributes.++ const html = (value) => String(value).replace(/\b(href|src|action)=(['"])(\/(?!\/)[^'"]*)\2/g,++ (_all, attr, quote, value) => `${attr}=${quote}${path(value)}${quote}`);++ return { path, absolute, html, prefix, origin: base.origin };++}++++function legacyPage(path) {++ return path === '/' || /^\/(?:items(?:\/[^/]+)?|me|workshop(?:\/.*)?|u\/[^/]+|updates)\/?$/.test(path);++}++module.exports = { urls, legacyPage };@@@ -12,6 +12,7 @@* its image appears before that. Nothing is sold, bought or traded (ADR-054 §5).*/const express = require('express');++const { urls } = require('../urls');const Busboy = require('busboy');const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');@@@ -52,6 +53,7 @@ const img = (d, size = 64) => html`<img class="ws-img" src="https://openvibe.medfunction createWorkshopRoutes(ctx) {const { config, inv, workshopMedia, people } = ctx;const r = asyncRouter();++ const url = urls(config).path;const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);const isStaff = (req) => !!(signedIn(req) && STAFF_ROLES.has(req.viewer.role));@@@ -68,7 +70,7 @@ function createWorkshopRoutes(ctx) {description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',body: html`<h1>The Workshop</h1><p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>--<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop/mine">Your badges</a></p>++<p><a class="inv-btn primary" href="${url(`/workshop/new`)}">Make a badge</a> <a class="inv-btn" href="${url(`/workshop/mine`)}">Your badges</a></p><h2>How it works</h2><ol class="ws-steps"><li><strong>Make it.</strong> A square PNG or WebP, ${MIN_SIDE}–${MAX_SIDE} pixels, at most ${MAX_BYTES / 1024} KB, that you made or may use, a name, and how many may ever exist (up to ${WORKSHOP.maxCap.toLocaleString('en-US')}).</li>@@@ -76,7 +78,7 @@ function createWorkshopRoutes(ctx) {<li><strong>Give it.</strong> Type someone's name and it lands in their inventory, up to ${WORKSHOP.giftsPerDay} a day. They wear it before their name in chat and on their profile.</li></ol><h2>Community badges <span class="muted small">${published.length}</span></h2>--${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,++${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="${url(`/items/${d.id}`)}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,};}r.get('/workshop', async (req, res) => page(req, res, await workshopPage()));@@@ -89,13 +91,13 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li crobots: 'noindex',body: html`<h1>Make a badge</h1>${notice(req)}--<form class="ws-form" method="post" action="/workshop/new" enctype="multipart/form-data">++<form class="ws-form" method="post" action="${url(`/workshop/new`)}" enctype="multipart/form-data"><label>Image <input type="file" name="image" accept="image/png,image/webp" required></label><p class="muted small">A square PNG or WebP, ${MIN_SIDE} to ${MAX_SIDE} pixels on a side, at most ${MAX_BYTES / 1024} KB. It shows small, before a name: keep it simple and readable.</p><label>Name <input type="text" name="name" maxlength="80" required placeholder="OG Viewer"></label><label>What it means (optional) <textarea name="description" maxlength="500" rows="2" placeholder="For everyone who was in chat for the first stream."></textarea></label><label>How many may ever exist <input type="number" name="supply_cap" min="1" max="${WORKSHOP.maxCap}" value="100" required></label>-- <label class="ws-check"><input type="checkbox" name="rights" value="yes" required> <span>I made this image, or I have the right to use it, and it follows the <a href="/terms">rules</a>.</span></label>++ <label class="ws-check"><input type="checkbox" name="rights" value="yes" required> <span>I made this image, or I have the right to use it, and it follows the <a href="${url(`/terms`)}">rules</a>.</span></label><button type="submit" class="inv-btn primary">Send for review</button></form><p class="muted small">Staff review every badge before anyone else sees it. At most ${WORKSHOP.inReview} in review at once and ${WORKSHOP.perDay} a day.</p>`,@@@ -140,19 +142,19 @@ ${notice(req)}robots: 'noindex',body: html`<h1>Your badges</h1>${notice(req)}--<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop">The Workshop</a></p>++<p><a class="inv-btn primary" href="${url(`/workshop/new`)}">Make a badge</a> <a class="inv-btn" href="${url(`/workshop`)}">The Workshop</a></p>${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-item"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><div class="ws-item-body"><h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}-- ${d.status === 'draft' ? html`<form method="post" action="/workshop/${d.id}/resubmit" class="ws-give">++ ${d.status === 'draft' ? html`<form method="post" action="${url(`/workshop/${d.id}/resubmit`)}" class="ws-give"><label>Name <input type="text" name="name" required maxlength="64" value="${d.name}"></label><button type="submit" class="inv-btn primary">Send for review again</button></form>` : ''}${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}-- ${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="/items/${d.id}">its page</a></p>-- ${d.supply.issued < d.supply.cap ? html`<form method="post" action="/workshop/${d.id}/give" class="ws-give">++ ${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="${url(`/items/${d.id}`)}">its page</a></p>++ ${d.supply.issued < d.supply.cap ? html`<form method="post" action="${url(`/workshop/${d.id}/give`)}" class="ws-give"><label>Give it to <input type="text" name="to" required maxlength="25" placeholder="@username" pattern="@?[A-Za-z0-9_]{3,24}"></label><button type="submit" class="inv-btn primary">Give</button></form>` : html`<p class="muted">All ${d.supply.cap} have been given.</p>`}` : ''}@@@ -211,7 +213,7 @@ ${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-<h2>${d.name}</h2><p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · cap ${d.supply.cap} · sent ${String(d.created_at).slice(0, 16).replace('T', ' ')} UTC</p>${d.description ? html`<p>${d.description}</p>` : ''}-- <form method="post" action="/workshop/${d.id}/review" class="ws-review">++ <form method="post" action="${url(`/workshop/${d.id}/review`)}" class="ws-review"><label>Rarity <select name="rarity">${RARITY.map((x) => html`<option value="${x}"${x === 'common' ? ' selected' : ''}>${RARITY_LABEL[x]}</option>`)}</select></label><button type="submit" name="decision" value="publish" class="inv-btn primary">Publish</button><label class="ws-reason">Reason, if rejecting <input type="text" name="reason" maxlength="300" placeholder="The image is a brand's logo."></label>@@@ -226,7 +228,7 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it<div class="ws-item-body"><h2>${d.name}</h2><p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · ${d.supply.issued} of ${d.supply.cap} given</p>-- <form method="post" action="/workshop/${d.id}/takedown" class="ws-review">++ <form method="post" action="${url(`/workshop/${d.id}/takedown`)}" class="ws-review"><label class="ws-reason">Reason <input type="text" name="reason" maxlength="300" required placeholder="A rights holder asked us to remove it."></label><button type="submit" name="revoke" value="no" class="inv-btn">Retire</button><button type="submit" name="revoke" value="yes" class="inv-btn">Retire and revoke every copy</button>nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { urls } = require('../server/urls');++const { DEF_RE } = require('../server/inventory/store');++const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');++(async () => {++ const pinged = [];++ const t = await boot({ indexnow: { enabled: true, keyFile: (_req, _res, next) => next(), pingSoon: (paths) => pinged.push(...paths) }, env: { PUBLIC_URL: 'https://openvibe.my/inventory', INVENTORY_REDIRECT_LEGACY: 'true' } });++ const user = t.network.addUser('inventory-owner');++ const defs = await t.ctx.inv.listDefinitions({ limit: 2 });++ const id = defs[0].id;++ await check('item IDs cannot collide with reserved routes', async () => {++ assert.ok(DEF_RE.test(id));++ for (const word of ['all', 'workshop', 'u', 'updates', 'auth', 'api']) assert.ok(!DEF_RE.test(word));++ });++ await check('all page links, assets and form actions stay in Inventory', async () => {++ for (const [path, as] of [++ ['/inventory'], ['/inventory/all'], ['/inventory/all?kind=live.hat'], [`/inventory/${id}`],++ ['/inventory/workshop'], ['/inventory/updates'], ['/inventory/terms'], ['/inventory/privacy'], ['/inventory', user],++ ['/inventory/workshop/new', user], ['/inventory/workshop/mine', user],++ ['/inventory/workshop/review', { ...user, role: 'admin' }], [`/inventory/u/${user.subject}`],++ ['/inventory/missing'],++ ]) {++ const r = await t.get(path, { as });++ assert.strictEqual(r.status, path === '/inventory/missing' ? 404 : 200, path);++ const links = [...r.text.matchAll(/\b(?:href|src|action)=["'](\/(?!\/)[^"']*)["']/g)].map((m) => m[1]);++ assert.ok(links.length, path);++ assert.deepStrictEqual(links.filter((s) => !/^\/inventory(?:[/?#]|$)/.test(s)), [], path);++ assert.ok(!r.text.includes('/inventory/inventory'), 'no double prefix');++ assert.ok(r.text.includes(`rel=\"canonical\" href=\"https://openvibe.my${path.split('?')[0]}\"`), path + ': canonical');++ assert.match(r.headers.get('content-security-policy'), /img-src 'self' data: https:;/);++ if (path === '/inventory' && as) assert.match(r.text, /<h1>Your inventory<\/h1>/);++ }++ const asset = await t.get('/inventory/css/app.css');++ assert.strictEqual(asset.status, 200);++ const sitemap = await t.get('/inventory/sitemap.xml');++ assert.ok(sitemap.text.includes('https://openvibe.my/inventory/all'));++ assert.ok(sitemap.text.includes(`https://openvibe.my/inventory/${id}`));++ assert.ok(!sitemap.text.includes('/items'));++ const markdown = await t.get('/inventory/all.md');++ assert.strictEqual(markdown.status, 200);++ assert.ok(markdown.text.includes('canonical: \"https://openvibe.my/inventory/all\"'));++ assert.strictEqual((await t.get('/inventory.md')).status, 200);++ for (const path of ['/inventory/internal/events', '/inventory/metrics']) assert.strictEqual((await t.get(path)).status, 404);++ });++ await check('search documents and IndexNow use the prefixed URLs', async () => {++ await t.ctx.searchIndex.sweep();++ assert.ok(pinged.includes('https://openvibe.my/inventory/sitemap.xml'));++ assert.ok(pinged.includes(`https://openvibe.my/inventory/${id}`));++ assert.ok(pinged.every((p) => p.startsWith('https://openvibe.my/inventory/')));++ });++ await check('legacy page 301 table preserves queries; machine endpoints stay', async () => {++ for (const [from, to] of [['/', ''], ['/items', '/all'], [`/items/${id}`, `/${id}`], ['/me', ''],++ ['/workshop', '/workshop'], ['/workshop/new', '/workshop/new'], [`/u/${user.subject}`, `/u/${user.subject}`], ['/updates', '/updates']]) {++ const r = await t.get(`${from}?keep=1`);++ assert.strictEqual(r.status, 301, from);++ assert.strictEqual(r.headers.get('location'), `https://openvibe.my/inventory${to}?keep=1`);++ }++ for (const path of ['/api/v1/kinds', '/.well-known/agent.json', '/auth/me', '/health', '/metrics', '/mcp', '/feed.xml', '/feed.json']) {++ assert.notStrictEqual((await t.get(path)).status, 301, path);++ }++ });++ await check('browser entry silently checks Network without third-party cookies', async () => {++ const r = await t.get('/inventory', { headers: { accept: 'text/html' } });++ assert.strictEqual(r.status, 302);++ assert.strictEqual(r.headers.get('location'), '/inventory/auth/login?silent=1&next=%2Finventory');++ assert.strictEqual((await t.get('/inventory?sso=none', { headers: { accept: 'text/html' } })).status, 200);++ assert.strictEqual((await t.get('/inventory', { cookie: 'inventory_ov_sso_hint=guest', headers: { accept: 'text/html' } })).status, 200);++ });++ await check('silent PKCE callback uses isolated host-only Inventory cookies', async () => {++ const hub = `ov_token=${t.network.userToken(user)}; ov_refresh=hub-refresh; ov_oauth_state=hub-state`;++ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user, null, 'hub session is not an Inventory session');++ const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: hub });++ const loc = new URL(login.headers.get('location'));++ assert.strictEqual(loc.searchParams.get('prompt'), 'none');++ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.my/inventory/auth/callback');++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const callback = await t.get(`/inventory/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: `${hub}; ${cookieHeader(login)}` });++ assert.strictEqual(callback.status, 302, callback.text);++ assert.strictEqual(callback.headers.get('location'), '/inventory');++ for (const r of [login, callback]) for (const c of r.headers.getSetCookie()) {++ assert.match(c, /; Path=\/inventory(?:;|$)/);++ assert.ok(!/; Domain=/i.test(c));++ assert.ok(!/^ov_(?:token|refresh|oauth_)/.test(c), 'never write hub cookie names');++ }++ const session = cookieHeader(callback);++ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: `${hub}; ${session}` })).json().user.username, user.username);++ assert.match((await t.get('/inventory', { cookie: session })).text, /<h1>Your inventory<\/h1>/);++ const logout = await t.get('/inventory/auth/logout', { cookie: session, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(logout.headers.get('location'), '/inventory');++ for (const c of logout.headers.getSetCookie()) assert.match(c, /; Path=\/inventory(?:;|$)/);++ const guest = await t.get(`/inventory/auth/callback?error=login_required`, { cookie: cookieHeader(login) });++ assert.match(guest.headers.get('location'), /^\/inventory\?sso=none$/);++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -43,7 +43,7 @@ async function boot(opts = {}) {const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;-- const signIn = (user) => `inventory_at=${network.userToken(user)}`;++ const signIn = (user) => `${new URL(config.baseUrl).pathname.replace(/\/+$/, '') ? 'inventory_ov_token' : 'inventory_at'}=${network.userToken(user)}`;async function get(p, o = {}) {const headers = { ...(o.headers || {}) };
@@ -235,3 +235,44 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -235,3 +235,44 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-shared: v4.150.0
- openvibe-shared: v4.150.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
+
+
+### Account hub deployment (review before merging)
+
+`PUBLIC_URL=https://openvibe.my/inventory` overrides `BASE_URL` for browser URLs and
+SSO's default callback. Without it, the existing unprefixed deployment is unchanged.
+Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventory/workshop`,
+`/inventory/u/<subject>` and `/inventory/updates`. APIs and feeds on the original host stay available.
+`INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only,
+preserving query strings. Leave it unset until the new deployment is verified.
+
+Sign-in needs Opus review before either PR merges. SDK 0.94 hardcodes cookie names and paths;
+`server/auth/scoped-session.js` adapts its Express boundary. Under the prefix, session and
+flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.
+The hub's cookies at `/` are untouched. Browser page entry makes a top-level
+`prompt=none` round trip, so silent sign-in also works with third-party cookies blocked.
+Guests return with `sso=none`; Inventory's own sign-out hint prevents automatic re-entry.
+The adapter ignores the hub's root session and flow cookies. OAuth still uses SDK PKCE,
+state verification, refresh and error recovery. The original host keeps its existing cookies.
+
+After review, deploy in this order:
+
+1. Merge/deploy the companion Network PR with
+ `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Network#<N>:network' < /dev/null`.
+ Its boot seed merges the new callback into existing clients, keeping old callbacks.
+ Install Network's `deploy/nginx/parked-domains.conf` with `sudo ov-vhost-install`
+ using the host's installed filename. The `/inventory` proxy preserves the prefix.
+2. Back up `/etc/openvibe/inventory.env` with sudo before editing it (never print its contents).
+ Set `PUBLIC_URL=https://openvibe.my/inventory`; if `OV_OAUTH_REDIRECT_URI` is set,
+ set it to `https://openvibe.my/inventory/auth/callback`. Leave legacy redirects disabled.
+ Deploy with `/home/workstation/openvibe/work/tools/merge-deploy.sh 'OpenVibe.Inventory#<N>:inventory' < /dev/null`.
+3. Verify hub and Inventory sign-in together, silent entry from Network, refresh, sign-out,
+ Workshop forms, assets and API callers. Use `networkToken()` from `tools/site-signin.js`
+ with the probe password loaded inline on the host, then follow the `/inventory` silent
+ flow in a browser with a path-aware cookie jar. That helper's `signIn()` assumes root
+ `/auth` and cannot test this prefix or hub cookie coexistence. Never print credentials
+ or cookies. The live check is pending
+ until the reviewed callback and prefix are deployed. Then enable
+ `INVENTORY_REDIRECT_LEGACY=true` and redeploy Inventory to activate the 301 table.
+
+Local coverage: `npm test -- base-path auth-ops security-session workshop discovery search-index`.
@@ -94,6 +94,10 @@ server {
@@ -94,6 +94,10 @@ server {
# Events posts to http://127.0.0.1:5030/internal/events directly; never proxied from outside (ADR-033).
# Events posts to http://127.0.0.1:5030/internal/events directly; never proxied from outside (ADR-033).
location ~* ^/internal(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
+ # Browser page redirects are app-owned, enabled last with INVENTORY_REDIRECT_LEGACY=true:
+ # / -> /inventory; /items -> /inventory/all; /items/:id -> /inventory/:id;
+ # /me -> /inventory; /workshop/*, /u/:subject and /updates keep their suffix.
+ # /api, /.well-known, /auth, health, metrics, MCP and feeds never redirect.
# ── Pages, docs, robots.txt, sitemap, static assets ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
proxy_pass http://127.0.0.1:5030;
proxy_pass http://127.0.0.1:5030;
@@ -11,6 +11,10 @@ function createAgentPages(ctx, publicViews) {
@@ -11,6 +11,10 @@ function createAgentPages(ctx, publicViews) {
const twin = (page, path) => (page && page.agent === true ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);
const twin = (page, path) => (page && page.agent === true ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);
async function renderPublicPage(path, { query } = {}) {
async function renderPublicPage(path, { query } = {}) {
+ const prefix = require('./urls').urls(ctx.config).prefix;
+ if (prefix && path.startsWith(prefix)) path = path.slice(prefix.length) || '/';
+ if (path === '/all') path = '/items';
+ path = path.replace(/^\/(itd_[0-9A-HJKMNP-TV-Z]{26})$/, '/items/$1');
if (path === '/') return twin(await publicViews.home({ signed: false }), path);
if (path === '/') return twin(await publicViews.home({ signed: false }), path);
if (path === '/items') return twin(await publicViews.items(query && query.get('kind') ? String(query.get('kind')) : null, { signed: false }), path);
if (path === '/items') return twin(await publicViews.items(query && query.get('kind') ? String(query.get('kind')) : null, { signed: false }), path);
if (path === '/updates') return twin(publicViews.updates(), path);
if (path === '/updates') return twin(publicViews.updates(), path);
@@ -22,6 +22,7 @@ const contracts = require('openvibe-contracts');
@@ -22,6 +22,7 @@ const contracts = require('openvibe-contracts');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
+const { urls, legacyPage } = require('./urls');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
@@ -95,6 +96,29 @@ async function createApp(opts = {}) {
@@ -95,6 +96,29 @@ async function createApp(opts = {}) {
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
const app = express();
const app = express();
+ const url = urls(config);
+ app.use((req, res, next) => {
+ const scoped = url.prefix && (req.path === `${url.prefix}.md` || req.path === url.prefix || req.path.startsWith(`${url.prefix}/`));
+ req.inventoryScoped = !!scoped;
+ if (!scoped && url.prefix && config.redirectLegacyPages && ['GET', 'HEAD'].includes(req.method) && legacyPage(req.path)) {
+ return res.redirect(301, url.absolute(req.url));
+ }
+ if (scoped) {
+ req.url = req.url.slice(url.prefix.length) || '/';
+ if (req.url.startsWith('.md')) req.url = '/index' + req.url;
+ if (req.url.startsWith('?')) req.url = '/' + req.url;
+ // The existing page handlers stay shared with the original, unprefixed deployment.
+ req.url = req.url.replace(/^\/all(?=[?\/]|$)/, '/items')
+ .replace(/^\/(itd_[0-9A-HJKMNP-TV-Z]{26})(?=[.?]|$)/, '/items/$1');
+ if (/^\/(?:internal|metrics)(?:\/|$)/i.test(req.path)) return res.sendStatus(404);
+ }
+ const sendBody = res.send.bind(res);
+ res.send = (body) => sendBody(typeof body === 'string' && String(res.get('Content-Type')).includes('text/html') ? url.html(body) : body);
+ const redirect = res.redirect.bind(res);
+ res.redirect = (status, target) => typeof status === 'string'
+ ? redirect(url.path(status)) : redirect(status, url.path(target));
+ next();
+ });
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -139,7 +163,7 @@ async function createApp(opts = {}) {
@@ -139,7 +163,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ imgSrc: ["'self'", 'data:', 'https:'],
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
@@ -155,6 +179,7 @@ async function createApp(opts = {}) {
@@ -155,6 +179,7 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+ app.use(require('./auth/scoped-session').scopedSession(config));
// ── OpenVibe.Events → Inventory (loopback only: nginx answers 404 for /internal/) ──
// ── OpenVibe.Events → Inventory (loopback only: nginx answers 404 for /internal/) ──
// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
@@ -171,12 +196,36 @@ async function createApp(opts = {}) {
@@ -171,12 +196,36 @@ async function createApp(opts = {}) {
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
app.use(sso.middleware());
app.use(sso.middleware());
+ // A top-level silent authorization sees Network's SameSite cookies even when
+ // third-party iframe cookies are blocked. Guests return with sso=none once.
+ app.use((req, res, next) => {
+ if (req.inventoryScoped && req.method === 'GET' && legacyPage(req.path)
+ && String(req.get('accept') || '').includes('text/html')
+ && !/bot|crawl|spider|slurp/i.test(String(req.get('user-agent') || ''))
+ && req.viewer.kind !== 'user' && req.cookies.ov_sso_hint !== 'guest' && req.query.sso !== 'none') {
+ res.set('Cache-Control', 'private, no-store');
+ return res.redirect(302, url.path('/auth/login') + '?silent=1&next=' + encodeURIComponent(req.originalUrl));
+ }
+ next();
+ });
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me', standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me', standardHeaders: true, legacyHeaders: false }));
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
- { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'inventory', service: 'inventory', host: 'inventory.openvibe.network', name: 'OpenVibe.Inventory', profile: 'ugc' })); }
+ {
+ const legal = require('openvibe-shared/legal');
+ const site = { id: 'inventory', service: 'inventory', host: new URL(config.baseUrl).host, name: 'OpenVibe.Inventory', profile: 'ugc' };
+ app.get(legal.PATHS, (req, res, next) => {
+ if (!url.prefix) return legal.handler(site)(req, res, next);
+ // Shared's standalone legal shell reads root ov_token. Use our scoped
+ // navbar and canonical while keeping its legal article unchanged.
+ const kind = req.path.replace(/^\/+|\/+$/g, '');
+ const doc = legal.build(kind, site);
+ return send(res, 200, { config, viewer: req.viewer, path: req.originalUrl,
+ title: doc.title, description: doc.description, body: require('./render/html').raw(legal.article(kind, site)) });
+ });
+ }
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -219,17 +268,21 @@ async function createApp(opts = {}) {
@@ -219,17 +268,21 @@ async function createApp(opts = {}) {
// manifest and twin requests reach this mount; the pages advertise the twin themselves.
// manifest and twin requests reach this mount; the pages advertise the twin themselves.
const agentLayer = express();
const agentLayer = express();
require('openvibe-shared/agent-layer').mount(agentLayer, {
require('openvibe-shared/agent-layer').mount(agentLayer, {
- descriptor: require('./agent-descriptor'),
+ descriptor: { ...require('./agent-descriptor'), home: url.absolute('/'), site: new URL(config.baseUrl).hostname,
+ api: 'https://inventory.openvibe.network/api/v1', openapi: 'https://inventory.openvibe.network/openapi.json',
+ llms: url.absolute('/llms.txt'), llmsFull: url.absolute('/llms-full.txt'), sitemap: url.absolute('/sitemap.xml') },
render: require('./agent-pages').createAgentPages(ctx, { ...pageRoutes.publicViews, workshop: workshopRoutes.publicViews.workshop }).renderPublicPage,
render: require('./agent-pages').createAgentPages(ctx, { ...pageRoutes.publicViews, workshop: workshopRoutes.publicViews.workshop }).renderPublicPage,
});
});
app.use((req, res, next) => {
app.use((req, res, next) => {
const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
if (req.path !== '/.well-known/agent.json' && !twin) return next();
if (req.path !== '/.well-known/agent.json' && !twin) return next();
- return agentLayer(req, res, next);
+ const routedUrl = req.url;
+ if (req.inventoryScoped) req.url = req.originalUrl;
+ return agentLayer(req, res, (err) => { req.url = routedUrl; next(err); });
});
});
app.use(workshopRoutes);
app.use(workshopRoutes);
app.use(pageRoutes);
app.use(pageRoutes);
- app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
+ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="${url.path('/')}">the home page</a> or <a href="${url.path('/updates')}">the update log</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+const { urls } = require('../urls');
+
+// SDK 0.94 fixes its cookie names and /auth paths. Adapt its Express boundary so
+// Inventory never reads or writes the account hub's root session or flow cookies.
+function scopedSession(config) {
+ const url = urls(config);
+ return (req, res, next) => {
+ if (!req.inventoryScoped) return next();
+ const incoming = req.cookies || {};
+ req.cookies = Object.fromEntries(Object.entries(incoming)
+ .filter(([name]) => name.startsWith('inventory_ov_'))
+ .map(([name, value]) => [name.slice('inventory_'.length), value]));
+ const cookie = res.cookie.bind(res);
+ const clearCookie = res.clearCookie.bind(res);
+ const nameFor = (name) => name.startsWith('ov_') ? `inventory_${name}` : name;
+ const options = (opts) => ({ ...opts, domain: undefined, path: url.prefix });
+ res.cookie = (name, value, opts) => cookie(nameFor(name), value, options(opts));
+ // Express clearCookie calls res.cookie; pass the original name to avoid renaming twice.
+ res.clearCookie = (name, opts) => clearCookie(name, options(opts));
+ next();
+ };
+}
+module.exports = { scopedSession };
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
* refresh writes ov_token / ov_refresh and clears them.
* refresh writes ov_token / ov_refresh and clears them.
*/
*/
const express = require('express');
const express = require('express');
+const { urls } = require('../urls');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
@@ -44,6 +45,7 @@ function pkcePair() {
@@ -44,6 +45,7 @@ function pkcePair() {
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
const secure = config.cookies.secure;
const secure = config.cookies.secure;
+ const url = urls(config);
const sso = createSsoClient({
const sso = createSsoClient({
site: 'inventory',
site: 'inventory',
siteName: 'OpenVibe.Inventory',
siteName: 'OpenVibe.Inventory',
@@ -159,7 +161,12 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -159,7 +161,12 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
// The shared navbar's silent sign-in sends the whole page address: keep its path before the SDK sees it.
// The shared navbar's silent sign-in sends the whole page address: keep its path before the SDK sees it.
r.use((req, _res, next) => {
r.use((req, _res, next) => {
if (req.method === 'GET' && req.path === '/login') {
if (req.method === 'GET' && req.path === '/login') {
- const target = sanitizeNext(req.query.next);
+ let value = req.query.next;
+ // Shared passes an absolute current URL. Accept only this exact origin.
+ if (typeof value === 'string' && /^https?:/.test(value)) {
+ try { const u = new URL(value); value = u.origin === url.origin ? u.pathname + u.search : '/'; } catch { value = '/'; }
+ }
+ const target = url.path(sanitizeNext(value));
if (target === '/') delete req.query.next; else req.query.next = target;
if (target === '/') delete req.query.next; else req.query.next = target;
}
}
next();
next();
@@ -179,8 +186,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -179,8 +186,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
- + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
- + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ + `<form method="post" action="${url.path(`/auth/logout${q}`)}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="${url.path('/')}">Cancel</a></form>`);
});
});
r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
r.all('/logout', async (req, res) => {
r.all('/logout', async (req, res) => {
@@ -14,7 +14,7 @@ function load(env = process.env) {
@@ -14,7 +14,7 @@ function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 5030);
const port = int(env.PORT, 5030);
- const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));
+ const baseUrl = trim(env.PUBLIC_URL || env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
return {
return {
@@ -24,6 +24,7 @@ function load(env = process.env) {
@@ -24,6 +24,7 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
baseUrl,
+ redirectLegacyPages: env.INVENTORY_REDIRECT_LEGACY === 'true',
// IndexNow (openvibe-shared/indexnow): with a key, each page Search gains, changes or loses is sent to search
// IndexNow (openvibe-shared/indexnow): with a key, each page Search gains, changes or loses is sent to search
// engines in one batch (openvibe-publishing/search-feed), and the key is served at /<key>.txt. Unset: off.
// engines in one batch (openvibe-publishing/search-feed), and the key is served at /<key>.txt. Unset: off.
indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
@@ -17,6 +17,7 @@ const { asyncRouter } = require('./router');
@@ -17,6 +17,7 @@ const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Inventory';
const SITE_NAME = 'OpenVibe.Inventory';
const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
+const { urls } = require('../urls');
const DISALLOW = ['/auth/', '/api/'];
const DISALLOW = ['/auth/', '/api/'];
// The two routes that need a subject point at the API reference, not at a URL that answers 400.
// The two routes that need a subject point at the API reference, not at a URL that answers 400.
const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';
const API_DOC = 'https://github.com/OpenVibers/OpenVibe.Inventory#api-apiv1-problemjson-errors';
@@ -42,7 +43,7 @@ function homeJsonLd(config) {
@@ -42,7 +43,7 @@ function homeJsonLd(config) {
return [
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
- seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: urls(config).absolute('/'), description: DESCRIPTION, siteUrl: site }),
];
];
}
}
@@ -81,14 +82,14 @@ function createDiscoveryRoutes(ctx) {
@@ -81,14 +82,14 @@ function createDiscoveryRoutes(ctx) {
const { config, inv } = ctx;
const { config, inv } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
- const abs = (p) => `${site}${p}`;
+ const abs = urls(config).absolute;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
'# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
+ '# Agent manifest: https://inventory.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'
+ '# Agent manifest: https://inventory.openvibe.network/.well-known/agent.json; every public page has a .md twin.\n'
- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW.map(urls(config).path) }));
});
});
r.get('/llms.txt', async (_req, res) => {
r.get('/llms.txt', async (_req, res) => {
@@ -135,12 +136,12 @@ function createDiscoveryRoutes(ctx) {
@@ -135,12 +136,12 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms-full.txt', async (_req, res) => {
r.get('/llms-full.txt', async (_req, res) => {
const { kinds, defs, byKind } = await catalog(inv);
const { kinds, defs, byKind } = await catalog(inv);
- const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
+ const pages = [...publicPages(), CATALOG_PAGE].map((e) => ({ url: abs(e.path), title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
// Every published item, one line each (name, rarity, kind and URL), grouped by kind.
// Every published item, one line each (name, rarity, kind and URL), grouped by kind.
const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {
const itemSections = kindsWithItems({ kinds, byKind }).map((k) => {
const items = byKind.get(k.id);
const items = byKind.get(k.id);
- pages.push({ url: kindPath(k.id), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });
- return { title: k.name, pages: items.map((d) => ({ title: d.name, url: `/items/${d.id}`, text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };
+ pages.push({ url: abs(kindPath(k.id)), title: `${k.name} items`, text: `${plural(items.length, 'item', 'items')}, with their rarity.` });
+ return { title: k.name, pages: items.map((d) => ({ title: d.name, url: abs(`/items/${d.id}`), text: `${RARITY_LABEL[d.rarity] || d.rarity} ${k.name}` })) };
});
});
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const express = require('express');
const express = require('express');
+const { urls } = require('../urls');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
@@ -49,7 +50,7 @@ function art(d, { size = 'md' } = {}) {
@@ -49,7 +50,7 @@ function art(d, { size = 'md' } = {}) {
}
}
const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;
const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;
-function card(d, { href = `/items/${d.id}`, extra = '', owned = null, worn = false } = {}) {
+function renderCard(d, { url, href = url(`/items/${d.id}`), extra = '', owned = null, worn = false } = {}) {
return html`<li class="inv-card r-${d.rarity}${worn ? ' is-worn' : ''}">
return html`<li class="inv-card r-${d.rarity}${worn ? ' is-worn' : ''}">
<a class="inv-card-link" href="${href}">${art(d)}<span class="inv-card-body"><span class="inv-card-name">${d.name}</span>${rarityBadge(d.rarity)}</span></a>
<a class="inv-card-link" href="${href}">${art(d)}<span class="inv-card-body"><span class="inv-card-name">${d.name}</span>${rarityBadge(d.rarity)}</span></a>
${d.description ? html`<p class="inv-card-desc">${d.description}</p>` : ''}${owned ? html`<p class="inv-card-meta">${owned}</p>` : ''}${extra}
${d.description ? html`<p class="inv-card-desc">${d.description}</p>` : ''}${owned ? html`<p class="inv-card-meta">${owned}</p>` : ''}${extra}
@@ -92,10 +93,12 @@ function itemDescription(d, k) {
@@ -92,10 +93,12 @@ function itemDescription(d, k) {
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, inv } = ctx;
const { config, inv } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ const url = urls(config).path;
+ const card = (d, options = {}) => renderCard(d, { ...options, url });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
- const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/items">Browse every item</a>.</p>` }, 404);
+ const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="${url(`/items`)}">Browse every item</a>.</p>` }, 404);
async function catalog() {
async function catalog() {
const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);
const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);
@@ -117,10 +120,10 @@ function createPageRoutes(ctx) {
@@ -117,10 +120,10 @@ function createPageRoutes(ctx) {
accent: 'on every OpenVibe site.',
accent: 'on every OpenVibe site.',
lede: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and more. Earn them across the network, wear them in chat, on stream overlays and on your profile.',
lede: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and more. Earn them across the network, wear them in chat, on stream overlays and on your profile.',
actions: me
actions: me
- ? [{ label: 'Your inventory', href: '/me', primary: true }, { label: 'Browse every item', href: '/items' }]
- : [{ label: 'Browse every item', href: '/items', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
+ ? [{ label: 'Your inventory', href: url('/me'), primary: true }, { label: 'Browse every item', href: url('/items') }]
+ : [{ label: 'Browse every item', href: url('/items'), primary: true }, { label: 'Sign in to see yours', href: url('/auth/login?next=%2Fme') }],
note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',
note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',
- aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },
+ aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="${url(`/items/${d.id}`)}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },
});
});
return {
return {
agent: true, index: true, cache: me ? null : PUBLIC_CACHE,
agent: true, index: true, cache: me ? null : PUBLIC_CACHE,
@@ -133,7 +136,7 @@ ${raw(showcase.features({
@@ -133,7 +136,7 @@ ${raw(showcase.features({
// nor counted, so "in 4 kinds" sits over four cards.
// nor counted, so "in 4 kinds" sits over four cards.
lede: `${plural(defs.length, 'item', 'items')} in ${plural(kinds.filter((k) => (byKind.get(k.id) || []).length).length, 'kind', 'kinds')} today. Each kind knows where it shows, so a new kind is all it takes for a new site or game to join.`,
lede: `${plural(defs.length, 'item', 'items')} in ${plural(kinds.filter((k) => (byKind.get(k.id) || []).length).length, 'kind', 'kinds')} today. Each kind knows where it shows, so a new kind is all it takes for a new site or game to join.`,
// Kinds that have items, the biggest first; an empty kind waits until an issuer defines something in it.
// Kinds that have items, the biggest first; an empty kind waits until an issuer defines something in it.
- items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: `/items?kind=${encodeURIComponent(k.id)}` })),
+ items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: url(`/items?kind=${encodeURIComponent(k.id)}`) })),
}))}
}))}
${raw(showcase.steps({
${raw(showcase.steps({
title: 'How items reach you',
title: 'How items reach you',
@@ -142,14 +145,17 @@ ${raw(showcase.steps({
@@ -142,14 +145,17 @@ ${raw(showcase.steps({
{ title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },
{ title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },
{ title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },
{ title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },
{ title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },
{ title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },
- { title: 'Or make one', text: 'In the Workshop you make a badge, staff review it, and you give it to the people you choose.', href: '/workshop' },
+ { title: 'Or make one', text: 'In the Workshop you make a badge, staff review it, and you give it to the people you choose.', href: url('/workshop') },
],
],
}))}
}))}
-${raw(showcase.network({ service: 'inventory' }))}
-${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,
+${raw(urls(config).html(showcase.network({ service: 'inventory' })))}
+${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: url('/me') }] : [{ label: 'Sign in with OpenVibe', href: url('/auth/login?next=%2Fme') }, { label: 'The API', href: url('/api/v1/kinds') }] }))}`,
};
};
}
}
- r.get('/', async (req, res) => page(req, res, await homePage({ signed: !!signedIn(req) })));
+ r.get('/', async (req, res, next) => {
+ if (urls(config).prefix && signedIn(req)) return ownPage(req, res);
+ return page(req, res, await homePage({ signed: !!signedIn(req) }));
+ });
// ── The catalog ──────────────────────────────────────────
// ── The catalog ──────────────────────────────────────────
async function itemsPage(only = null, { signed = false } = {}) {
async function itemsPage(only = null, { signed = false } = {}) {
@@ -163,7 +169,7 @@ ${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something
@@ -163,7 +169,7 @@ ${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something
description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
<p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>
<p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>
-<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All <span class="muted">${defs.length}</span></a>${kinds.filter((k) => (byKind.get(k.id) || []).length || only === k.id).map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>
+<nav class="inv-tabs" aria-label="Kinds"><a href="${url(`/items`)}"${only ? '' : raw(' aria-current="page"')}>All <span class="muted">${defs.length}</span></a>${kinds.filter((k) => (byKind.get(k.id) || []).length || only === k.id).map((k) => html`<a href="${url(`/items?kind=${encodeURIComponent(k.id)}`)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>
${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}">
${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}">
<h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">${plural((byKind.get(k.id) || []).length, 'item', 'items')} · shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
<h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">${plural((byKind.get(k.id) || []).length, 'item', 'items')} · shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
${(byKind.get(k.id) || []).length ? tiles(byKind.get(k.id), (d) => tile(d), { first: only ? Infinity : 12, more: `More ${k.name.toLowerCase()} items` }) : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
${(byKind.get(k.id) || []).length ? tiles(byKind.get(k.id), (d) => tile(d), { first: only ? Infinity : 12, more: `More ${k.name.toLowerCase()} items` }) : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
@@ -187,7 +193,7 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
@@ -187,7 +193,7 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,
title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,
// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.
// What the item is, how many people own it and how it is got; its flavor text alone ("Angelic") said none of that.
description: itemDescription(d, k),
description: itemDescription(d, k),
- body: html`<p class="crumbs"><a href="/items">Every item</a> › <a href="/items?kind=${encodeURIComponent(k.id)}">${k.name}</a></p>
+ body: html`<p class="crumbs"><a href="${url(`/items`)}">Every item</a> › <a href="${url(`/items?kind=${encodeURIComponent(k.id)}`)}">${k.name}</a></p>
<div class="inv-detail r-${d.rarity}">
<div class="inv-detail r-${d.rarity}">
${art(d, { size: 'xl' })}
${art(d, { size: 'xl' })}
<div>
<div>
@@ -209,7 +215,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
@@ -209,7 +215,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
<h2>Who gives it</h2>
<h2>Who gives it</h2>
<p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>
<p>${issuer}${(d.grantors || []).length ? ', and the services it names (see How to get it)' : ''}. ${(d.grantors || []).length ? 'Nobody else' : 'Only its issuer'} can give this item; it is never sold here.</p>
-<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,
+<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="${url(`/api/v1/definitions/${d.id}`)}">JSON</a></p>`,
};
};
}
}
r.get('/items/:id', async (req, res) => {
r.get('/items/:id', async (req, res) => {
@@ -228,7 +234,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
@@ -228,7 +234,7 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
return { kinds, byKind, worn, owned, more: !!list.next_cursor };
return { kinds, byKind, worn, owned, more: !!list.next_cursor };
}
}
- r.get('/me', async (req, res) => {
+ async function ownPage(req, res) {
const me = signedIn(req);
const me = signedIn(req);
if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });
const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });
@@ -245,20 +251,21 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-
@@ -245,20 +251,21 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-
const on = byKind.get(k.id).find(({ i }) => worn.has(i.id));
const on = byKind.get(k.id).find(({ i }) => worn.has(i.id));
return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;
return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;
})}</ul></section>` : ''}
})}</ul></section>` : ''}
-<p class="inv-links"><a href="/u/${me}">Your public page</a> · <a href="/items">Every item</a></p>
+<p class="inv-links"><a href="${url(`/u/${me}`)}">Your public page</a> · <a href="${url(`/items`)}">Every item</a></p>
${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">
${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">
<h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>
<h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>
${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {
${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {
worn: worn.has(i.id),
worn: worn.has(i.id),
note: i.serial ? `#${i.serial}` : '',
note: i.serial ? `#${i.serial}` : '',
- form: k.slots.length ? (label) => html`<form method="post" action="/me/equip" class="inv-tile-form">
+ form: k.slots.length ? (label) => html`<form method="post" action="${url(`/me/equip`)}" class="inv-tile-form">
<input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}">
<input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}">
<button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,
<button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,
}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}
}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}
</section>`)}
</section>`)}
-${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="/api/v1/me/items">/api/v1/me/items</a>.</p>` : ''}`,
+${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="${url(`/api/v1/me/items`)}">/api/v1/me/items</a>.</p>` : ''}`,
});
});
- });
+ }
+ r.get('/me', ownPage);
r.post('/me/equip', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
r.post('/me/equip', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
const me = signedIn(req);
const me = signedIn(req);
@@ -296,7 +303,7 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
@@ -296,7 +303,7 @@ ${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
const updatesPage = () => ({
const updatesPage = () => ({
agent: true, index: true, cache: PUBLIC_CACHE,
agent: true, index: true, cache: PUBLIC_CACHE,
title: `What shipped on ${SITE_NAME}`,
title: `What shipped on ${SITE_NAME}`,
- body: raw(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
+ body: raw(urls(config).html(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME })) + `<script src="${url(ovServe.url('shipped.js'))}" defer></script>`),
});
});
r.get('/updates', (req, res) => page(req, res, updatesPage()));
r.get('/updates', (req, res) => page(req, res, updatesPage()));
@@ -43,7 +43,7 @@ function sameOrigin(req, baseUrl) {
@@ -43,7 +43,7 @@ function sameOrigin(req, baseUrl) {
const site = String(req.get('sec-fetch-site') || '');
const site = String(req.get('sec-fetch-site') || '');
if (site) return site === 'same-origin';
if (site) return site === 'same-origin';
const origin = req.get('origin');
const origin = req.get('origin');
- if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+ if (origin) return origin === new URL(baseUrl).origin || origin === `${req.protocol}://${req.get('host')}`;
return false;
return false;
}
}
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* - the product's own script only where a page offers an in-browser convenience
* - the product's own script only where a page offers an in-browser convenience
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
+const { urls } = require('../urls');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -50,32 +51,35 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -50,32 +51,35 @@ function setRelease(id) { if (id) RELEASE = String(id); }
* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
*/
*/
function renderPage(o) {
function renderPage(o) {
+ const { path: url, absolute, html: sharedHtml } = urls(o.config);
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const canonical = absolute(pathNow.split('?')[0]);
const nav = {
const nav = {
service: 'inventory',
service: 'inventory',
+ ...(urls(o.config).prefix ? { brand: { href: url('/'), tld: 'inventory', name: 'Inventory' } } : {}),
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Items', href: '/items', icon: 'fa-tag' }, { label: 'Yours', href: '/me', icon: 'fa-shirt' }, { label: 'Workshop', href: '/workshop', icon: 'fa-screwdriver-wrench' }], // phones: the bottom tab bar (Shared 4.20)
+ links: NAV.map((l) => ({ label: l.label, href: url(l.href), ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === url(l.href) || pathNow.startsWith(`${url(l.href)}/`) })),
+ appBar: [{ label: 'Home', href: url('/'), icon: 'fa-house' }, { label: 'Items', href: url('/items'), icon: 'fa-tag' }, { label: 'Yours', href: url('/me'), icon: 'fa-shirt' }, { label: 'Workshop', href: url('/workshop'), icon: 'fa-screwdriver-wrench' }], // phones: the bottom tab bar (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
- silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
- sessionUrl: '/auth/me',
- loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
- logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
+ silentLogin: urls(o.config).prefix ? null : absolute('/auth/login') + '?silent=1&next={url}',
+ sessionUrl: url('/auth/me'),
+ fedcmLogin: url('/auth/fedcm'),
+ loginUrl: url('/auth/login?next={path}'), // filled from the current page (boost moves between pages)
+ logoutUrl: url('/auth/logout?next={path}'), // Sign out in the shared navbar ends this site's session too
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
};
- const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
+ const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: url('/updates'), ...(urls(o.config).prefix ? { sitemap: url('/sitemap.xml'), legalBase: absolute('/') } : {}) };
const account = signedIn
const account = signedIn
- ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
- : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="${url(`/auth/logout?next=${loginNext}`)}">Sign out</a>`
+ : html`<a href="${url(`/auth/login?next=${loginNext}`)}">Sign in with OpenVibe</a>`;
const crumbs = (o.crumbs || []).length
const crumbs = (o.crumbs || []).length
- ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
+ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${url(c.href)}">${c.label}</a>` : c.label}`)}</nav>`
: '';
: '';
- const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
- return shell.page({
+ const scripts = (o.scripts || []).map((rel) => html`<script src="${url(asset(rel))}" defer></script>`);
+ return sharedHtml(shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
title: o.title || `${SITE_NAME}: ${TAGLINE}`,
title: o.title || `${SITE_NAME}: ${TAGLINE}`,
@@ -87,34 +91,43 @@ function renderPage(o) {
@@ -87,34 +91,43 @@ function renderPage(o) {
// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
// is server/agent-pages.js.
// is server/agent-pages.js.
agentLayer: o.agent === true,
agentLayer: o.agent === true,
- llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
+ llms: absolute('/llms.txt'),
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
- home: '/',
- navLinks: NAV,
+ home: url('/'),
+ navLinks: NAV.map((l) => ({ ...l, href: url(l.href) })),
navbar: nav,
navbar: nav,
footer,
footer,
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
-${appIcon.headTags({ site: 'inventory' })}
-<link rel="stylesheet" href="${asset('css/app.css')}">
-${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+${sharedHtml(appIcon.headTags({ site: 'inventory' }))}
+<link rel="stylesheet" href="${url(asset('css/app.css'))}">
+${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(url(ovServe.url(name)))}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
-<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+<script src="${url(ovServe.url('boost.js'))}" data-main="#main" defer></script>`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<main id="main" class="page">
<main id="main" class="page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${render(o.body)}
-${o.path === '/' ? frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+${o.path === '/' ? sharedHtml(frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` })) : ''}
</main>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function () {
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+${urls(o.config).prefix ? ` // Shared 4.150 hardcodes the footer brand's home and rerenders it after loading
+ // Frame data. Keep that link inside this section on each render.
+ var footer = document.getElementById('ov-footer');
+ var home = ${JSON.stringify(url('/'))};
+ if (footer && home !== '/') {
+ var fixHome = function () { footer.querySelectorAll('a.ovf-brand').forEach(function (a) { if (a.getAttribute('href') !== home) a.setAttribute('href', home); }); };
+ fixHome();
+ new MutationObserver(fixHome).observe(footer, { childList: true, subtree: true });
+ }` : ''}
});
});
</script>`,
</script>`,
- });
+ }));
}
}
function render(v) { return require('./html').render(v); }
function render(v) { return require('./html').render(v); }
@@ -126,8 +139,7 @@ function send(res, status, o) {
@@ -126,8 +139,7 @@ function send(res, status, o) {
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
if (o.agent) {
if (o.agent) {
- const site = String(o.config.baseUrl).replace(/\/+$/, '');
- res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(urls(o.config).absolute(String(o.path || '/').split('?')[0]), urls(o.config).absolute('/llms.txt')).header);
}
}
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
@@ -48,11 +48,15 @@ function describe(row) {
@@ -48,11 +48,15 @@ function describe(row) {
}
}
function createSearchIndex({ config, s, outbox, log = console, indexnow = null }) {
function createSearchIndex({ config, s, outbox, log = console, indexnow = null }) {
+ const url = require('./urls').urls(config);
+ // Publishing's origin-based sitemap ping needs the same section prefix as pages.
+ const scopedIndexnow = indexnow && { enabled: indexnow.enabled,
+ pingSoon: (paths) => indexnow.pingSoon(paths.map((p) => p === `${url.origin}/sitemap.xml` ? url.absolute('/sitemap.xml') : p)) };
const feed = createSearchFeed({
const feed = createSearchFeed({
- owner: 'inventory', db: s.db, outbox, baseUrl: config.baseUrl, now: s.now, log, indexnow,
+ owner: 'inventory', db: s.db, outbox, baseUrl: url.origin, now: s.now, log, indexnow: scopedIndexnow,
types: {
types: {
definition: {
definition: {
- page: (row) => `/items/${row.id}`,
+ page: (row) => url.path(`/items/${row.id}`),
document: describe,
document: describe,
rows: (after, limit) => s.db.many(`${SELECT} WHERE d.id > $1 ORDER BY d.id LIMIT $2`, [after, limit]),
rows: (after, limit) => s.db.many(`${SELECT} WHERE d.id > $1 ORDER BY d.id LIMIT $2`, [after, limit]),
exists: async (ids) => (await s.db.many('SELECT id FROM inventory_definitions WHERE id = ANY($1)', [ids])).map((r) => r.id),
exists: async (ids) => (await s.db.many('SELECT id FROM inventory_definitions WHERE id = ANY($1)', [ids])).map((r) => r.id),
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+
+// One translation for browser links, redirects, metadata and discovery. API paths retain their shape.
+function urls(config) {
+ const base = new URL(config.baseUrl);
+ const prefix = base.pathname.replace(/\/+$/, '');
+ function path(value = '/') {
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return value;
+ if (prefix && (value === `${prefix}.md` || value === prefix || value.startsWith(`${prefix}/`) || value.startsWith(`${prefix}?`))) return value;
+ if (!prefix) return value;
+ const match = /^([^?#]*)(.*)$/.exec(value);
+ let route = match[1];
+ if (route === '/items') route = '/all';
+ else if (route.startsWith('/items/')) route = route.slice(6);
+ else if (route === '/me') route = '/';
+ return prefix + (route === '/' ? '' : route) + match[2];
+ }
+ const absolute = (value = '/') => new URL(path(value), base.origin).href;
+ // Shared components return HTML, not link objects. Translate only their URL attributes.
+ const html = (value) => String(value).replace(/\b(href|src|action)=(['"])(\/(?!\/)[^'"]*)\2/g,
+ (_all, attr, quote, value) => `${attr}=${quote}${path(value)}${quote}`);
+ return { path, absolute, html, prefix, origin: base.origin };
+}
+
+function legacyPage(path) {
+ return path === '/' || /^\/(?:items(?:\/[^/]+)?|me|workshop(?:\/.*)?|u\/[^/]+|updates)\/?$/.test(path);
+}
+module.exports = { urls, legacyPage };
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* its image appears before that. Nothing is sold, bought or traded (ADR-054 §5).
* its image appears before that. Nothing is sold, bought or traded (ADR-054 §5).
*/
*/
const express = require('express');
const express = require('express');
+const { urls } = require('../urls');
const Busboy = require('busboy');
const Busboy = require('busboy');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
@@ -52,6 +53,7 @@ const img = (d, size = 64) => html`<img class="ws-img" src="https://openvibe.med
@@ -52,6 +53,7 @@ const img = (d, size = 64) => html`<img class="ws-img" src="https://openvibe.med
function createWorkshopRoutes(ctx) {
function createWorkshopRoutes(ctx) {
const { config, inv, workshopMedia, people } = ctx;
const { config, inv, workshopMedia, people } = ctx;
const r = asyncRouter();
const r = asyncRouter();
+ const url = urls(config).path;
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
const isStaff = (req) => !!(signedIn(req) && STAFF_ROLES.has(req.viewer.role));
const isStaff = (req) => !!(signedIn(req) && STAFF_ROLES.has(req.viewer.role));
@@ -68,7 +70,7 @@ function createWorkshopRoutes(ctx) {
@@ -68,7 +70,7 @@ function createWorkshopRoutes(ctx) {
description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',
description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',
body: html`<h1>The Workshop</h1>
body: html`<h1>The Workshop</h1>
<p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>
<p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>
-<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop/mine">Your badges</a></p>
+<p><a class="inv-btn primary" href="${url(`/workshop/new`)}">Make a badge</a> <a class="inv-btn" href="${url(`/workshop/mine`)}">Your badges</a></p>
<h2>How it works</h2>
<h2>How it works</h2>
<ol class="ws-steps">
<ol class="ws-steps">
<li><strong>Make it.</strong> A square PNG or WebP, ${MIN_SIDE}–${MAX_SIDE} pixels, at most ${MAX_BYTES / 1024} KB, that you made or may use, a name, and how many may ever exist (up to ${WORKSHOP.maxCap.toLocaleString('en-US')}).</li>
<li><strong>Make it.</strong> A square PNG or WebP, ${MIN_SIDE}–${MAX_SIDE} pixels, at most ${MAX_BYTES / 1024} KB, that you made or may use, a name, and how many may ever exist (up to ${WORKSHOP.maxCap.toLocaleString('en-US')}).</li>
@@ -76,7 +78,7 @@ function createWorkshopRoutes(ctx) {
@@ -76,7 +78,7 @@ function createWorkshopRoutes(ctx) {
<li><strong>Give it.</strong> Type someone's name and it lands in their inventory, up to ${WORKSHOP.giftsPerDay} a day. They wear it before their name in chat and on their profile.</li>
<li><strong>Give it.</strong> Type someone's name and it lands in their inventory, up to ${WORKSHOP.giftsPerDay} a day. They wear it before their name in chat and on their profile.</li>
</ol>
</ol>
<h2>Community badges <span class="muted small">${published.length}</span></h2>
<h2>Community badges <span class="muted small">${published.length}</span></h2>
-${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,
+${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="${url(`/items/${d.id}`)}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,
};
};
}
}
r.get('/workshop', async (req, res) => page(req, res, await workshopPage()));
r.get('/workshop', async (req, res) => page(req, res, await workshopPage()));
@@ -89,13 +91,13 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li c
@@ -89,13 +91,13 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li c
robots: 'noindex',
robots: 'noindex',
body: html`<h1>Make a badge</h1>
body: html`<h1>Make a badge</h1>
${notice(req)}
${notice(req)}
-<form class="ws-form" method="post" action="/workshop/new" enctype="multipart/form-data">
+<form class="ws-form" method="post" action="${url(`/workshop/new`)}" enctype="multipart/form-data">
<label>Image <input type="file" name="image" accept="image/png,image/webp" required></label>
<label>Image <input type="file" name="image" accept="image/png,image/webp" required></label>
<p class="muted small">A square PNG or WebP, ${MIN_SIDE} to ${MAX_SIDE} pixels on a side, at most ${MAX_BYTES / 1024} KB. It shows small, before a name: keep it simple and readable.</p>
<p class="muted small">A square PNG or WebP, ${MIN_SIDE} to ${MAX_SIDE} pixels on a side, at most ${MAX_BYTES / 1024} KB. It shows small, before a name: keep it simple and readable.</p>
<label>Name <input type="text" name="name" maxlength="80" required placeholder="OG Viewer"></label>
<label>Name <input type="text" name="name" maxlength="80" required placeholder="OG Viewer"></label>
<label>What it means (optional) <textarea name="description" maxlength="500" rows="2" placeholder="For everyone who was in chat for the first stream."></textarea></label>
<label>What it means (optional) <textarea name="description" maxlength="500" rows="2" placeholder="For everyone who was in chat for the first stream."></textarea></label>
<label>How many may ever exist <input type="number" name="supply_cap" min="1" max="${WORKSHOP.maxCap}" value="100" required></label>
<label>How many may ever exist <input type="number" name="supply_cap" min="1" max="${WORKSHOP.maxCap}" value="100" required></label>
- <label class="ws-check"><input type="checkbox" name="rights" value="yes" required> <span>I made this image, or I have the right to use it, and it follows the <a href="/terms">rules</a>.</span></label>
+ <label class="ws-check"><input type="checkbox" name="rights" value="yes" required> <span>I made this image, or I have the right to use it, and it follows the <a href="${url(`/terms`)}">rules</a>.</span></label>
<button type="submit" class="inv-btn primary">Send for review</button>
<button type="submit" class="inv-btn primary">Send for review</button>
</form>
</form>
<p class="muted small">Staff review every badge before anyone else sees it. At most ${WORKSHOP.inReview} in review at once and ${WORKSHOP.perDay} a day.</p>`,
<p class="muted small">Staff review every badge before anyone else sees it. At most ${WORKSHOP.inReview} in review at once and ${WORKSHOP.perDay} a day.</p>`,
@@ -140,19 +142,19 @@ ${notice(req)}
@@ -140,19 +142,19 @@ ${notice(req)}
robots: 'noindex',
robots: 'noindex',
body: html`<h1>Your badges</h1>
body: html`<h1>Your badges</h1>
${notice(req)}
${notice(req)}
-<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop">The Workshop</a></p>
+<p><a class="inv-btn primary" href="${url(`/workshop/new`)}">Make a badge</a> <a class="inv-btn" href="${url(`/workshop`)}">The Workshop</a></p>
${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-item">
${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-item">
<span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span>
<span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span>
<div class="ws-item-body">
<div class="ws-item-body">
<h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>
<h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>
${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}
${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}
- ${d.status === 'draft' ? html`<form method="post" action="/workshop/${d.id}/resubmit" class="ws-give">
+ ${d.status === 'draft' ? html`<form method="post" action="${url(`/workshop/${d.id}/resubmit`)}" class="ws-give">
<label>Name <input type="text" name="name" required maxlength="64" value="${d.name}"></label>
<label>Name <input type="text" name="name" required maxlength="64" value="${d.name}"></label>
<button type="submit" class="inv-btn primary">Send for review again</button>
<button type="submit" class="inv-btn primary">Send for review again</button>
</form>` : ''}
</form>` : ''}
${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}
${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}
- ${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="/items/${d.id}">its page</a></p>
- ${d.supply.issued < d.supply.cap ? html`<form method="post" action="/workshop/${d.id}/give" class="ws-give">
+ ${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="${url(`/items/${d.id}`)}">its page</a></p>
+ ${d.supply.issued < d.supply.cap ? html`<form method="post" action="${url(`/workshop/${d.id}/give`)}" class="ws-give">
<label>Give it to <input type="text" name="to" required maxlength="25" placeholder="@username" pattern="@?[A-Za-z0-9_]{3,24}"></label>
<label>Give it to <input type="text" name="to" required maxlength="25" placeholder="@username" pattern="@?[A-Za-z0-9_]{3,24}"></label>
<button type="submit" class="inv-btn primary">Give</button>
<button type="submit" class="inv-btn primary">Give</button>
</form>` : html`<p class="muted">All ${d.supply.cap} have been given.</p>`}` : ''}
</form>` : html`<p class="muted">All ${d.supply.cap} have been given.</p>`}` : ''}
@@ -211,7 +213,7 @@ ${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-
@@ -211,7 +213,7 @@ ${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-
<h2>${d.name}</h2>
<h2>${d.name}</h2>
<p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · cap ${d.supply.cap} · sent ${String(d.created_at).slice(0, 16).replace('T', ' ')} UTC</p>
<p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · cap ${d.supply.cap} · sent ${String(d.created_at).slice(0, 16).replace('T', ' ')} UTC</p>
${d.description ? html`<p>${d.description}</p>` : ''}
${d.description ? html`<p>${d.description}</p>` : ''}
- <form method="post" action="/workshop/${d.id}/review" class="ws-review">
+ <form method="post" action="${url(`/workshop/${d.id}/review`)}" class="ws-review">
<label>Rarity <select name="rarity">${RARITY.map((x) => html`<option value="${x}"${x === 'common' ? ' selected' : ''}>${RARITY_LABEL[x]}</option>`)}</select></label>
<label>Rarity <select name="rarity">${RARITY.map((x) => html`<option value="${x}"${x === 'common' ? ' selected' : ''}>${RARITY_LABEL[x]}</option>`)}</select></label>
<button type="submit" name="decision" value="publish" class="inv-btn primary">Publish</button>
<button type="submit" name="decision" value="publish" class="inv-btn primary">Publish</button>
<label class="ws-reason">Reason, if rejecting <input type="text" name="reason" maxlength="300" placeholder="The image is a brand's logo."></label>
<label class="ws-reason">Reason, if rejecting <input type="text" name="reason" maxlength="300" placeholder="The image is a brand's logo."></label>
@@ -226,7 +228,7 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it
@@ -226,7 +228,7 @@ ${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-it
<div class="ws-item-body">
<div class="ws-item-body">
<h2>${d.name}</h2>
<h2>${d.name}</h2>
<p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · ${d.supply.issued} of ${d.supply.cap} given</p>
<p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · ${d.supply.issued} of ${d.supply.cap} given</p>
- <form method="post" action="/workshop/${d.id}/takedown" class="ws-review">
+ <form method="post" action="${url(`/workshop/${d.id}/takedown`)}" class="ws-review">
<label class="ws-reason">Reason <input type="text" name="reason" maxlength="300" required placeholder="A rights holder asked us to remove it."></label>
<label class="ws-reason">Reason <input type="text" name="reason" maxlength="300" required placeholder="A rights holder asked us to remove it."></label>
<button type="submit" name="revoke" value="no" class="inv-btn">Retire</button>
<button type="submit" name="revoke" value="no" class="inv-btn">Retire</button>
<button type="submit" name="revoke" value="yes" class="inv-btn">Retire and revoke every copy</button>
<button type="submit" name="revoke" value="yes" class="inv-btn">Retire and revoke every copy</button>
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { urls } = require('../server/urls');
+const { DEF_RE } = require('../server/inventory/store');
+const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
+(async () => {
+ const pinged = [];
+ const t = await boot({ indexnow: { enabled: true, keyFile: (_req, _res, next) => next(), pingSoon: (paths) => pinged.push(...paths) }, env: { PUBLIC_URL: 'https://openvibe.my/inventory', INVENTORY_REDIRECT_LEGACY: 'true' } });
+ const user = t.network.addUser('inventory-owner');
+ const defs = await t.ctx.inv.listDefinitions({ limit: 2 });
+ const id = defs[0].id;
+ await check('item IDs cannot collide with reserved routes', async () => {
+ assert.ok(DEF_RE.test(id));
+ for (const word of ['all', 'workshop', 'u', 'updates', 'auth', 'api']) assert.ok(!DEF_RE.test(word));
+ });
+ await check('all page links, assets and form actions stay in Inventory', async () => {
+ for (const [path, as] of [
+ ['/inventory'], ['/inventory/all'], ['/inventory/all?kind=live.hat'], [`/inventory/${id}`],
+ ['/inventory/workshop'], ['/inventory/updates'], ['/inventory/terms'], ['/inventory/privacy'], ['/inventory', user],
+ ['/inventory/workshop/new', user], ['/inventory/workshop/mine', user],
+ ['/inventory/workshop/review', { ...user, role: 'admin' }], [`/inventory/u/${user.subject}`],
+ ['/inventory/missing'],
+ ]) {
+ const r = await t.get(path, { as });
+ assert.strictEqual(r.status, path === '/inventory/missing' ? 404 : 200, path);
+ const links = [...r.text.matchAll(/\b(?:href|src|action)=["'](\/(?!\/)[^"']*)["']/g)].map((m) => m[1]);
+ assert.ok(links.length, path);
+ assert.deepStrictEqual(links.filter((s) => !/^\/inventory(?:[/?#]|$)/.test(s)), [], path);
+ assert.ok(!r.text.includes('/inventory/inventory'), 'no double prefix');
+ assert.ok(r.text.includes(`rel=\"canonical\" href=\"https://openvibe.my${path.split('?')[0]}\"`), path + ': canonical');
+ assert.match(r.headers.get('content-security-policy'), /img-src 'self' data: https:;/);
+ if (path === '/inventory' && as) assert.match(r.text, /<h1>Your inventory<\/h1>/);
+ }
+ const asset = await t.get('/inventory/css/app.css');
+ assert.strictEqual(asset.status, 200);
+ const sitemap = await t.get('/inventory/sitemap.xml');
+ assert.ok(sitemap.text.includes('https://openvibe.my/inventory/all'));
+ assert.ok(sitemap.text.includes(`https://openvibe.my/inventory/${id}`));
+ assert.ok(!sitemap.text.includes('/items'));
+ const markdown = await t.get('/inventory/all.md');
+ assert.strictEqual(markdown.status, 200);
+ assert.ok(markdown.text.includes('canonical: \"https://openvibe.my/inventory/all\"'));
+ assert.strictEqual((await t.get('/inventory.md')).status, 200);
+ for (const path of ['/inventory/internal/events', '/inventory/metrics']) assert.strictEqual((await t.get(path)).status, 404);
+ });
+ await check('search documents and IndexNow use the prefixed URLs', async () => {
+ await t.ctx.searchIndex.sweep();
+ assert.ok(pinged.includes('https://openvibe.my/inventory/sitemap.xml'));
+ assert.ok(pinged.includes(`https://openvibe.my/inventory/${id}`));
+ assert.ok(pinged.every((p) => p.startsWith('https://openvibe.my/inventory/')));
+ });
+ await check('legacy page 301 table preserves queries; machine endpoints stay', async () => {
+ for (const [from, to] of [['/', ''], ['/items', '/all'], [`/items/${id}`, `/${id}`], ['/me', ''],
+ ['/workshop', '/workshop'], ['/workshop/new', '/workshop/new'], [`/u/${user.subject}`, `/u/${user.subject}`], ['/updates', '/updates']]) {
+ const r = await t.get(`${from}?keep=1`);
+ assert.strictEqual(r.status, 301, from);
+ assert.strictEqual(r.headers.get('location'), `https://openvibe.my/inventory${to}?keep=1`);
+ }
+ for (const path of ['/api/v1/kinds', '/.well-known/agent.json', '/auth/me', '/health', '/metrics', '/mcp', '/feed.xml', '/feed.json']) {
+ assert.notStrictEqual((await t.get(path)).status, 301, path);
+ }
+ });
+ await check('browser entry silently checks Network without third-party cookies', async () => {
+ const r = await t.get('/inventory', { headers: { accept: 'text/html' } });
+ assert.strictEqual(r.status, 302);
+ assert.strictEqual(r.headers.get('location'), '/inventory/auth/login?silent=1&next=%2Finventory');
+ assert.strictEqual((await t.get('/inventory?sso=none', { headers: { accept: 'text/html' } })).status, 200);
+ assert.strictEqual((await t.get('/inventory', { cookie: 'inventory_ov_sso_hint=guest', headers: { accept: 'text/html' } })).status, 200);
+ });
+ await check('silent PKCE callback uses isolated host-only Inventory cookies', async () => {
+ const hub = `ov_token=${t.network.userToken(user)}; ov_refresh=hub-refresh; ov_oauth_state=hub-state`;
+ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user, null, 'hub session is not an Inventory session');
+ const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: hub });
+ const loc = new URL(login.headers.get('location'));
+ assert.strictEqual(loc.searchParams.get('prompt'), 'none');
+ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.my/inventory/auth/callback');
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const callback = await t.get(`/inventory/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: `${hub}; ${cookieHeader(login)}` });
+ assert.strictEqual(callback.status, 302, callback.text);
+ assert.strictEqual(callback.headers.get('location'), '/inventory');
+ for (const r of [login, callback]) for (const c of r.headers.getSetCookie()) {
+ assert.match(c, /; Path=\/inventory(?:;|$)/);
+ assert.ok(!/; Domain=/i.test(c));
+ assert.ok(!/^ov_(?:token|refresh|oauth_)/.test(c), 'never write hub cookie names');
+ }
+ const session = cookieHeader(callback);
+ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: `${hub}; ${session}` })).json().user.username, user.username);
+ assert.match((await t.get('/inventory', { cookie: session })).text, /<h1>Your inventory<\/h1>/);
+ const logout = await t.get('/inventory/auth/logout', { cookie: session, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(logout.headers.get('location'), '/inventory');
+ for (const c of logout.headers.getSetCookie()) assert.match(c, /; Path=\/inventory(?:;|$)/);
+ const guest = await t.get(`/inventory/auth/callback?error=login_required`, { cookie: cookieHeader(login) });
+ assert.match(guest.headers.get('location'), /^\/inventory\?sso=none$/);
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -43,7 +43,7 @@ async function boot(opts = {}) {
@@ -43,7 +43,7 @@ async function boot(opts = {}) {
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
- const signIn = (user) => `inventory_at=${network.userToken(user)}`;
+ const signIn = (user) => `${new URL(config.baseUrl).pathname.replace(/\/+$/, '') ? 'inventory_ov_token' : 'inventory_at'}=${network.userToken(user)}`;
async function get(p, o = {}) {
async function get(p, o = {}) {
const headers = { ...(o.headers || {}) };
const headers = { ...(o.headers || {}) };