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)
Changes
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://sbreaking change to any exported function, table layout, reason code or document shape is a newmajor (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@@@ -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 datalayer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method isasync, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; eachstore 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-publishinadditive, no existing export changed. v1.2.0 adds `openvibe-publishing/layout`, the page document theseven 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.0on 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.@@@ -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"}@@@ -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));@@@ -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",@@@ -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": {@@@ -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/);});
@@ -4,6 +4,14 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -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
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.
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
## 1.4.0 — 2026-10-10
**Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape
**Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
> 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
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
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
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
@@ -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
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,
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`,
`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,
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,
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.
Help, Inventory, Quest, Food and Host. Each adopts a release by pinning its tag.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "1.4.0",
+ "version": "1.4.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"runtime": null,
"port": null,
"port": null,
@@ -68,5 +68,5 @@
@@ -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 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"
"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"
}
}
@@ -179,7 +179,10 @@ function canonicalUrl(origin, path = '/', { query = [] } = {}) {
@@ -179,7 +179,10 @@ function canonicalUrl(origin, path = '/', { query = [] } = {}) {
if (Array.isArray(path)) {
if (Array.isArray(path)) {
pathname = '/' + path.filter((s) => s != null && s !== '').map((s) => encodeURIComponent(String(s))).join('/');
pathname = '/' + path.filter((s) => s != null && s !== '').map((s) => encodeURIComponent(String(s))).join('/');
} else {
} 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');
if (u.origin !== base.origin) throw new TypeError('path must stay on the origin');
pathname = u.pathname;
pathname = u.pathname;
const keep = [...u.searchParams.entries()].filter(([k]) => query.includes(k)).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
const keep = [...u.searchParams.entries()].filter(([k]) => query.includes(k)).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.4.0",
+ "version": "1.4.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.4.0",
+ "version": "1.4.1",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"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.",
"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",
"main": "index.js",
"exports": {
"exports": {
@@ -99,6 +99,10 @@ test('canonicalUrl', () => {
@@ -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/', '/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.strictEqual(seo.canonicalUrl('https://openvibe.blog', '/'), 'https://openvibe.blog/');
assert.throws(() => seo.canonicalUrl('https://openvibe.blog', 'https://evil.test/x'), /origin/);
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/);
assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);
});
});