openvibers/openvibe.inventory Commits

Move Inventory pages under openvibe.my/inventory (#211)

OP OpenVibers committed 5 days ago · 260df57ee5 · 16 files, +358 −72

* Serve Inventory pages and isolated SSO under the account hub

* Keep legal pages and live footer links inside Inventory

* Scope IndexNow sitemap pings and retain the existing page budget

* Merge remote-tracking branch 'origin/main' into feat/inventory-to-my-1108

# Conflicts:
#	server/http/pages.js

Changes

ddiff --git a/README.md b/README.md
iindex 3e982d4..d540f79 100644
---- a/README.md
++++ b/README.md
@@@ -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`.
ddiff --git a/deploy/nginx/inventory.openvibe.network.conf b/deploy/nginx/inventory.openvibe.network.conf
iindex 7a3fb80..3b810cd 100644
---- a/deploy/nginx/inventory.openvibe.network.conf
++++ b/deploy/nginx/inventory.openvibe.network.conf
@@@ -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;
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 5eb5c94..2d208c7 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -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);
ddiff --git a/server/app.js b/server/app.js
iindex b02b9d8..f6b780c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/auth/scoped-session.js b/server/auth/scoped-session.js
nnew file mode 100644
iindex 0000000..386c331
---- /dev/null
++++ b/server/auth/scoped-session.js
@@@ -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 };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex ba02070..e349f89 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -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 = () => Dat
res.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) => {
ddiff --git a/server/config.js b/server/config.js
iindex b516ace..eec8fb0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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() },
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a155163..e274141 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0e4c54a..0cbb05c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 something
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>
<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.repl
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.
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' ? htm
return { 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()));
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex f570f37..9314cc4 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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;
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 153c007..e26626f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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 too
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
-- ? 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));
}
ddiff --git a/server/search-index.js b/server/search-index.js
iindex 63c9303..07aace1 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -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),
ddiff --git a/server/urls.js b/server/urls.js
nnew file mode 100644
iindex 0000000..299c290
---- /dev/null
++++ b/server/urls.js
@@@ -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 };
ddiff --git a/server/workshop/pages.js b/server/workshop/pages.js
iindex 9f9b8fa..2aacf74 100644
---- a/server/workshop/pages.js
++++ b/server/workshop/pages.js
@@@ -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.med
function 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 c
robots: '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>
ddiff --git a/test/base-path.test.js b/test/base-path.test.js
nnew file mode 100644
iindex 0000000..16f91bd
---- /dev/null
++++ b/test/base-path.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex ce06c2f..c4a1138 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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 || {}) };