openvibers/openvibe.publishing Commits

1.4.1: seo.canonicalUrl keeps a path with several leading slashes on the site ("//feed/" threw 'path must stay on the origin', a 500 on News, Deals and Blog) (#56)

OP OpenVibers committed 22 days ago · d7ced1a0a7 · 7 files, +25 −9

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1318918..529893c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 1.4.1 — 2026-10-22
++
++**Fix: `seo.canonicalUrl` keeps a path with several leading slashes on the site.** A request path such as `//feed/` or
++`//wp-includes/wlwmanifest.xml` (scanners send them) was read as a protocol-relative URL naming another host, so
++`canonicalUrl` threw "path must stay on the origin" and News, Deals and Blog answered 500. A run of leading slashes
++(or backslashes) now counts as one: `canonicalUrl('https://openvibe.news', '//feed/')` is `https://openvibe.news/feed`.
++An absolute URL on another origin still throws. No export, table layout, reason code or document shape changes.
++
## 1.4.0 — 2026-10-10
**Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape
ddiff --git a/README.md b/README.md
iindex a1ae41e..b348c24 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
--**Status:** alpha, **1.4.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
++**Status:** alpha, **1.4.1**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
store gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest
@@@ -11,8 +11,9 @@ chassis (`openvibe-publishing/ingest`) and publication glue (`openvibe-publishin
additive, no existing export changed. v1.2.0 adds `openvibe-publishing/layout`, the page document the
seven sites render through `openvibe-shared/shell` (additive). v1.3.0 forwards the AI summary, facts,
`updated` and `url` through `layout.renderDocument` to the shell (additive). v1.4.0 adds `openvibe-publishing/search-feed`,
--which puts a record product's pages in OpenVibe.Search (additive). The exit proof (`examples/two-products`) runs in `npm test`.
--Releases v0.1.0 to v1.4.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). All seven publication products pin v1.3.0
++which puts a record product's pages in OpenVibe.Search (additive). v1.4.1 keeps a request path that starts with
++several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a 500 on News, Deals and Blog). The exit proof (`examples/two-products`) runs in `npm test`.
++Releases v0.1.0 to v1.4.1 are tagged (see [CHANGELOG.md](CHANGELOG.md)). All seven publication products pin v1.3.0
on PostgreSQL today, and all seven are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,
openvibe.deals, openvibe.coupons and openvibe.trade. Seven record products pin v1.4.0 for the search feed: Work, Rent,
Help, Inventory, Quest, Food and Host. Each adopts a release by pinning its tag.
ddiff --git a/STATUS.json b/STATUS.json
iindex c292df6..09419d4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"wave": "W15",
"kind": "packages",
"package": "openvibe-publishing",
-- "version": "1.4.0",
++ "version": "1.4.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"port": null,
@@@ -68,5 +68,5 @@
"the publication glue: gate → document → sequencer.stamp → events → outbox.enqueue on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping",
"the page document through openvibe-shared/shell page(): robots and canonical from the gate's decision, article times, prev/next, feeds, app icon, stylesheets, boost marker and the footer init, plus the AI summary (ai-summary meta, WebPage JSON-LD, facts block) when the caller passes summary/facts/updated/url"
],
-- "updated": "2026-10-21"
++ "updated": "2026-10-22"
}
ddiff --git a/lib/seo.js b/lib/seo.js
iindex 794661c..cbd7d9f 100644
---- a/lib/seo.js
++++ b/lib/seo.js
@@@ -179,7 +179,10 @@ function canonicalUrl(origin, path = '/', { query = [] } = {}) {
if (Array.isArray(path)) {
pathname = '/' + path.filter((s) => s != null && s !== '').map((s) => encodeURIComponent(String(s))).join('/');
} else {
-- const u = new URL(String(path || '/'), base.origin);
++ // A request path can start with several slashes ("//feed/" from scanners); read as a URL it would name another
++ // host, so a run of leading slashes counts as one and the path stays on the origin.
++ const raw = String(path || '/');
++ const u = new URL(/^[\\/]{2,}/.test(raw) ? raw.replace(/^[\\/]+/, '/') : raw, base.origin);
if (u.origin !== base.origin) throw new TypeError('path must stay on the origin');
pathname = u.pathname;
const keep = [...u.searchParams.entries()].filter(([k]) => query.includes(k)).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
ddiff --git a/package-lock.json b/package-lock.json
iindex 06f794b..5707be8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-publishing",
-- "version": "1.4.0",
++ "version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "1.4.0",
++ "version": "1.4.1",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 5b0c784..b885f30 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-publishing",
-- "version": "1.4.0",
++ "version": "1.4.1",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"main": "index.js",
"exports": {
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 9f912a5..cfbcc73 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -99,6 +99,10 @@ test('canonicalUrl', () => {
assert.strictEqual(seo.canonicalUrl('https://openvibe.blog/', '/alex/posts/bread/?utm_source=x&page=2#top', { query: ['page'] }), 'https://openvibe.blog/alex/posts/bread?page=2');
assert.strictEqual(seo.canonicalUrl('https://openvibe.blog', '/'), 'https://openvibe.blog/');
assert.throws(() => seo.canonicalUrl('https://openvibe.blog', 'https://evil.test/x'), /origin/);
++ // A request path with leading slashes is a path on this site, not a protocol-relative host (it was a 500 on News).
++ assert.strictEqual(seo.canonicalUrl('https://openvibe.news', '//feed/'), 'https://openvibe.news/feed');
++ assert.strictEqual(seo.canonicalUrl('https://openvibe.news', '//evil.test/wp-includes/x.xml'), 'https://openvibe.news/evil.test/wp-includes/x.xml');
++ assert.strictEqual(seo.canonicalUrl('https://openvibe.news', '/\\evil.test/x'), 'https://openvibe.news/evil.test/x');
assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);
});