openvibers/openvibe.publishing Commits
openvibe-publishing 0.1.0: shared publishing packages (revisions, schedule, taxonomy, citations, media, discussion, seo gate, authorship, index hooks, ssr) with the two-products exit proof
Changes
nnew file mode 100644@@@ -0,0 +1,32 @@++name: ci++on:++ push:++ branches: [main]++ tags: ['v*']++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # the consuming services run Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # The release artifact is GitHub's tarball of the tag (repository root = package root):++ # install one built the same way into an empty project and load every entry point by name.++ - name: install from a tag-style tarball++ run: |++ git archive --format=tar.gz --prefix=OpenVibe.Publishing-ci/ -o "$RUNNER_TEMP/openvibe-publishing.tgz" HEAD++ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null++ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-publishing.tgz" better-sqlite3@11++ node -e "++ const p = require('openvibe-publishing');++ for (const m of ['revisions','schedule','taxonomy','citations','media','discussion','seo','authorship','index-hooks','ssr']) require('openvibe-publishing/' + m);++ const Database = require('better-sqlite3');++ const db = new Database(':memory:');++ p.revisions.createRevisionStore(db, { prefix: 'ci' }).create({ entityId: 'e', expectedRevision: 0, content: 'x' });++ console.log('ok', require('openvibe-publishing/package.json').version);++ "nnew file mode 100644@@@ -0,0 +1,5 @@++node_modules/++*.db++*.db-shm++*.db-wal++.tmp/nnew file mode 100644@@@ -0,0 +1,34 @@++# Changelog++++All notable changes to `openvibe-publishing`. Versions follow [semver](https://semver.org/): a++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.++++## 0.1.0 — 2026-09-22++++First version (roadmap Wave 15). Packages only: no runtime, no domain, no database.++++### Added++- `revisions`: drafts, immutable revisions with parent pointers (UPDATE/DELETE blocked by SQLite++ triggers), optimistic concurrency with a 412 `revision.conflict`, line and word diffs (Myers, with++ a bounded fallback), revert as a new revision, audited `purgeEntity`.++- `schedule`: idempotent publish/unpublish jobs with leases; re-run safe after a worker crash;++ retry with backoff; abandoned after `maxAttempts`; injectable clock.++- `taxonomy`: slugs, get-or-create terms per vocabulary, hierarchical categories, entity links.++- `citations`: append-only source references per revision (Sources item id / URL, retrieved_at,++ quote span, license note), `carryForward`, lookups by source item.++- `media`: attachments by Media object id with `unverified | available | broken` states and an++ explicit broken placeholder in `figureHtml`.++- `discussion`: Community thread resolution (`POST /api/v1/comments/threads/resolve`) with a++ service token; stores thread ids only.++- `seo`: the deterministic indexability gate (19 stable reason codes, `hidden`/`noindex` effects),++ canonical URL builder, history-aware redirects, meta/robots tags, sitemaps, RSS 2.0, Atom 1.0,++ JSON Feed 1.1, JSON-LD builders (Article/BlogPosting/NewsArticle, Review, Product/Offer,++ AggregateRating, BreadcrumbList, WebPage) that omit anything not provided.++- `authorship`: human / ai / hybrid / imported records with AI workflow + run references,++ disclosure labels, append-only human review log; AI content starts as draft + noindex.++- `index-hooks`: Search index documents and tombstones, `actionFor` transitions, and++ `<product>.<type>.<action>` event envelopes valid against `events.event-envelope@1`.++- `ssr`: escaping templates, safe Markdown subset, pagination, breadcrumbs, diff markup, `<time>`.++- `examples/two-products`: a mini wiki and a mini blog with separate databases (the exit proof).++- Proposed contract `docs/contracts-proposal/search.index-document.v1.json`.nnew file mode 100644@@@ -0,0 +1,21 @@++MIT License++++Copyright (c) 2026 OpenVibers++++Permission is hereby granted, free of charge, to any person obtaining a copy++of this software and associated documentation files (the "Software"), to deal++in the Software without restriction, including without limitation the rights++to use, copy, modify, merge, publish, distribute, sublicense, and/or sell++copies of the Software, and to permit persons to whom the Software is++furnished to do so, subject to the following conditions:++++The above copyright notice and this permission notice shall be included in all++copies or substantial portions of the Software.++++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,++OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE++SOFTWARE.nnew file mode 100644@@@ -0,0 +1,138 @@++# OpenVibe.Publishing++++> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,++> Deals, Coupons and Trade.++++**Status:** alpha, 0.1.0 (roadmap Wave 15). The modules are tested and the exit proof++(`examples/two-products`) runs in `npm test`; not tagged or released yet. No product uses them in production yet: Wiki and Blog (Wave 16) are the++first planned consumers.++**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).++**License:** MIT, like OpenVibe.Shared.++++## The rule: packages, not an authority++++This repository is **code, never state**. It has no runtime, no port, no domain, no database and++no API of its own, and it must never get one. Each product (Wiki, Blog, News, …) owns its++publication state in its own database; these modules take the product's `better-sqlite3` handle++and a table prefix, and create `<prefix>_…` tables *inside that product's database*. Two products++share the code, not the data: `wiki.db` holds `wiki_*` tables, `blog.db` holds `blog_*` tables,++and nothing is shared between them (proved by [test/two-products.test.js](test/two-products.test.js)).++++Following the plan (§12.4, §31.1, §31.5): share *how* something is done (revisions, the gate,++feeds, indexing); never share *what is true* (which revision is live, who may read it, editorial++rules). A generic `OpenVibe.Content` authority is explicitly not what this is.++++## Module map++++Every module is its own entry point and can be used alone.++++| Entry point | What it does | Tables (in the consumer's DB) |++|---|---|---|++| `openvibe-publishing/revisions` | Drafts, immutable revisions with parent pointers, optimistic concurrency (`expectedRevision` → 412 `revision.conflict`), line/word diff, revert as a new revision, audited purge | `<prefix>_revisions`, `<prefix>_drafts`, `<prefix>_revision_purges` |++| `openvibe-publishing/schedule` | Scheduled publish/unpublish jobs: idempotent `schedule()`, lease-based `claim()`, re-run safe across worker restarts, retries with backoff, injectable clock | `<prefix>_schedule_jobs` |++| `openvibe-publishing/taxonomy` | Tags and terms with slugs, hierarchical categories (ancestors for breadcrumbs, descendants, trees, cycle checks) | `<prefix>_terms`, `<prefix>_term_links` |++| `openvibe-publishing/citations` | Source references on one revision: Sources item id and/or URL, `retrieved_at`, quote span, license note; append-only, never dropped by later revisions; `carryForward` to reuse | `<prefix>_citations`, `<prefix>_citation_purges` |++| `openvibe-publishing/media` | Attachments by Media object id (`med_…` / `legacy:…`), explicit `broken` state when the object is gone, `verify()` that never flips state on an outage, `<figure>` rendering with an honest placeholder | `<prefix>_attachments` |++| `openvibe-publishing/discussion` | Community comment-thread references: resolves via `POST /api/v1/comments/threads/resolve`, stores the thread id only (no comment content) | `<prefix>_discussion_refs` |++| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |++| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |++| `openvibe-publishing/index-hooks` | Search index documents (and tombstones) from a published revision; `<product>.<type>.published\|updated\|unpublished\|deleted` event envelopes for the product's outbox | — |++| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |++++`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).++++## Install++++Pin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):++++```json++"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.1.0"++```++++`better-sqlite3` (≥ 11) is a peer dependency: the product brings its own handle. The only runtime++dependency is `openvibe-shared` (v1.0.0), whose `seo` module is the network's single implementation++of head tags, sitemap XML, robots.txt and JSON-LD escaping; this package adds the publication rules on top.++++## The indexability gate++++```js++const seo = require('openvibe-publishing/seo');++const decision = seo.evaluate(++ { state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 2, authorship: { mode: 'ai', reviewed: false } },++ { policy: { minWords: 150, requireSources: true }, now: Date.now() },++);++// → { indexable: false, listable: false, robots: 'noindex, nofollow', codes: ['ai_generated_unreviewed'], reasons: [...], canonical, gate }++```++++A pure function of (facts, policy, now): the same input always gives the same decision. Every "no"++carries stable reason codes, in a fixed order (`seo.REASONS`):++++| Effect | Codes |++|---|---|++| **hidden** — never indexed, never in sitemaps, feeds or public search | `deleted`, `takedown`, `private`, `gated`, `unlisted`, `draft` (draft or scheduled), `unpublished`, `ai_generated_unreviewed`, `stub_provider`, `unreviewed_sensitive` |++| **noindex** — may be served and listed in feeds, never indexed or put in a sitemap | `retracted`, `expired`, `stale_price`, `duplicate_of` (canonical points at the original), `thin`, `unsourced`, `unsupported_claims`, `missing_canonical`, `noindex_requested` |++++There is no default that makes content indexable: unknown fact names throw, a rule that needs a fact++the caller did not give throws, and time rules need an explicit `now` (no hidden clock). Each product++sets its own policy (`minWords`, `requireSources`, `minSources`, `priceMaxAgeMs`, `sensitiveCategories`).++`metaTags`, `robotsMeta`, `sitemap`, the three feed builders and `buildIndexDocument` (for a++published document) all require the decision, so an object without one cannot reach a crawler by omission.++++## Honesty guarantees (tested)++++- **Structured data** is generated only from provided fields. A missing rating, price, currency,++ date or author is omitted, never defaulted (no `bestRating: 5`, no assumed `InStock`, no++ "now" as a date, no placeholder author). An `AggregateRating` needs a real count; an `Offer` needs a++ price and a currency. [test/seo.test.js](test/seo.test.js) checks every output leaf against the input.++- **Feeds and sitemaps** never invent `pubDate`, `lastmod` or authors; an Atom entry without any date++ is left out rather than dated "now".++- **Citations** never get a retrieval time they did not have, and cannot be edited or deleted++ (SQLite triggers) except by an audited purge.++- **Media** outages are `check_failed`, not "gone" and not "fine".++- **Discussion** failures are errors, never a fabricated thread.++- **AI content** starts as draft + noindex: `authorship.canPublish()` refuses it and the gate hides it++ (`ai_generated_unreviewed`) until a person (a `usr_` subject, never a service) records an approving++ review. Products must call these; the package cannot stop a product that bypasses its own checks.++++## Events and Search++++`index-hooks.buildIndexDocument()` produces the Search document ([REF] §12.3 fields plus the gate++decision) or a tombstone; the proposed schema is++[docs/contracts-proposal/search.index-document.v1.json](docs/contracts-proposal/search.index-document.v1.json).++`publicationEvent()` wraps it in an `events.event-envelope@1` envelope with `event_type`++`<product>.<type>.<action>` and no `event_id` (the OpenVibe.Events outbox assigns one); events about++private, gated or unlisted content are `internal`. Enqueue it with the Events outbox in the same++transaction as the product's state change.++++## Exit proof: two products++++[examples/two-products](examples/two-products) holds a mini wiki and a mini blog, each with its own++SQLite file and its own publication tables, both using revisions, citations, the gate and feeds with++different editorial policies. `node examples/two-products/wiki/app.js` (port 4801) and++`node examples/two-products/blog/app.js` (port 4811) serve them on temp databases.++[test/two-products.test.js](test/two-products.test.js) shows they load the same modules, that each++database holds only its own product's tables, that writes never cross, and that pages are useful++without JavaScript, old slugs 301, private/VIP/deleted content leaves feeds and sitemaps, and++scheduled publication survives a worker restart.++++## Development++++```bash++fnm exec --using=22.22.1 npm install++fnm exec --using=22.22.1 npm test # every test/*.test.js, temp databases, no network++```++++## What it does not do++++- It does not own publication state, run workers, listen on a port, or talk to a database it was not handed.++- It does not decide editorial rules: each product passes its own gate policy and its own permission checks.++- It does not store or render comment content (Community owns it), media bytes (Media owns them) or++ source items (Sources owns them); it stores references.++- It is not a notification, search or event runtime: it builds the documents and envelopes those services consume.++++## Depends on++++- `openvibe-shared` v1.0.0 (`seo`), at runtime.++- `better-sqlite3` ≥ 11, supplied by the consumer.++- Contracts it produces for: `common.entity-ref@1`, `media.media-ref@1`, `events.event-envelope@1`++ (validated in tests against `openvibe-contracts` v0.7.0), and the proposed `search.index-document@1`.nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "repository": "OpenVibers/OpenVibe.Publishing",++ "stage": "alpha",++ "code": true,++ "wave": "W15",++ "kind": "packages",++ "package": "openvibe-publishing",++ "version": "0.1.0",++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",++ "runtime": null,++ "port": null,++ "domain": null,++ "database": null,++ "modules": ["revisions", "schedule", "taxonomy", "citations", "media", "discussion", "seo", "authorship", "index-hooks", "ssr"],++ "consumers": [],++ "plannedConsumers": ["OpenVibe.Wiki", "OpenVibe.Blog", "OpenVibe.News", "OpenVibe.Reviews", "OpenVibe.Deals", "OpenVibe.Coupons", "OpenVibe.Trade"],++ "exitProof": "examples/two-products + test/two-products.test.js",++ "contracts": "openvibe-contracts v0.7.0 (tests); proposes search.index-document@1",++ "released": false,++ "updated": "2026-09-22"++}nnew file mode 100644@@@ -0,0 +1,113 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/search/index-document.v1.json",++ "title": "SearchIndexDocument",++ "description": "PROPOSAL (openvibe-publishing 0.1.0, for OpenVibe.Contracts and OpenVibe.Search). The document a publication product sends to Search for one revision of one resource, or the tombstone that removes it. Owner, resource, revision, visibility + ACL, canonical URL, title/summary/body, facets, provenance, publication state, deletion marker ([REF] §12.3), plus the publishing SEO gate's indexability decision. Search must never return a document to a query that fails its ACL, and must not list a document whose indexability.listable is false on public surfaces.",++ "oneOf": [++ {++ "title": "IndexDocument",++ "type": "object",++ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "visibility", "acl", "canonical_url", "title", "summary", "body", "facets", "provenance", "publication_state", "deleted", "indexability", "published_at", "updated_at"],++ "additionalProperties": false,++ "properties": {++ "schema": { "const": "search.index-document@1" },++ "owner_service": { "$ref": "#/$defs/service" },++ "resource_type": { "$ref": "#/$defs/segment" },++ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },++ "revision": { "type": "integer", "minimum": 0 },++ "visibility": { "enum": ["public", "private", "gated"] },++ "acl": {++ "oneOf": [++ { "type": "object", "required": ["public"], "additionalProperties": false, "properties": { "public": { "const": true } } },++ {++ "type": "object", "required": ["public"], "additionalProperties": false,++ "properties": {++ "public": { "const": false },++ "subjects": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^(usr|gst|app|mod)_[0-9A-HJKMNP-TV-Z]{26}$" } },++ "entitlements": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }++ },++ "anyOf": [{ "required": ["subjects"] }, { "required": ["entitlements"] }]++ }++ ]++ },++ "canonical_url": { "type": "string", "pattern": "^https?://" },++ "title": { "type": "string", "minLength": 1 },++ "summary": { "type": ["string", "null"] },++ "body": { "type": "string", "maxLength": 100000 },++ "body_truncated": { "const": true },++ "locale": { "type": "string" },++ "facets": {++ "type": "object",++ "propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,39}$" },++ "additionalProperties": {++ "anyOf": [++ { "type": ["string", "number", "boolean"] },++ { "type": "array", "items": { "type": ["string", "number", "boolean"] } }++ ]++ }++ },++ "provenance": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "authorship": {++ "type": "object", "required": ["mode"], "additionalProperties": false,++ "properties": {++ "mode": { "enum": ["human", "ai", "hybrid", "imported"] },++ "reviewed": { "type": "boolean" },++ "workflow": { "type": "object", "required": ["id", "run_id"], "additionalProperties": false, "properties": { "id": { "type": "string" }, "run_id": { "type": "string" } } }++ }++ },++ "citations": {++ "type": "array",++ "items": {++ "type": "object", "additionalProperties": false,++ "properties": {++ "source_item_id": { "type": "string" },++ "url": { "type": "string" },++ "retrieved_at": { "type": "string", "format": "date-time" },++ "license_note": { "type": "string" }++ },++ "anyOf": [{ "required": ["source_item_id"] }, { "required": ["url"] }]++ }++ },++ "derived_from": { "type": "object" }++ }++ },++ "publication_state": { "const": "published" },++ "deleted": { "const": false },++ "indexability": {++ "type": "object", "required": ["indexable", "listable", "reasons", "gate"], "additionalProperties": false,++ "properties": {++ "indexable": { "type": "boolean" },++ "listable": { "type": "boolean" },++ "reasons": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z_]*$" } },++ "gate": { "type": ["string", "null"] }++ }++ },++ "published_at": { "type": ["string", "null"], "format": "date-time" },++ "updated_at": { "type": ["string", "null"], "format": "date-time" }++ }++ },++ {++ "title": "IndexTombstone",++ "type": "object",++ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "publication_state", "deleted", "updated_at"],++ "additionalProperties": false,++ "properties": {++ "schema": { "const": "search.index-document@1" },++ "owner_service": { "$ref": "#/$defs/service" },++ "resource_type": { "$ref": "#/$defs/segment" },++ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },++ "revision": { "type": "integer", "minimum": 0 },++ "publication_state": { "enum": ["draft", "scheduled", "published", "unpublished", "deleted"] },++ "deleted": { "const": true },++ "updated_at": { "type": ["string", "null"], "format": "date-time" }++ }++ }++ ],++ "$defs": {++ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },++ "segment": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,39}$" }++ }++}nnew file mode 100644@@@ -0,0 +1,22 @@++# Two products, one package, two databases++++The Wave 15 exit proof. `wiki/app.js` and `blog/app.js` are deliberately small products that++import `openvibe-publishing/*` exactly as a real product would, each opening its **own** SQLite file:++++| | Mini wiki | Mini blog |++|---|---|---|++| Database | `wiki.db` — only `wiki_*` tables | `blog.db` — only `blog_*` tables |++| Publication state | `wiki_pages` (owned by the app) | `blog_posts` (owned by the app) |++| Revisions | `wiki_page_revisions` | `blog_post_revisions` |++| Citations | `wiki_citations` (sources required) | `blog_post_citations` (optional) |++| Gate policy | `minWords 30`, `requireSources` | `minWords 20` |++| Feeds | Atom + JSON Feed | RSS + JSON Feed |++| Also | redirects, AI authorship + review, index events | taxonomy, scheduling, media attachments |++++```bash++node examples/two-products/wiki/app.js # http://127.0.0.1:4801/p/rye++node examples/two-products/blog/app.js # http://127.0.0.1:4811/posts/first-loaf++```++++Both use temp databases and made-up sample text. The assertions live in++[../../test/two-products.test.js](../../test/two-products.test.js).nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * Mini blog — the second example consumer of openvibe-publishing, with its own SQLite file and its++ * own publication state (blog_posts). Different editorial rules from the wiki (sources optional,++ * shorter minimum), same shared mechanics.++ *++ * node examples/two-products/blog/app.js # http://127.0.0.1:4811 with a temp database++ *++ * Uses: revisions (blog_post_*), citations (blog_post_citations), taxonomy (blog_terms), schedule++ * (blog_schedule_jobs), media attachments (blog_post_attachments), seo gate + sitemap + RSS/JSON++ * Feed, ssr.++ */++const http = require('http');++const path = require('path');++const os = require('os');++const fs = require('fs');++const Database = require('better-sqlite3');++const { createRevisionStore } = require('openvibe-publishing/revisions');++const { createCitationStore } = require('openvibe-publishing/citations');++const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');++const { createScheduler } = require('openvibe-publishing/schedule');++const { createAttachmentStore, figureHtml } = require('openvibe-publishing/media');++const seo = require('openvibe-publishing/seo');++const ssr = require('openvibe-publishing/ssr');++++const POLICY = { minWords: 20, requireSources: false };++++function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'https://openvibe.media', now = () => Date.now() } = {}) {++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.exec(`++ CREATE TABLE IF NOT EXISTS blog_posts (++ id TEXT PRIMARY KEY,++ slug TEXT NOT NULL UNIQUE,++ author_name TEXT,++ visibility TEXT NOT NULL DEFAULT 'public',++ state TEXT NOT NULL DEFAULT 'draft',++ published_revision INTEGER,++ published_at INTEGER,++ updated_at INTEGER NOT NULL++ );++ `);++ const revisions = createRevisionStore(db, { prefix: 'blog_post', now });++ const citations = createCitationStore(db, { prefix: 'blog_post', now, revisions });++ const taxonomy = createTaxonomy(db, { prefix: 'blog', now });++ const scheduler = createScheduler(db, { prefix: 'blog', now, leaseMs: 60000 });++ const media = createAttachmentStore(db, { prefix: 'blog_post', now });++++ const urlOf = (slug) => seo.canonicalUrl(origin, ['posts', slug]);++ const getPost = (id) => db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);++++ function decide(post, rev) {++ return seo.evaluate({++ state: post.state, visibility: post.visibility, canonicalUrl: urlOf(post.slug),++ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(post.id, rev.number).length,++ }, { policy: POLICY, now: now() });++ }++++ /** The idempotent effect a scheduled job applies: "revision N is the published one". */++ function applyPublish(id, revision) {++ const info = db.prepare("UPDATE blog_posts SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ? AND (state IS NOT 'published' OR published_revision IS NOT ?)")++ .run(revision, now(), now(), id, revision);++ return { changed: info.changes };++ }++++ const api = {++ db, revisions, citations, taxonomy, scheduler, media, policy: POLICY,++++ draft({ title, body, authorName = null, tags = [], sources = [] }) {++ return db.transaction(() => {++ const slug = slugify(title);++ const id = `post_${slug}`;++ db.prepare('INSERT INTO blog_posts (id, slug, author_name, updated_at) VALUES (?, ?, ?, ?)').run(id, slug, authorName, now());++ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title } });++ citations.attachMany(id, revision.number, sources);++ taxonomy.setTerms(id, 'tag', tags);++ return { id, revision: revision.number };++ })();++ },++++ edit(id, { expectedRevision, body, title }) {++ const head = revisions.head(id);++ return revisions.create({ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title } }).revision.number;++ },++++ publishNow(id, revision) { return applyPublish(id, revision); },++ schedulePublish(id, { revision, at }) { return scheduler.schedule({ entityId: id, action: 'publish', runAt: at, revision }); },++ runScheduled(worker) {++ return scheduler.runDue({ worker, handler: (job) => (job.action === 'publish' ? applyPublish(job.entityId, job.revision) : null) });++ },++ setVisibility(id, visibility) { db.prepare('UPDATE blog_posts SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id); },++++ published() {++ return db.prepare("SELECT * FROM blog_posts WHERE state = 'published' ORDER BY published_at DESC, id").all().map((post) => {++ const rev = revisions.get(post.id, post.published_revision);++ return { post, rev, decision: decide(post, rev) };++ });++ },++++ feedItems() {++ return api.published().map(({ post, rev, decision }) => ({++ id: `tag:openvibe.blog,2026:post/${post.id}`, url: urlOf(post.slug), title: rev.fields.title,++ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content),++ published: post.published_at, updated: rev.createdAt, decision,++ authors: post.author_name ? [{ name: post.author_name }] : [],++ tags: taxonomy.termsFor(post.id, 'tag').map((t) => t.name),++ }));++ },++ rss() { return seo.rssFeed({ title: 'Mini blog', link: `${origin}/`, description: 'Posts from the mini blog example', feedUrl: `${origin}/feed.xml` }, api.feedItems()); },++ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini blog', link: `${origin}/`, feedUrl: `${origin}/feed.json` }, api.feedItems()); },++ sitemapXml() { return seo.sitemap(api.published().map(({ post, rev, decision }) => ({ loc: urlOf(post.slug), lastmod: rev.createdAt, decision }))).files[0]; },++++ renderPost(post) {++ const rev = revisions.get(post.id, post.published_revision);++ const decision = decide(post, rev);++ const figures = media.list(post.id).map((a) => ssr.raw(figureHtml(a, { urlFor: (id) => `${mediaOrigin}/o/${id}` })));++ const tags = taxonomy.termsFor(post.id, 'tag');++ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',++ jsonLd: seo.structuredData.article({ type: 'BlogPosting', headline: rev.fields.title, url: urlOf(post.slug),++ datePublished: post.published_at ? new Date(post.published_at).toISOString() : null, dateModified: rev.createdAt,++ authors: post.author_name ? [{ name: post.author_name }] : [], keywords: tags.map((t) => t.name) }) });++ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}${ssr.raw(seo.feedLinks([{ type: 'rss', href: '/feed.xml', title: 'RSS' }]))}</head><body>++<article><h1>${rev.fields.title}</h1><p>${post.author_name ? ssr.html`By ${post.author_name} · ` : ''}${ssr.raw(ssr.timeTag(post.published_at))}</p>++${figures}++${ssr.raw(ssr.renderMarkdown(rev.content))}++<p>${tags.map((t) => ssr.html`<a href="/tags/${t.slug}">#${t.name}</a> `)}</p></article></body></html>`);++ },++++ handler(req, res) {++ const url = new URL(req.url, origin);++ const send = (status, type, body) => { res.writeHead(status, { 'Content-Type': type }); res.end(body); };++ if (url.pathname === '/feed.xml') return send(200, 'application/rss+xml', api.rss());++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());++ const m = url.pathname.match(/^\/posts\/([^/]+)$/);++ const post = m && db.prepare('SELECT * FROM blog_posts WHERE slug = ?').get(decodeURIComponent(m[1]));++ if (!post || post.state !== 'published' || post.visibility === 'private' || post.visibility === 'gated') return send(404, 'text/plain', 'Not found');++ return send(200, 'text/html; charset=utf-8', api.renderPost(post));++ },++++ close() { db.close(); },++ };++ return api;++}++++module.exports = { createBlog };++++if (require.main === module) {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-blog-'));++ const blog = createBlog({ dbPath: path.join(dir, 'blog.db'), origin: 'http://127.0.0.1:4811' });++ const { id, revision } = blog.draft({ title: 'First loaf', authorName: 'Alex', tags: ['bread', 'rye'],++ body: 'My first rye loaf came out dense but tasty. Next time I will give the starter a longer rise and bake it a little hotter.' });++ blog.publishNow(id, revision);++ http.createServer(blog.handler).listen(4811, '127.0.0.1', () => console.log(`mini blog on http://127.0.0.1:4811/posts/first-loaf (db ${dir}/blog.db)`));++}nnew file mode 100644@@@ -0,0 +1,249 @@++'use strict';++/**++ * Mini wiki — an example consumer of openvibe-publishing. It owns its own SQLite file and its own++ * publication state (wiki_pages); the packages only supply the mechanics.++ *++ * node examples/two-products/wiki/app.js # http://127.0.0.1:4801 with a temp database++ *++ * Uses: revisions (wiki_page_*), citations (wiki_citations), seo gate + redirects + sitemap +++ * Atom/JSON feeds, authorship (AI pages start as drafts), index-hooks (events into wiki_outbox), ssr.++ */++const http = require('http');++const path = require('path');++const os = require('os');++const fs = require('fs');++const Database = require('better-sqlite3');++// The package's public entry points, exactly as a product would import them.++const { createRevisionStore } = require('openvibe-publishing/revisions');++const { createCitationStore } = require('openvibe-publishing/citations');++const seo = require('openvibe-publishing/seo');++const authorship = require('openvibe-publishing/authorship');++const hooks = require('openvibe-publishing/index-hooks');++const ssr = require('openvibe-publishing/ssr');++const { slugify } = require('openvibe-publishing/taxonomy');++++const POLICY = { minWords: 30, requireSources: true, minSources: 1 };++++function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date.now() } = {}) {++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.exec(`++ CREATE TABLE IF NOT EXISTS wiki_pages (++ id TEXT PRIMARY KEY,++ slug TEXT NOT NULL UNIQUE,++ owner TEXT NOT NULL,++ visibility TEXT NOT NULL DEFAULT 'public',++ state TEXT NOT NULL DEFAULT 'draft',++ published_revision INTEGER,++ published_at INTEGER,++ updated_at INTEGER NOT NULL++ );++ CREATE TABLE IF NOT EXISTS wiki_outbox (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ envelope TEXT NOT NULL++ );++ `);++ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });++ const citations = createCitationStore(db, { prefix: 'wiki', now, revisions });++ const redirects = seo.createRedirectStore(db, { prefix: 'wiki_page', now });++ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now });++++ const pathOf = (slug) => `/p/${slug}`;++ const urlOf = (slug) => seo.canonicalUrl(origin, pathOf(slug));++ const getPage = (id) => db.prepare('SELECT * FROM wiki_pages WHERE id = ?').get(id);++ const bySlug = (slug) => db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug);++++ function decide(page, rev) {++ const rec = rev.meta.authorship;++ return seo.evaluate({++ state: page.state, visibility: page.visibility, canonicalUrl: urlOf(page.slug),++ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(page.id, rev.number).length,++ ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),++ }, { policy: POLICY, now: now() });++ }++++ function document(page, rev, decision) {++ return hooks.buildIndexDocument({++ service: 'wiki', type: 'page', id: page.id, revision: rev ? rev.number : 0, state: page.state, visibility: page.visibility,++ canonicalUrl: urlOf(page.slug), title: rev ? rev.fields.title : page.slug, summary: rev ? ssr.markdownToText(rev.content, 160) : null,++ body: rev ? ssr.markdownToText(rev.content) : '', decision, acl: { subjects: [page.owner] },++ provenance: { authorship: rev && rev.meta.authorship, citations: rev ? citations.forRevision(page.id, rev.number) : [] },++ publishedAt: page.published_at, updatedAt: page.updated_at,++ });++ }++++ function emit(before, page, actor) {++ const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;++ const action = hooks.actionFor(before && { state: before.state, visibility: before.visibility, revision: before.published_revision },++ { state: page.state, visibility: page.visibility, revision: page.published_revision });++ if (!action) return null;++ const decision = rev ? decide(page, rev) : null;++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0, actor, document: document(page, rev, decision), decision, now: now() });++ db.prepare('INSERT INTO wiki_outbox (envelope) VALUES (?)').run(JSON.stringify(env));++ return env;++ }++++ const api = {++ db, revisions, citations, redirects, reviews, policy: POLICY,++++ createPage({ title, body, author, sources = [], authorship: rec = authorship.record({ mode: 'human', authors: [author] }) }) {++ return db.transaction(() => {++ const id = `pg_${slugify(title)}`;++ db.prepare('INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (?, ?, ?, ?)').run(id, slugify(title), author, now());++ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title }, meta: { authorship: rec }, author });++ citations.attachMany(id, revision.number, sources);++ return { id, revision: revision.number, initial: authorship.initialState(rec) };++ })();++ },++++ /** Edit: new revision; `keepSources` carries earlier citations forward, `sources` adds new ones. */++ edit(id, { expectedRevision, body, title, author, sources = [], keepSources = [] }) {++ return db.transaction(() => {++ const head = revisions.head(id);++ const { revision } = revisions.create({++ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title },++ meta: { authorship: authorship.record({ mode: 'human', authors: [author] }) }, author,++ });++ if (keepSources.length) citations.carryForward({ entityId: id, fromRevision: head.number, toRevision: revision.number, ids: keepSources });++ citations.attachMany(id, revision.number, sources);++ return revision.number;++ })();++ },++++ revert(id, { toRevision, expectedRevision, author }) {++ return db.transaction(() => {++ const { revision } = revisions.revert({ entityId: id, toRevision, expectedRevision, author });++ citations.carryForward({ entityId: id, fromRevision: toRevision, toRevision: revision.number });++ return revision.number;++ })();++ },++++ publish(id, { revision, actor }) {++ return db.transaction(() => {++ const before = getPage(id);++ const rev = revisions.get(id, revision);++ const rec = rev.meta.authorship;++ const ok = authorship.canPublish(rec, reviews.latest(id, revision));++ if (!ok.ok) throw Object.assign(new Error(`cannot publish: ${ok.reason}`), { status: 409, code: ok.reason });++ db.prepare("UPDATE wiki_pages SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?").run(revision, now(), now(), id);++ return emit(before, getPage(id), actor);++ })();++ },++++ setVisibility(id, visibility, { actor }) {++ return db.transaction(() => {++ const before = getPage(id);++ db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id);++ return emit(before, getPage(id), actor);++ })();++ },++++ rename(id, newTitle) {++ return db.transaction(() => {++ const page = getPage(id);++ const slug = slugify(newTitle);++ redirects.recordMove(id, pathOf(page.slug), pathOf(slug));++ db.prepare('UPDATE wiki_pages SET slug = ?, updated_at = ? WHERE id = ?').run(slug, now(), id);++ return slug;++ })();++ },++++ remove(id, { actor }) {++ return db.transaction(() => {++ const before = getPage(id);++ db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), id);++ return emit(before, getPage(id), actor);++ })();++ },++++ outbox() { return db.prepare('SELECT envelope FROM wiki_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)); },++++ /** Every page with its live revision and gate decision. */++ published() {++ return db.prepare("SELECT * FROM wiki_pages WHERE state = 'published' ORDER BY published_at DESC, id").all().map((page) => {++ const rev = revisions.get(page.id, page.published_revision);++ return { page, rev, decision: decide(page, rev) };++ });++ },++++ sitemapXml() {++ return seo.sitemap(api.published().map(({ page, rev, decision }) => ({ loc: urlOf(page.slug), lastmod: rev.createdAt, decision }))).files[0];++ },++++ feedItems() {++ return api.published().map(({ page, rev, decision }) => ({++ id: `tag:openvibe.wiki,2026:page/${page.id}`, url: urlOf(page.slug), title: rev.fields.title,++ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content, { rel: 'noopener' }),++ published: page.published_at, updated: rev.createdAt, decision,++ }));++ },++++ atom() {++ // Atom needs a feed-level <updated>: the last real change to any page (never "now").++ const last = db.prepare('SELECT MAX(updated_at) AS t FROM wiki_pages').get().t;++ return seo.atomFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.atom`, updated: last }, api.feedItems());++ },++ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.json` }, api.feedItems()); },++++ renderPage(page) {++ const rev = revisions.get(page.id, page.published_revision);++ const decision = decide(page, rev);++ const cites = citations.forRevision(page.id, rev.number);++ const disclosure = authorship.disclosure(rev.meta.authorship, reviews.latest(page.id, rev.number));++ const crumbs = [{ name: 'Wiki', url: `${origin}/` }, { name: rev.fields.title, url: urlOf(page.slug) }];++ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',++ jsonLd: [seo.structuredData.article({ headline: rev.fields.title, url: urlOf(page.slug), dateModified: rev.createdAt,++ datePublished: page.published_at ? new Date(page.published_at).toISOString() : null, citations: cites }), seo.structuredData.breadcrumbs(crumbs)].filter(Boolean) });++ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}</head><body>++${ssr.raw(ssr.breadcrumbsHtml(crumbs))}++<article><h1>${rev.fields.title}</h1>${disclosure ? ssr.html`<p class="disclosure">${disclosure.long}</p>` : ''}++${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}++<p>Revision ${rev.number}, ${ssr.raw(ssr.timeTag(rev.createdAt))} · <a href="${pathOf(page.slug)}/history">history</a></p>++<section><h2>Sources</h2><ol>${cites.map((c) => ssr.html`<li>${c.url ? ssr.html`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : c.sourceItemId}${c.retrievedAt ? ssr.html`, retrieved ${ssr.raw(ssr.timeTag(c.retrievedAt))}` : ''}${c.licenseNote ? ` (${c.licenseNote})` : ''}</li>`)}</ol></section>++</article></body></html>`);++ },++++ /** Node http handler: pages, 301/410 history, sitemap, feeds. No JavaScript anywhere. */++ handler(req, res) {++ const url = new URL(req.url, origin);++ const send = (status, type, body, headers = {}) => { res.writeHead(status, { 'Content-Type': type, ...headers }); res.end(body); };++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());++ if (url.pathname === '/feed.atom') return send(200, 'application/atom+xml', api.atom());++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));++ if (url.pathname === '/robots.txt') return send(200, 'text/plain', seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] }));++ const m = url.pathname.match(/^\/p\/([^/]+)(\/history)?$/);++ if (!m) return send(404, 'text/plain', 'Not found');++ const page = bySlug(decodeURIComponent(m[1]));++ if (!page) {++ const r = redirects.resolve(url.pathname.replace(/\/history$/, ''), { currentPath: (id) => { const p = getPage(id); return p && p.state !== 'deleted' ? pathOf(p.slug) : null; } });++ if (r && r.status === 301) return send(301, 'text/plain', 'Moved', { Location: r.location + (m[2] || '') });++ if (r && r.status === 410) return send(410, 'text/plain', 'Gone');++ return send(404, 'text/plain', 'Not found');++ }++ if (page.state === 'deleted') return send(410, 'text/plain', 'Gone');++ if (page.state !== 'published' || page.visibility === 'private' || page.visibility === 'gated') return send(404, 'text/plain', 'Not found');++ if (m[2]) {++ const list = revisions.list(page.id).map((r) => ssr.html`<li>r${r.number} ${r.kind} ${ssr.raw(ssr.timeTag(r.createdAt))}</li>`);++ return send(200, 'text/html; charset=utf-8', String(ssr.html`<!doctype html><title>History</title><meta name="robots" content="noindex"><ol>${list}</ol>`));++ }++ return send(200, 'text/html; charset=utf-8', api.renderPage(page));++ },++++ close() { db.close(); },++ };++ return api;++}++++module.exports = { createWiki };++++if (require.main === module) {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-wiki-'));++ const wiki = createWiki({ dbPath: path.join(dir, 'wiki.db'), origin: 'http://127.0.0.1:4801' });++ const { id } = wiki.createPage({++ title: 'Rye', author: 'usr_01J8Z6Q3KX0000000000000000',++ body: 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.',++ sources: [{ url: 'https://en.wikipedia.org/wiki/Rye', title: 'Rye — Wikipedia', licenseNote: 'CC BY-SA 4.0' }],++ });++ wiki.publish(id, { revision: 1, actor: 'usr_01J8Z6Q3KX0000000000000000' });++ http.createServer(wiki.handler).listen(4801, '127.0.0.1', () => console.log(`mini wiki on http://127.0.0.1:4801/p/rye (db ${dir}/wiki.db)`));++}nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++/**++ * openvibe-publishing — every module is also its own entry point and can be used alone:++ * require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,++ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr++ * This root export loads each one lazily, on first access.++ */++const MODULES = {++ revisions: './lib/revisions',++ schedule: './lib/schedule',++ taxonomy: './lib/taxonomy',++ citations: './lib/citations',++ media: './lib/media',++ discussion: './lib/discussion',++ seo: './lib/seo',++ authorship: './lib/authorship',++ indexHooks: './lib/index-hooks',++ ssr: './lib/ssr',++};++++const api = {};++for (const [name, path] of Object.entries(MODULES)) {++ Object.defineProperty(api, name, { enumerable: true, get: () => require(path) });++}++Object.defineProperty(api, 'PublishingError', { enumerable: true, get: () => require('./lib/internal').PublishingError });++Object.defineProperty(api, 'version', { enumerable: true, value: require('./package.json').version });++++module.exports = api;nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * openvibe-publishing/authorship — who made a revision (human | ai | hybrid | imported), which++ * OpenVibe.AI workflow run produced it, the disclosure label, and the human review that lets++ * AI-generated content leave draft/noindex.++ *++ * const authorship = require('openvibe-publishing/authorship');++ * const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 3, runId: 'run_…' }, source: { label: 'Sources item src_…' } });++ * authorship.initialState(rec) // → { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' }++ * authorship.disclosure(rec).short // → 'AI-generated'++ * const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page' }); // wiki_page_reviews++ * reviews.record({ entityId, revision: 4, reviewer: 'usr_…', decision: 'approved' });++ * authorship.gateFacts(rec, reviews.latest(entityId, 4)) // → { authorship: { mode: 'ai', reviewed: true } }++ *++ * Modes (§15.12 names in brackets): human; ai [AI-generated]; hybrid [AI-assisted: a person wrote or++ * cut it from an AI suggestion]; imported [legacy/imported, keeps the original author].++ * Rules:++ * - ai and hybrid records must name the AI workflow and run (workflow.id + workflow.runId).++ * - human records must not carry a workflow (that would hide AI involvement the other way round).++ * - AI-generated content starts as draft + noindex; only a review by a person (a usr_ subject,++ * never a service or the AI itself) marks it reviewed.++ * - The record is metadata the product stores with the revision (revisions `meta.authorship`);++ * reviews are append-only rows, so the revision itself stays immutable.++ * Authorship is data, not a sentence: surfaces render the disclosure from this record.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber } = require('./internal');++++const MODES = ['human', 'ai', 'hybrid', 'imported'];++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;++const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;++++function record({ mode, authors = [], workflow = null, stubProvider = false, source = null, importedFrom = null } = {}) {++ if (!MODES.includes(mode)) throw new TypeError(`mode must be one of ${MODES.join(', ')}`);++ for (const a of authors) if (!SUBJECT_RE.test(String(a))) throw new TypeError('authors must be usr_/gst_ subject ids');++ let wf = null;++ if (workflow != null) {++ if (!workflow.id || !WORKFLOW_RE.test(workflow.id)) throw new TypeError('workflow.id must be an OpenVibe.AI workflow key like "wiki.generate_page"');++ if (!workflow.runId || typeof workflow.runId !== 'string') throw new TypeError('workflow.runId is required');++ wf = { id: workflow.id, runId: workflow.runId };++ if (workflow.version != null) wf.version = workflow.version;++ if (workflow.model != null) wf.model = String(workflow.model);++ }++ if ((mode === 'ai' || mode === 'hybrid') && !wf) throw new PublishingError(400, 'authorship.workflow_required', `${mode} authorship must reference the AI workflow and run`);++ if (mode === 'human' && wf) throw new PublishingError(400, 'authorship.workflow_on_human', 'human authorship cannot carry an AI workflow; use hybrid');++ if ((mode === 'human' || mode === 'hybrid') && !authors.length) throw new PublishingError(400, 'authorship.author_required', `${mode} authorship names the person accountable`);++ if (mode === 'imported' && !importedFrom) throw new PublishingError(400, 'authorship.import_source_required', 'imported authorship names where it came from');++ const out = { mode, authors: [...authors] };++ if (wf) out.workflow = wf;++ if (stubProvider) out.stubProvider = true;++ if (source) out.source = { label: source.label == null ? null : String(source.label), ref: source.ref || null };++ if (importedFrom) out.importedFrom = { label: String(importedFrom.label || ''), originalAuthor: importedFrom.originalAuthor == null ? null : String(importedFrom.originalAuthor) };++ return out;++}++++/** Only AI-generated content needs a review before it may be published and indexed. */++function needsReview(rec) { return rec.mode === 'ai' || Boolean(rec.stubProvider); }++++function isReviewed(rec, review) {++ return Boolean(review && review.decision === 'approved' && USER_RE.test(String(review.reviewer)));++}++++/** Where a new revision starts. AI-generated → draft + noindex until reviewed. */++function initialState(rec, review = null) {++ if (needsReview(rec) && !isReviewed(rec, review)) return { state: 'draft', noindex: true, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };++ return { state: 'draft', noindex: false, reason: null };++}++++/** { ok, reason } — whether this revision may be published now. */++function canPublish(rec, review = null) {++ if (needsReview(rec) && !isReviewed(rec, review)) return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };++ return { ok: true, reason: null };++}++++/** Facts for seo.evaluate: { authorship: { mode, reviewed }, stubProvider }. */++function gateFacts(rec, review = null) {++ const reviewed = isReviewed(rec, review);++ const out = { authorship: { mode: rec.mode, reviewed } };++ if (rec.stubProvider) out.stubProvider = true;++ return out;++}++++/**++ * Disclosure text, rendered at the item (not in a footer). Returns null for plain human content.++ * { short, long, mode }++ */++function disclosure(rec, review = null, { sourceLabel } = {}) {++ const src = sourceLabel || (rec.source && rec.source.label) || null;++ const wf = rec.workflow ? `${rec.workflow.id}${rec.workflow.version != null ? ` v${rec.workflow.version}` : ''}` : null;++ const reviewed = isReviewed(rec, review);++ switch (rec.mode) {++ case 'human': return null;++ case 'ai': return {++ mode: 'ai',++ short: 'AI-generated',++ long: `AI-generated${src ? ` from ${src}` : ''} by workflow ${wf}${reviewed ? ', reviewed by a person' : ', not yet reviewed by a person'}.`,++ };++ case 'hybrid': return { mode: 'hybrid', short: 'Written with AI assistance', long: `Written by a person with AI assistance (workflow ${wf})${src ? `, from ${src}` : ''}.` };++ case 'imported': return {++ mode: 'imported',++ short: 'Imported',++ long: `Imported from ${rec.importedFrom.label}${rec.importedFrom.originalAuthor ? `; originally by ${rec.importedFrom.originalAuthor}` : ''}.`,++ };++ default: return null;++ }++}++++/** Append-only review log: <prefix>_reviews. */++function createReviewLog(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const R = `${prefix}_reviews`;++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${R} (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ entity_id TEXT NOT NULL,++ revision INTEGER NOT NULL,++ reviewer TEXT NOT NULL,++ decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),++ note TEXT,++ reviewed_at INTEGER NOT NULL++ );++ CREATE INDEX IF NOT EXISTS ${R}_rev ON ${R} (entity_id, revision, id);++ CREATE TRIGGER IF NOT EXISTS ${R}_no_update BEFORE UPDATE ON ${R} BEGIN SELECT RAISE(ABORT, '${R} rows are immutable'); END;++ `);++ const shape = (r) => (r ? { id: r.id, entityId: r.entity_id, revision: r.revision, reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: new Date(r.reviewed_at).toISOString() } : null);++ const q = {++ insert: db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, decision, note, reviewed_at) VALUES (?, ?, ?, ?, ?, ?)`),++ latest: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`),++ all: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id`),++ byId: db.prepare(`SELECT * FROM ${R} WHERE id = ?`),++ };++ return {++ table: R,++ /** A person's review of one revision. The reviewer must be a usr_ subject. */++ record({ entityId, revision, reviewer, decision, note = null } = {}) {++ assertEntityId(entityId);++ assertRevisionNumber(revision, 'revision');++ if (!USER_RE.test(String(reviewer))) throw new PublishingError(400, 'review.reviewer_not_person', 'Only a person (usr_ subject) can review');++ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');++ const info = q.insert.run(entityId, revision, reviewer, decision, note == null ? null : String(note).slice(0, 2000), clock());++ return shape(q.byId.get(info.lastInsertRowid));++ },++ latest(entityId, revision) { return shape(q.latest.get(assertEntityId(entityId), revision)); },++ history(entityId) { return q.all.all(assertEntityId(entityId)).map(shape); },++ };++}++++module.exports = { MODES, record, needsReview, isReviewed, initialState, canPublish, gateFacts, disclosure, createReviewLog };nnew file mode 100644@@@ -0,0 +1,205 @@++'use strict';++/**++ * openvibe-publishing/citations — source references attached to one specific revision.++ *++ * const { createCitationStore } = require('openvibe-publishing/citations');++ * const cites = createCitationStore(db, { prefix: 'wiki', revisions }); // wiki_citations++ * cites.attach({ entityId: 'pg_1', revision: 2, url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z',++ * quote: { text: '…', start: 120, end: 180 }, licenseNote: 'CC BY 4.0', sourceItemId: 'src_…' });++ * cites.carryForward({ entityId: 'pg_1', fromRevision: 2, toRevision: 3 }); // reuse in r3++ * cites.forRevision('pg_1', 2); // still there after r3 dropped them++ *++ * Rules:++ * - A citation belongs to (entity, revision). It is never dropped: the table is append-only++ * (UPDATE and DELETE abort in SQLite triggers), so a later revision that does not reuse a++ * source leaves the earlier revision's citations intact. Only purgeEntity (audited) removes rows.++ * - Every citation names a source: a Sources item id, an http(s) URL, or both.++ * - Nothing is filled in: a missing retrieved_at, title or license note stays null. This module++ * never stamps "now" as a retrieval time.++ * - If a revisions store is passed, attach() refuses revisions that do not exist.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers } = require('./internal');++++function shape(row) {++ if (!row) return null;++ const quote = row.quote_text == null && row.quote_start == null ? null++ : { text: row.quote_text, start: row.quote_start, end: row.quote_end };++ return {++ id: row.id,++ entityId: row.entity_id,++ revision: row.revision,++ anchor: row.anchor,++ sourceItemId: row.source_item_id,++ url: row.url,++ title: row.title,++ retrievedAt: row.retrieved_at,++ quote,++ licenseNote: row.license_note,++ carriedFrom: row.carried_from,++ attachedBy: row.attached_by,++ attachedAt: new Date(row.attached_at).toISOString(),++ };++}++++function checkUrl(url) {++ if (url == null || url === '') return null;++ let u;++ try { u = new URL(String(url)); } catch { throw new TypeError('url must be an absolute URL'); }++ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new TypeError('url must be http(s)');++ return u.toString();++}++++function checkQuote(quote) {++ if (quote == null) return { text: null, start: null, end: null };++ if (typeof quote !== 'object') throw new TypeError('quote must be { text, start, end }');++ const text = quote.text == null ? null : String(quote.text).slice(0, 5000);++ const start = quote.start == null ? null : Number(quote.start);++ const end = quote.end == null ? null : Number(quote.end);++ if ((start == null) !== (end == null)) throw new TypeError('quote.start and quote.end go together');++ if (start != null && (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start)) throw new TypeError('quote span must be 0 <= start <= end');++ if (text == null && start == null) return { text: null, start: null, end: null };++ return { text, start, end };++}++++function createCitationStore(db, { prefix, now, revisions = null } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const C = `${prefix}_citations`;++ const P = `${prefix}_citation_purges`;++++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${C} (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ entity_id TEXT NOT NULL,++ revision INTEGER NOT NULL CHECK (revision >= 1),++ anchor TEXT,++ source_item_id TEXT,++ url TEXT,++ title TEXT,++ retrieved_at TEXT,++ quote_text TEXT,++ quote_start INTEGER,++ quote_end INTEGER,++ license_note TEXT,++ carried_from INTEGER REFERENCES ${C}(id),++ attached_by TEXT,++ attached_at INTEGER NOT NULL,++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)++ );++ CREATE INDEX IF NOT EXISTS ${C}_rev ON ${C} (entity_id, revision);++ CREATE INDEX IF NOT EXISTS ${C}_source ON ${C} (source_item_id);++ CREATE TABLE IF NOT EXISTS ${P} (++ entity_id TEXT PRIMARY KEY,++ reason TEXT NOT NULL,++ purged_by TEXT,++ purged_at INTEGER NOT NULL++ );++ ${immutableTriggers(C, P)}++ `);++++ const q = {++ insert: db.prepare(`INSERT INTO ${C} (entity_id, revision, anchor, source_item_id, url, title, retrieved_at, quote_text, quote_start, quote_end, license_note, carried_from, attached_by, attached_at)++ VALUES (@entity_id, @revision, @anchor, @source_item_id, @url, @title, @retrieved_at, @quote_text, @quote_start, @quote_end, @license_note, @carried_from, @attached_by, @attached_at)`),++ byId: db.prepare(`SELECT * FROM ${C} WHERE id = ?`),++ forRevision: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? AND revision = ? ORDER BY id`),++ history: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? ORDER BY revision, id`),++ bySource: db.prepare(`SELECT * FROM ${C} WHERE source_item_id = ? ORDER BY entity_id, revision, id`),++ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),++ };++++ function checkRevision(entityId, revision) {++ assertRevisionNumber(revision, 'revision');++ if (revision < 1) throw new TypeError('revision must be >= 1');++ if (revisions && !revisions.get(entityId, revision)) throw new PublishingError(404, 'revision.not_found', `No revision ${revision} of ${entityId}`);++ }++++ function insert(entityId, revision, c, carriedFrom = null) {++ const sourceItemId = c.sourceItemId == null || c.sourceItemId === '' ? null : String(c.sourceItemId).slice(0, 128);++ const url = checkUrl(c.url);++ if (!sourceItemId && !url) throw new PublishingError(400, 'citation.no_source', 'A citation needs a Sources item id or an http(s) URL');++ const quote = checkQuote(c.quote);++ const row = {++ entity_id: entityId,++ revision,++ anchor: c.anchor == null ? null : String(c.anchor).slice(0, 100),++ source_item_id: sourceItemId,++ url,++ title: c.title == null ? null : String(c.title).slice(0, 500),++ retrieved_at: isoOrNull(c.retrievedAt, 'retrievedAt'),++ quote_text: quote.text,++ quote_start: quote.start,++ quote_end: quote.end,++ license_note: c.licenseNote == null ? null : String(c.licenseNote).slice(0, 1000),++ carried_from: carriedFrom,++ attached_by: c.attachedBy == null ? null : String(c.attachedBy),++ attached_at: clock(),++ };++ return shape(q.byId.get(q.insert.run(row).lastInsertRowid));++ }++++ const api = {++ table: C,++++ /** Attach one citation to (entityId, revision). */++ attach({ entityId, revision, ...citation } = {}) {++ assertEntityId(entityId);++ checkRevision(entityId, revision);++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);++ return insert(entityId, revision, citation);++ },++++ /** Attach several in one transaction. */++ attachMany(entityId, revision, list = []) {++ assertEntityId(entityId);++ checkRevision(entityId, revision);++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);++ return db.transaction(() => list.map((c) => insert(entityId, revision, c)))();++ },++++ /**++ * Reuse citations of `fromRevision` in `toRevision` (all, or only `ids`). New rows point back++ * with carried_from; the originals are untouched.++ */++ carryForward({ entityId, fromRevision, toRevision, ids = null, attachedBy = null } = {}) {++ assertEntityId(entityId);++ checkRevision(entityId, toRevision);++ return db.transaction(() => {++ const wanted = ids ? new Set(ids.map(Number)) : null;++ return q.forRevision.all(entityId, fromRevision)++ .filter((r) => !wanted || wanted.has(r.id))++ .map((r) => {++ const c = shape(r);++ return insert(entityId, toRevision, { ...c, attachedBy: attachedBy || c.attachedBy }, r.carried_from || r.id);++ });++ })();++ },++++ get(id) { return shape(q.byId.get(Number(id))); },++ forRevision(entityId, revision) { return q.forRevision.all(assertEntityId(entityId), revision).map(shape); },++ /** Every citation any revision of the entity ever had, oldest revision first. */++ history(entityId) { return q.history.all(assertEntityId(entityId)).map(shape); },++ /** Where a Sources item is cited — for correction/removal propagation (a source changed → revise). */++ bySourceItem(sourceItemId) { return q.bySource.all(String(sourceItemId)).map(shape); },++++ /** Audited erasure of every citation of one entity. */++ purgeEntity(entityId, { reason, purgedBy = null } = {}) {++ if (!reason) throw new TypeError('a purge needs a recorded reason');++ return db.transaction(() => {++ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());++ return { deleted: db.prepare(`DELETE FROM ${C} WHERE entity_id = ?`).run(entityId).changes };++ })();++ },++ };++ return api;++}++++/** Plain facts for the SEO gate: how many citations, and how many name a retrieval time. */++function gateFacts(citations = []) {++ return {++ citationCount: citations.length,++ datedCitationCount: citations.filter((c) => c && c.retrievedAt).length,++ };++}++++module.exports = { createCitationStore, gateFacts };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Line and word diffs (Myers' O(ND) algorithm over tokens), used by revisions.diff and ssr.diffHtml.++ *++ * diffText(a, b, { mode: 'line' | 'word' }) ->++ * { mode, ops: [{ op: 'equal'|'insert'|'delete', text }], added, removed, approximate }++ *++ * `added` / `removed` count tokens (lines or words). Common prefix and suffix are trimmed before++ * the search; if the two texts differ by more than `maxEdits` tokens the middle is reported as one++ * delete plus one insert and `approximate` is true, so a pathological input cannot exhaust memory.++ */++++const WORD_RE = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;++++function tokenize(text, mode) {++ const s = String(text == null ? '' : text);++ if (!s) return [];++ if (mode === 'word') return s.match(WORD_RE) || [];++ return s.match(/[^\n]*\n|[^\n]+$/g) || [];++}++++function myers(a, b, maxEdits) {++ const n = a.length;++ const m = b.length;++ const max = n + m;++ const offset = max + 1;++ const v = new Int32Array(2 * max + 3);++ const trace = [];++ for (let d = 0; d <= max; d++) {++ if (d > maxEdits) return null;++ trace.push(v.slice(offset - d - 1, offset + d + 2));++ for (let k = -d; k <= d; k += 2) {++ let x = (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1;++ let y = x - k;++ while (x < n && y < m && a[x] === b[y]) { x++; y++; }++ v[offset + k] = x;++ if (x >= n && y >= m) return backtrack(trace, a, b);++ }++ }++ return backtrack(trace, a, b);++}++++function backtrack(trace, a, b) {++ const ops = [];++ let x = a.length;++ let y = b.length;++ for (let d = trace.length - 1; d >= 0; d--) {++ const row = trace[d];++ const at = (k) => row[k + d + 1];++ const k = x - y;++ const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;++ const prevX = at(prevK);++ const prevY = prevX - prevK;++ while (x > prevX && y > prevY) { ops.push({ op: 'equal', text: a[x - 1] }); x--; y--; }++ if (d > 0) {++ if (x === prevX) { ops.push({ op: 'insert', text: b[y - 1] }); y--; }++ else { ops.push({ op: 'delete', text: a[x - 1] }); x--; }++ }++ x = prevX;++ y = prevY;++ }++ return ops.reverse();++}++++function merge(ops) {++ const out = [];++ for (const o of ops) {++ const last = out[out.length - 1];++ if (last && last.op === o.op) last.text += o.text;++ else out.push({ op: o.op, text: o.text });++ }++ return out;++}++++function diffTokens(a, b, { maxEdits = 4000 } = {}) {++ let start = 0;++ while (start < a.length && start < b.length && a[start] === b[start]) start++;++ let endA = a.length;++ let endB = b.length;++ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }++ const midA = a.slice(start, endA);++ const midB = b.slice(start, endB);++ let middle = myers(midA, midB, maxEdits);++ let approximate = false;++ if (!middle) {++ approximate = true;++ middle = [...midA.map((t) => ({ op: 'delete', text: t })), ...midB.map((t) => ({ op: 'insert', text: t }))];++ }++ const ops = [++ ...a.slice(0, start).map((t) => ({ op: 'equal', text: t })),++ ...middle,++ ...a.slice(endA).map((t) => ({ op: 'equal', text: t })),++ ];++ let added = 0;++ let removed = 0;++ for (const o of ops) {++ if (o.op === 'insert' && o.text.trim()) added++;++ if (o.op === 'delete' && o.text.trim()) removed++;++ }++ return { ops: merge(ops), added, removed, approximate };++}++++function diffText(a, b, { mode = 'line', maxEdits } = {}) {++ if (mode !== 'line' && mode !== 'word') throw new TypeError('mode must be "line" or "word"');++ return { mode, ...diffTokens(tokenize(a, mode), tokenize(b, mode), { maxEdits }) };++}++++/** Unified-style text for a line diff: "+ added", "- removed", " kept". */++function formatLines(result) {++ const out = [];++ for (const o of result.ops) {++ const sign = o.op === 'insert' ? '+ ' : o.op === 'delete' ? '- ' : ' ';++ for (const line of tokenize(o.text, 'line')) out.push(sign + line.replace(/\n$/, ''));++ }++ return out.join('\n');++}++++module.exports = { diffText, formatLines, tokenize };nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * openvibe-publishing/discussion — Community comment-thread references. Reference, never copy.++ *++ * const { createDiscussionClient, createDiscussionRefs } = require('openvibe-publishing/discussion');++ * const community = createDiscussionClient({ communityUrl: 'https://openvibe.community', tokenClient });++ * const refs = createDiscussionRefs(db, { prefix: 'wiki' }); // wiki_discussion_refs++ * const { threadId } = await refs.threadFor('pg_1', { service: 'wiki', type: 'page', id: 'pg_1', label: 'Sourdough' }, { client: community });++ *++ * The thread and its comments live in OpenVibe.Community. This module calls++ * `POST /api/v1/comments/threads/resolve { ref: EntityRef }` (get-or-create; 201 created, 200++ * existing) with the product's Network service token (`community.comment.write`, audience++ * `openvibe.community`), and stores only the thread id next to the entity. There is no column for++ * comment text, counts or authors, on purpose: products render the thread by embedding or by++ * reading Community, so moderation and deletion there are always what readers see.++ *++ * tokenClient is anything with `authHeaders()` → { Authorization } (openvibe-contracts'++ * serviceAuth.createTokenClient). Failures are errors (code 'discussion.unavailable' or the++ * problem code Community returned) — never a fabricated thread.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, parseJson } = require('./internal');++++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;++const TRACEPARENT_RE = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;++++/** Validates and normalises an EntityRef (common.entity-ref@1). */++function entityRef({ service, type, id, label, revision } = {}) {++ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('ref.service must be a service slug');++ if (!TYPE_RE.test(String(type || ''))) throw new TypeError('ref.type must match ^[a-z][a-z0-9_]{1,39}$');++ if (typeof id !== 'string' || !id || id.length > 128) throw new TypeError('ref.id must be a 1–128 character string');++ const ref = { service, type, id };++ if (Number.isInteger(revision) && revision >= 0) ref.revision = revision;++ if (label != null && label !== '') ref.label = String(label).slice(0, 200);++ return ref;++}++++function createDiscussionClient({ communityUrl, tokenClient, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {++ if (!communityUrl) throw new TypeError('communityUrl is required');++ if (!tokenClient || typeof tokenClient.authHeaders !== 'function') throw new TypeError('tokenClient with authHeaders() is required');++ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');++ const base = String(communityUrl).replace(/\/+$/, '');++++ return {++ base,++ /**++ * Get-or-create the Community thread for an entity.++ * opts: subject (usr_/gst_ id the product acts for; omit to act as the service),++ * traceparent, requestId.++ * → { threadId, created, visibility }++ */++ async resolveThread(ref, { subject, traceparent, requestId } = {}) {++ const body = { ref: entityRef(ref) };++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient.authHeaders()) };++ if (subject) {++ if (!SUBJECT_RE.test(subject)) throw new TypeError('subject must be a usr_… or gst_… id');++ headers['X-OV-Subject'] = subject;++ }++ if (traceparent && TRACEPARENT_RE.test(traceparent)) headers.traceparent = traceparent;++ if (requestId) headers['X-OpenVibe-Request-Id'] = String(requestId).slice(0, 128);++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), timeoutMs);++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/comments/threads/resolve`, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });++ } catch (err) {++ throw new PublishingError(503, 'discussion.unavailable', `Community did not answer: ${err && err.name === 'AbortError' ? 'timeout' : String(err && err.message || err)}`);++ } finally {++ clearTimeout(timer);++ }++ const text = await res.text().catch(() => '');++ const json = parseJson(text, null);++ if (res.status === 401 && typeof tokenClient.invalidate === 'function') tokenClient.invalidate();++ if (!res.ok) {++ const code = json && typeof json.code === 'string' ? json.code : 'discussion.unavailable';++ throw new PublishingError(res.status >= 500 ? 503 : res.status, code, (json && (json.detail || json.title)) || `Community answered ${res.status}`, { upstreamStatus: res.status });++ }++ const thread = json && json.thread;++ if (!thread || typeof thread.id !== 'string' && typeof thread.id !== 'number') {++ throw new PublishingError(502, 'discussion.bad_response', 'Community returned no thread id');++ }++ return { threadId: String(thread.id), created: res.status === 201 || Boolean(json.created), visibility: thread.visibility || null };++ },++ };++}++++/** Local table of entity → Community thread id. Stores the reference only. */++function createDiscussionRefs(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const R = `${prefix}_discussion_refs`;++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${R} (++ entity_id TEXT PRIMARY KEY,++ thread_id TEXT NOT NULL,++ ref TEXT NOT NULL,++ resolved_at INTEGER NOT NULL++ );++ `);++ const q = {++ get: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ?`),++ put: db.prepare(`INSERT INTO ${R} (entity_id, thread_id, ref, resolved_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (entity_id) DO UPDATE SET thread_id = excluded.thread_id, ref = excluded.ref, resolved_at = excluded.resolved_at`),++ del: db.prepare(`DELETE FROM ${R} WHERE entity_id = ?`),++ };++ const shape = (row) => (row ? { entityId: row.entity_id, threadId: row.thread_id, ref: parseJson(row.ref, null), resolvedAt: new Date(row.resolved_at).toISOString() } : null);++++ const api = {++ table: R,++ get(entityId) { return shape(q.get.get(assertEntityId(entityId))); },++ /** Record a thread id obtained elsewhere (e.g. from a Community event). */++ set(entityId, threadId, ref) {++ q.put.run(assertEntityId(entityId), String(threadId), JSON.stringify(entityRef(ref)), clock());++ return api.get(entityId);++ },++ forget(entityId) { return q.del.run(assertEntityId(entityId)).changes > 0; },++ /** The stored thread id, or resolve it through Community once and store it. */++ async threadFor(entityId, ref, { client, ...opts } = {}) {++ const known = api.get(entityId);++ if (known) return { threadId: known.threadId, created: false, cached: true };++ if (!client) throw new TypeError('client (createDiscussionClient) is required to resolve a new thread');++ const out = await client.resolveThread(ref, opts);++ api.set(entityId, out.threadId, ref);++ return { ...out, cached: false };++ },++ };++ return api;++}++++module.exports = { createDiscussionClient, createDiscussionRefs, entityRef };nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * openvibe-publishing/index-hooks — Search index documents and publication event envelopes.++ *++ * const hooks = require('openvibe-publishing/index-hooks');++ * const doc = hooks.buildIndexDocument({ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published',++ * visibility: 'public', canonicalUrl, title, summary, body, facets: { space: 'food' }, provenance, decision });++ * const action = hooks.actionFor(before, after); // 'published' | 'updated' | 'unpublished' | 'deleted' | null++ * const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: 'pg_1', revision: 4,++ * actor: 'usr_…', document: doc, decision, now: Date.now() });++ * outbox.enqueue(env); // OpenVibe.Events createOutbox, in the same transaction as the product's change++ *++ * The index document is the Search contract of [REF] §12.3 (owner service, resource type/id,++ * revision, visibility + ACL, canonical URL, title, summary, body, facets, provenance, publication++ * state, deletion marker) plus the gate's indexability decision. Proposed schema:++ * docs/contracts-proposal/search.index-document.v1.json.++ *++ * Safety rules built in:++ * - Anything not published, deleted, or unlisted becomes a tombstone (deletion marker, no++ * title/summary/body), so a visibility change or deletion removes content from the index.++ * - private content must carry an ACL naming subjects; gated content must name entitlements.++ * There is no way to build a private document that reads as world-visible.++ * - Events about non-public or non-listable content are 'internal', never 'public'.++ * - The envelope carries no event_id; the outbox assigns evt_<ULID> (or pass eventId).++ */++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const SEGMENT_RE = /^[a-z][a-z0-9_]{0,39}$/;++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const SUBJECT_ID_RE = new RegExp(`^(usr|gst|app|mod)_${ULID}$`);++const ACTIONS = ['published', 'updated', 'unpublished', 'deleted'];++const SCHEMA = 'search.index-document@1';++const MAX_BODY = 100000;++++function assertDecision(d) {++ if (!d || typeof d.indexable !== 'boolean' || typeof d.listable !== 'boolean') throw new TypeError('decision (seo.evaluate) is required');++ return { indexable: d.indexable, listable: d.listable, reasons: Array.isArray(d.codes) ? [...d.codes] : [], gate: d.gate || null };++}++++function iso(v, name) {++ if (v == null || v === '') return null;++ const d = new Date(v);++ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);++ return d.toISOString();++}++++function checkFacets(facets) {++ const out = {};++ for (const [k, v] of Object.entries(facets || {})) {++ if (!SEGMENT_RE.test(k)) throw new TypeError(`facet name "${k}" must match ${SEGMENT_RE}`);++ if (v == null) continue;++ const ok = (x) => typeof x === 'string' || typeof x === 'boolean' || (typeof x === 'number' && Number.isFinite(x));++ if (Array.isArray(v) ? !v.every(ok) : !ok(v)) throw new TypeError(`facet "${k}" must be a string, number, boolean or an array of them`);++ out[k] = Array.isArray(v) ? [...v] : v;++ }++ return out;++}++++function checkProvenance(p = {}) {++ const out = {};++ if (p.authorship) {++ out.authorship = { mode: p.authorship.mode };++ if (p.authorship.workflow) out.authorship.workflow = { id: p.authorship.workflow.id, run_id: p.authorship.workflow.runId };++ if (p.authorship.reviewed != null) out.authorship.reviewed = Boolean(p.authorship.reviewed);++ }++ if (Array.isArray(p.citations)) {++ out.citations = p.citations.map((c) => {++ const o = {};++ if (c.sourceItemId) o.source_item_id = String(c.sourceItemId);++ if (c.url) o.url = String(c.url);++ if (c.retrievedAt) o.retrieved_at = iso(c.retrievedAt, 'citation.retrievedAt');++ if (c.licenseNote) o.license_note = String(c.licenseNote);++ return o;++ }).filter((o) => o.source_item_id || o.url);++ }++ if (p.derivedFrom) out.derived_from = p.derivedFrom;++ return out;++}++++function checkIdentity({ service, type, id, revision }) {++ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('service must be a service slug');++ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');++ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError('id must be a non-empty string');++ if (!Number.isInteger(revision) || revision < 0) throw new TypeError('revision must be a non-negative integer');++}++++/** A deletion marker: Search removes (resource, revision ≤ this) and caches purge it. */++function tombstone({ service, type, id, revision, state = 'deleted', updatedAt = null }) {++ checkIdentity({ service, type, id, revision });++ return {++ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,++ publication_state: state, deleted: true, updated_at: iso(updatedAt, 'updatedAt'),++ };++}++++/**++ * Build the Search document for a revision. Non-published, deleted and unlisted resources come++ * back as tombstones.++ */++function buildIndexDocument({++ service, type, id, revision, state, visibility, acl = {}, canonicalUrl, title, summary = null, body = '',++ facets = {}, provenance = {}, decision, publishedAt = null, updatedAt = null, locale = null, deleted = false,++} = {}) {++ checkIdentity({ service, type, id, revision });++ if (deleted || state !== 'published' || visibility === 'unlisted') {++ return tombstone({ service, type, id, revision, state: deleted ? 'deleted' : state || 'deleted', updatedAt });++ }++ const indexability = assertDecision(decision);++ let docAcl;++ if (visibility === 'public') docAcl = { public: true };++ else if (visibility === 'private') {++ const subjects = (acl.subjects || []).map(String);++ if (!subjects.length || !subjects.every((s) => SUBJECT_ID_RE.test(s))) throw new TypeError('private documents need acl.subjects (usr_/gst_/app_/mod_ ids)');++ docAcl = { public: false, subjects };++ } else if (visibility === 'gated') {++ const entitlements = (acl.entitlements || []).map(String).filter(Boolean);++ if (!entitlements.length) throw new TypeError('gated documents need acl.entitlements');++ docAcl = { public: false, entitlements, ...(acl.subjects && acl.subjects.length ? { subjects: acl.subjects.map(String) } : {}) };++ } else {++ throw new TypeError('visibility must be public, private, gated or unlisted');++ }++ if (!canonicalUrl || !/^https?:\/\//.test(canonicalUrl)) throw new TypeError('canonicalUrl must be absolute');++ if (!title) throw new TypeError('title is required');++ const text = String(body == null ? '' : body);++ const doc = {++ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,++ visibility, acl: docAcl, canonical_url: canonicalUrl, title: String(title),++ summary: summary == null ? null : String(summary), body: text.slice(0, MAX_BODY),++ facets: checkFacets(facets), provenance: checkProvenance(provenance),++ publication_state: 'published', deleted: false, indexability,++ published_at: iso(publishedAt, 'publishedAt'), updated_at: iso(updatedAt, 'updatedAt'),++ };++ if (text.length > MAX_BODY) doc.body_truncated = true;++ if (locale) doc.locale = String(locale);++ return doc;++}++++/**++ * Which publication event a transition produces. before/after: { state, visibility, revision }++ * (before may be null for a new resource). → 'published' | 'updated' | 'unpublished' | 'deleted' | null++ */++function actionFor(before, after) {++ const was = Boolean(before && before.state === 'published');++ if (after.state === 'deleted') return before && before.state === 'deleted' ? null : (before ? 'deleted' : null);++ const is = after.state === 'published';++ if (!was && is) return 'published';++ if (was && !is) return 'unpublished';++ if (was && is && (before.revision !== after.revision || before.visibility !== after.visibility)) return 'updated';++ return null;++}++++/** 'usr_…' | 'svc:wiki' | 'service:wiki' | { type, id } → SubjectRef. */++function subjectRef(actor) {++ if (actor && typeof actor === 'object') return { type: actor.type, id: actor.id };++ const s = String(actor || '');++ if (/^svc:/.test(s)) return { type: 'service', id: s.slice(4) };++ const m = s.match(/^(user|guest|service|system|app|mod):(.+)$/);++ if (m) return { type: m[1], id: m[2] };++ if (/^usr_/.test(s)) return { type: 'user', id: s };++ if (/^gst_/.test(s)) return { type: 'guest', id: s };++ throw new TypeError('actor must be a subject (usr_…, gst_…, svc:<service>) or a SubjectRef');++}++++/**++ * `<product>.<type>.<action>` envelope (contracts events/event-envelope@1) for the product's outbox.++ * document: the index document (or tombstone) for this revision — it becomes the payload.++ */++function publicationEvent({ product, type, action, id, revision, actor, document, decision = null, now = Date.now(), traceId = null, eventId = null, extra = {} } = {}) {++ if (!SERVICE_RE.test(String(product || '')) || !SEGMENT_RE.test(String(product))) throw new TypeError('product must be a lowercase service slug without dashes, e.g. "wiki"');++ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');++ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);++ if (!document || document.resource_id !== id) throw new TypeError('document (buildIndexDocument/tombstone) for this resource is required');++ if ((action === 'deleted' || action === 'unpublished') && !document.deleted) throw new TypeError(`${action} events carry a tombstone`);++ // A published-but-unlisted resource is a tombstone with publication_state 'published': it may be updated, not listed.++ if ((action === 'published' || action === 'updated') && document.publication_state !== 'published') throw new TypeError(`${action} events carry a published document`);++ const isPublic = !document.deleted && document.visibility === 'public' && document.indexability && document.indexability.listable;++ const env = {++ event_type: `${product}.${type}.${action}`,++ version: 1,++ source: product,++ actor: subjectRef(actor),++ timestamp: new Date(now).toISOString(),++ visibility: isPublic ? 'public' : 'internal',++ subject: { type, id, revision },++ payload: {++ canonical_url: document.canonical_url || null,++ publication_state: document.publication_state,++ indexability: decision ? assertDecision(decision) : document.indexability || null,++ document,++ ...extra,++ },++ };++ if (eventId) env.event_id = eventId;++ if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;++ return env;++}++++module.exports = { SCHEMA, ACTIONS, buildIndexDocument, tombstone, actionFor, publicationEvent, subjectRef };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * Helpers shared by the modules in this package. Not a public entry point.++ *++ * Every storage module works on a better-sqlite3 handle the consumer owns and names its tables++ * with the consumer's prefix, so a product's publication state stays in the product's database.++ */++const crypto = require('crypto');++++const PREFIX_RE = /^[a-z][a-z0-9_]{0,39}$/;++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++++/** An error with an HTTP status and a stable code, ready for contracts.http.sendProblem(res, status, code, …). */++class PublishingError extends Error {++ constructor(status, code, message, extra = {}) {++ super(message);++ this.name = 'PublishingError';++ this.status = status;++ this.code = code;++ Object.assign(this, extra);++ }++}++++function assertDb(db) {++ if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') {++ throw new TypeError('a better-sqlite3 database handle is required');++ }++}++++function assertPrefix(prefix) {++ if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {++ throw new TypeError(`prefix must match ${PREFIX_RE} (got ${JSON.stringify(prefix)})`);++ }++ return prefix;++}++++function clockOf(now) {++ if (now == null) return () => Date.now();++ if (typeof now !== 'function') throw new TypeError('now must be a function returning epoch milliseconds');++ return () => {++ const t = Number(now());++ if (!Number.isFinite(t)) throw new TypeError('now() must return epoch milliseconds');++ return t;++ };++}++++/** Time-sortable id with a type prefix: rev_01J…, job_01J…. */++function newId(prefix, nowMs = Date.now()) {++ let time = '';++ for (let t = Math.floor(nowMs), i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;++ const bytes = crypto.randomBytes(16);++ let rand = '';++ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];++ return `${prefix}_${time}${rand}`;++}++++function sha256(text) {++ return crypto.createHash('sha256').update(String(text)).digest('hex');++}++++/** Stable JSON: object keys sorted, so the same fields always hash the same. */++function stableStringify(value) {++ if (value === undefined) return 'null';++ if (value === null || typeof value !== 'object') return JSON.stringify(value);++ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;++ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();++ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;++}++++function parseJson(text, fallback = null) {++ if (text == null) return fallback;++ try { return JSON.parse(text); } catch { return fallback; }++}++++function assertEntityId(id, name = 'entityId') {++ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError(`${name} must be a non-empty string of at most 200 characters`);++ return id;++}++++function assertRevisionNumber(n, name = 'revision') {++ if (!Number.isInteger(n) || n < 0) throw new TypeError(`${name} must be a non-negative integer`);++ return n;++}++++/** ISO-8601 instant or null. Never invents a time: missing stays null. */++function isoOrNull(value, name) {++ if (value == null || value === '') return null;++ const d = value instanceof Date ? value : new Date(value);++ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);++ return d.toISOString();++}++++/**++ * Append-only protection for a table: UPDATE always aborts, DELETE aborts unless the entity was++ * first registered in `<purgeTable>` (an audited, explicit erasure path).++ */++function immutableTriggers(table, purgeTable, keyColumn = 'entity_id') {++ return `++ CREATE TRIGGER IF NOT EXISTS ${table}_no_update BEFORE UPDATE ON ${table}++ BEGIN SELECT RAISE(ABORT, '${table} rows are immutable'); END;++ CREATE TRIGGER IF NOT EXISTS ${table}_no_delete BEFORE DELETE ON ${table}++ WHEN NOT EXISTS (SELECT 1 FROM ${purgeTable} WHERE entity_id = OLD.${keyColumn})++ BEGIN SELECT RAISE(ABORT, '${table} rows are never deleted outside a recorded purge'); END;++ `;++}++++module.exports = {++ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,++ assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers, PREFIX_RE,++};nnew file mode 100644@@@ -0,0 +1,192 @@++'use strict';++/**++ * openvibe-publishing/media — attachments by OpenVibe.Media object id, with an explicit++ * broken-asset state.++ *++ * const { createAttachmentStore } = require('openvibe-publishing/media');++ * const media = createAttachmentStore(db, { prefix: 'blog_post' }); // blog_post_attachments++ * media.attach({ entityId: 'post_1', mediaId: 'med_01J…', role: 'cover', alt: 'A loaf' });++ * // Media says the object is gone (media.object.deleted event, or a 404 on fetch):++ * media.markBroken('med_01J…', 'deleted');++ * media.list('post_1') // → [{ …, state: 'broken', brokenReason: 'deleted' }]++ *++ * Bytes never live here: a row is a reference (MediaRef: med_<ULID> or the transitional++ * legacy:<app>:<kind>:<id>). States:++ * unverified attached, never checked++ * available the last check found the object++ * broken the object is gone (not_found | deleted | forbidden) — rendered as an explicit++ * "media unavailable" placeholder, never silently dropped or replaced++ * A check that fails for another reason (timeout, 5xx) changes nothing and is reported as++ * check_failed: an outage is not evidence the object is gone, nor that it exists.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');++++const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;++const ROLE_RE = /^[a-z][a-z0-9_]{1,39}$/;++const VARIANT_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;++const STATES = ['unverified', 'available', 'broken'];++const BROKEN_REASONS = ['not_found', 'deleted', 'forbidden'];++++function isMediaId(id) { return typeof id === 'string' && MEDIA_ID_RE.test(id); }++++function shape(row) {++ if (!row) return null;++ return {++ id: row.id,++ entityId: row.entity_id,++ revision: row.revision,++ mediaId: row.media_id,++ role: row.role,++ variant: row.variant,++ alt: row.alt,++ caption: row.caption,++ position: row.position,++ state: row.state,++ broken: row.state === 'broken',++ brokenReason: row.broken_reason,++ checkedAt: row.checked_at == null ? null : new Date(row.checked_at).toISOString(),++ };++}++++function createAttachmentStore(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const A = `${prefix}_attachments`;++++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${A} (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ entity_id TEXT NOT NULL,++ revision INTEGER,++ media_id TEXT NOT NULL,++ role TEXT NOT NULL DEFAULT 'inline',++ variant TEXT,++ alt TEXT,++ caption TEXT,++ position INTEGER NOT NULL DEFAULT 0,++ state TEXT NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),++ broken_reason TEXT CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),++ checked_at INTEGER,++ created_at INTEGER NOT NULL,++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))++ );++ CREATE INDEX IF NOT EXISTS ${A}_entity ON ${A} (entity_id, position);++ CREATE INDEX IF NOT EXISTS ${A}_media ON ${A} (media_id);++ `);++++ const q = {++ insert: db.prepare(`INSERT INTO ${A} (entity_id, revision, media_id, role, variant, alt, caption, position, created_at)++ VALUES (@entity_id, @revision, @media_id, @role, @variant, @alt, @caption, @position, @now)`),++ byId: db.prepare(`SELECT * FROM ${A} WHERE id = ?`),++ list: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? ORDER BY position, id`),++ listRev: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND (revision IS NULL OR revision = ?) ORDER BY position, id`),++ detach: db.prepare(`DELETE FROM ${A} WHERE id = ? AND entity_id = ?`),++ broken: db.prepare(`UPDATE ${A} SET state = 'broken', broken_reason = ?, checked_at = ? WHERE media_id = ?`),++ available: db.prepare(`UPDATE ${A} SET state = 'available', broken_reason = NULL, checked_at = ? WHERE media_id = ?`),++ brokenFor: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND state = 'broken' ORDER BY position, id`),++ entitiesWith: db.prepare(`SELECT DISTINCT entity_id FROM ${A} WHERE media_id = ? ORDER BY entity_id`),++ };++++ const api = {++ table: A,++++ attach({ entityId, mediaId, revision = null, role = 'inline', variant = null, alt = null, caption = null, position = 0 } = {}) {++ assertEntityId(entityId);++ if (!isMediaId(mediaId)) throw new PublishingError(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');++ if (!ROLE_RE.test(role)) throw new TypeError(`role must match ${ROLE_RE}`);++ if (variant != null && !VARIANT_RE.test(variant)) throw new TypeError(`variant must match ${VARIANT_RE}`);++ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');++ const info = q.insert.run({++ entity_id: entityId, revision, media_id: mediaId, role, variant,++ alt: alt == null ? null : String(alt).slice(0, 1000),++ caption: caption == null ? null : String(caption).slice(0, 2000),++ position: Number.isInteger(position) ? position : 0, now: clock(),++ });++ return shape(q.byId.get(info.lastInsertRowid));++ },++++ detach(entityId, attachmentId) { return q.detach.run(Number(attachmentId), assertEntityId(entityId)).changes > 0; },++++ get(id) { return shape(q.byId.get(Number(id))); },++++ /** All attachments of an entity; with `revision`, entity-wide ones plus that revision's. */++ list(entityId, { revision } = {}) {++ assertEntityId(entityId);++ return (revision == null ? q.list.all(entityId) : q.listRev.all(entityId, revision)).map(shape);++ },++++ broken(entityId) { return q.brokenFor.all(assertEntityId(entityId)).map(shape); },++++ /** Entities that reference a Media object — what to re-render/re-index when it changes. */++ entitiesUsing(mediaId) { return q.entitiesWith.all(String(mediaId)).map((r) => r.entity_id); },++++ /** Mark every attachment of this object broken. Returns the affected entity ids. */++ markBroken(mediaId, reason = 'not_found') {++ if (!BROKEN_REASONS.includes(reason)) throw new TypeError(`reason must be one of ${BROKEN_REASONS.join(', ')}`);++ return db.transaction(() => {++ const ids = api.entitiesUsing(mediaId);++ q.broken.run(reason, clock(), String(mediaId));++ return ids;++ })();++ },++++ markAvailable(mediaId) {++ return db.transaction(() => {++ const ids = api.entitiesUsing(mediaId);++ q.available.run(clock(), String(mediaId));++ return ids;++ })();++ },++++ /**++ * Check an entity's attachments against Media. resolve(mediaId) must return++ * { exists: true } | { exists: false, reason: 'not_found'|'deleted'|'forbidden' } or throw.++ * Returns [{ mediaId, outcome: 'available'|'broken'|'check_failed', reason? }].++ */++ async verify(entityId, { resolve } = {}) {++ if (typeof resolve !== 'function') throw new TypeError('resolve(mediaId) is required');++ const ids = [...new Set(api.list(entityId).map((a) => a.mediaId))];++ const out = [];++ for (const mediaId of ids) {++ let r;++ try { r = await resolve(mediaId); } catch (err) {++ out.push({ mediaId, outcome: 'check_failed', error: String(err && err.message || err) });++ continue;++ }++ if (r && r.exists === true) { api.markAvailable(mediaId); out.push({ mediaId, outcome: 'available' }); }++ else if (r && r.exists === false && BROKEN_REASONS.includes(r.reason || 'not_found')) {++ api.markBroken(mediaId, r.reason || 'not_found');++ out.push({ mediaId, outcome: 'broken', reason: r.reason || 'not_found' });++ } else out.push({ mediaId, outcome: 'check_failed', error: 'resolver returned no verdict' });++ }++ return out;++ },++ };++ return api;++}++++/** The Contracts MediaRef ({ media_id, role?, variant? }) for an attachment. */++function mediaRef(att) {++ const ref = { media_id: att.mediaId };++ if (att.role) ref.role = att.role;++ if (att.variant) ref.variant = att.variant;++ return ref;++}++++/**++ * Server-rendered <figure> for an attachment. urlFor(mediaId, att) returns the delivery URL.++ * A broken attachment renders an explicit placeholder — no <img>, no guessed URL.++ */++function figureHtml(att, { urlFor, unavailableText = 'This media is no longer available.' } = {}) {++ const { escapeHtml: esc } = require('./ssr');++ const caption = att.caption ? `<figcaption>${esc(att.caption)}</figcaption>` : '';++ if (att.state === 'broken') {++ return `<figure class="ov-media ov-media-broken" data-media-id="${esc(att.mediaId)}" data-state="broken"><div class="ov-media-missing" role="img" aria-label="${esc(unavailableText)}">${esc(unavailableText)}</div>${caption}</figure>`;++ }++ if (typeof urlFor !== 'function') throw new TypeError('urlFor(mediaId) is required to render an available attachment');++ const src = urlFor(att.mediaId, att);++ return `<figure class="ov-media" data-media-id="${esc(att.mediaId)}" data-state="${esc(att.state)}"><img src="${esc(src)}" alt="${esc(att.alt || '')}" loading="lazy" decoding="async">${caption}</figure>`;++}++++module.exports = { createAttachmentStore, mediaRef, figureHtml, isMediaId, STATES, BROKEN_REASONS, MEDIA_ID_RE };nnew file mode 100644@@@ -0,0 +1,302 @@++'use strict';++/**++ * openvibe-publishing/revisions — drafts and immutable revisions in the consumer's own database.++ *++ * const { createRevisionStore } = require('openvibe-publishing/revisions');++ * const revs = createRevisionStore(db, { prefix: 'wiki_page' }); // wiki_page_revisions, …++ * const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Hi', fields: { title: 'Hi' }, author: 'user:usr_…' });++ * revs.create({ entityId: 'pg_1', expectedRevision: 1, content: '# Hello', author: … }); // r2, parent r1++ * revs.diff('pg_1', 1, 2, { mode: 'word' });++ * revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: … }); // r3 = copy of r1++ *++ * Tables (created idempotently):++ * <prefix>_revisions immutable: UPDATE aborts; DELETE aborts unless the entity is purged++ * <prefix>_drafts one mutable working copy per (entity, owner), based on a revision++ * <prefix>_revision_purges audit rows for explicit erasure (purgeEntity)++ *++ * Optimistic concurrency: every write names the revision number it was based on++ * (`expectedRevision`, 0 for a new entity). If the head moved, the write throws a PublishingError++ * with status 412 and code 'revision.conflict' carrying `expected` and `current`. A UNIQUE++ * (entity_id, number) index is the last line of defence against two processes racing.++ *++ * This module stores content, not publication state: which revision is live is the product's++ * decision, kept in the product's own tables.++ */++const {++ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,++ assertEntityId, assertRevisionNumber, immutableTriggers,++} = require('./internal');++const { diffText } = require('./diff');++++const KINDS = ['edit', 'revert', 'import'];++++function conflict(entityId, expected, current) {++ return new PublishingError(412, 'revision.conflict',++ `Revision conflict on ${entityId}: expected ${expected}, current is ${current}`,++ { entityId, expected, current });++}++++function shape(row) {++ if (!row) return null;++ return {++ id: row.id,++ entityId: row.entity_id,++ number: row.number,++ parentId: row.parent_id,++ parentNumber: row.parent_number,++ kind: row.kind,++ revertedTo: row.reverted_to,++ content: row.content,++ fields: parseJson(row.fields, {}),++ meta: parseJson(row.meta, {}),++ contentHash: row.content_hash,++ author: row.author,++ message: row.message,++ createdAt: new Date(row.created_at).toISOString(),++ };++}++++function createRevisionStore(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const T = `${prefix}_revisions`;++ const D = `${prefix}_drafts`;++ const P = `${prefix}_revision_purges`;++++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${T} (++ id TEXT PRIMARY KEY,++ entity_id TEXT NOT NULL,++ number INTEGER NOT NULL CHECK (number >= 1),++ parent_id TEXT,++ parent_number INTEGER,++ kind TEXT NOT NULL CHECK (kind IN ('edit','revert','import')),++ reverted_to INTEGER,++ content TEXT NOT NULL,++ fields TEXT NOT NULL DEFAULT '{}',++ meta TEXT NOT NULL DEFAULT '{}',++ content_hash TEXT NOT NULL,++ author TEXT,++ message TEXT,++ created_at INTEGER NOT NULL++ );++ CREATE UNIQUE INDEX IF NOT EXISTS ${T}_entity_number ON ${T} (entity_id, number);++ CREATE TABLE IF NOT EXISTS ${D} (++ entity_id TEXT NOT NULL,++ owner TEXT NOT NULL,++ base_revision INTEGER NOT NULL,++ content TEXT NOT NULL,++ fields TEXT NOT NULL DEFAULT '{}',++ meta TEXT NOT NULL DEFAULT '{}',++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ PRIMARY KEY (entity_id, owner)++ );++ CREATE TABLE IF NOT EXISTS ${P} (++ entity_id TEXT PRIMARY KEY,++ reason TEXT NOT NULL,++ purged_by TEXT,++ purged_at INTEGER NOT NULL++ );++ ${immutableTriggers(T, P)}++ `);++++ const q = {++ head: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? ORDER BY number DESC LIMIT 1`),++ byNumber: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number = ?`),++ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),++ list: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number < ? ORDER BY number DESC LIMIT ?`),++ insert: db.prepare(`INSERT INTO ${T} (id, entity_id, number, parent_id, parent_number, kind, reverted_to, content, fields, meta, content_hash, author, message, created_at)++ VALUES (@id, @entity_id, @number, @parent_id, @parent_number, @kind, @reverted_to, @content, @fields, @meta, @content_hash, @author, @message, @created_at)`),++ draft: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? AND owner = ?`),++ draftsFor: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? ORDER BY updated_at DESC`),++ upsertDraft: db.prepare(`INSERT INTO ${D} (entity_id, owner, base_revision, content, fields, meta, created_at, updated_at)++ VALUES (@entity_id, @owner, @base_revision, @content, @fields, @meta, @now, @now)++ ON CONFLICT (entity_id, owner) DO UPDATE SET base_revision = excluded.base_revision, content = excluded.content,++ fields = excluded.fields, meta = excluded.meta, updated_at = excluded.updated_at`),++ deleteDraft: db.prepare(`DELETE FROM ${D} WHERE entity_id = ? AND owner = ?`),++ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),++ };++++ function headNumber(entityId) {++ const h = q.head.get(entityId);++ return h ? h.number : 0;++ }++++ function contentHash(content, fields) {++ return sha256(`${content}\u0000${stableStringify(fields || {})}`);++ }++++ function insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, revertedTo, allowUnchanged }) {++ assertEntityId(entityId);++ assertRevisionNumber(expectedRevision, 'expectedRevision');++ if (typeof content !== 'string') throw new TypeError('content must be a string');++ if (fields != null && (typeof fields !== 'object' || Array.isArray(fields))) throw new TypeError('fields must be an object');++ if (meta != null && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');++ if (!KINDS.includes(kind)) throw new TypeError(`kind must be one of ${KINDS.join(', ')}`);++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged; its id cannot be reused`);++ const head = q.head.get(entityId);++ const current = head ? head.number : 0;++ if (current !== expectedRevision) throw conflict(entityId, expectedRevision, current);++ const hash = contentHash(content, fields);++ if (head && head.content_hash === hash && !allowUnchanged) return { revision: shape(head), created: false };++ const t = clock();++ const row = {++ id: newId('rev', t),++ entity_id: entityId,++ number: current + 1,++ parent_id: head ? head.id : null,++ parent_number: head ? head.number : null,++ kind,++ reverted_to: revertedTo == null ? null : revertedTo,++ content,++ fields: JSON.stringify(fields || {}),++ meta: JSON.stringify(meta || {}),++ content_hash: hash,++ author: author == null ? null : String(author),++ message: message == null ? null : String(message).slice(0, 2000),++ created_at: t,++ };++ try {++ q.insert.run(row);++ } catch (err) {++ if (/UNIQUE constraint failed/.test(String(err && err.message))) throw conflict(entityId, expectedRevision, headNumber(entityId));++ throw err;++ }++ return { revision: shape(q.byId.get(row.id)), created: true };++ }++++ const tx = (fn) => db.transaction(fn);++++ const store = {++ prefix,++ tables: { revisions: T, drafts: D, purges: P },++++ /** Latest revision of an entity, or null. */++ head(entityId) { return shape(q.head.get(assertEntityId(entityId))); },++++ /** Current head number (0 when the entity has no revision yet). */++ headNumber(entityId) { return headNumber(assertEntityId(entityId)); },++++ get(entityId, number) { return shape(q.byNumber.get(assertEntityId(entityId), number)); },++++ getById(id) { return shape(q.byId.get(String(id))); },++++ /** Newest first. `before` is an exclusive revision number cursor. */++ list(entityId, { limit = 50, before } = {}) {++ const lim = Math.max(1, Math.min(500, Number(limit) || 50));++ const cursor = before == null ? Number.MAX_SAFE_INTEGER : Number(before);++ return q.list.all(assertEntityId(entityId), cursor, lim).map(shape);++ },++++ /** Walks parent pointers from `number` (default: head) back to revision 1. */++ lineage(entityId, number) {++ let row = number == null ? q.head.get(assertEntityId(entityId)) : q.byNumber.get(assertEntityId(entityId), number);++ const out = [];++ while (row) {++ out.push(shape(row));++ row = row.parent_id ? q.byId.get(row.parent_id) : null;++ }++ return out;++ },++++ /**++ * New immutable revision. Returns { revision, created }. An edit identical to the head++ * (same content and fields) returns the head with created: false unless allowUnchanged.++ */++ create({ entityId, expectedRevision, content, fields = {}, meta = {}, author = null, message = null, kind = 'edit', allowUnchanged = false } = {}) {++ if (kind === 'revert') throw new TypeError('use revert() to create a revert revision');++ return tx(() => insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, allowUnchanged }))();++ },++++ /** Revert as a new revision: copies revision `toRevision`'s content and fields; history is never rewritten. */++ revert({ entityId, toRevision, expectedRevision, author = null, message = null, meta = {} } = {}) {++ return tx(() => {++ const target = q.byNumber.get(assertEntityId(entityId), toRevision);++ if (!target) throw new PublishingError(404, 'revision.not_found', `No revision ${toRevision} of ${entityId}`);++ return insertRevision({++ entityId, expectedRevision, content: target.content, fields: parseJson(target.fields, {}),++ meta: { ...meta, revertedFrom: headNumber(entityId) }, author,++ message: message == null ? `Revert to revision ${toRevision}` : message,++ kind: 'revert', revertedTo: toRevision, allowUnchanged: true,++ });++ })();++ },++++ /** Line or word diff of the content plus a field-by-field comparison. */++ diff(entityId, fromNumber, toNumber, { mode = 'line' } = {}) {++ const a = q.byNumber.get(assertEntityId(entityId), fromNumber);++ const b = q.byNumber.get(entityId, toNumber);++ if (!a || !b) throw new PublishingError(404, 'revision.not_found', `No revision ${!a ? fromNumber : toNumber} of ${entityId}`);++ const fa = parseJson(a.fields, {});++ const fb = parseJson(b.fields, {});++ const fields = [];++ for (const key of [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort()) {++ if (stableStringify(fa[key]) !== stableStringify(fb[key])) fields.push({ field: key, from: fa[key] === undefined ? null : fa[key], to: fb[key] === undefined ? null : fb[key] });++ }++ return { entityId, from: fromNumber, to: toNumber, content: diffText(a.content, b.content, { mode }), fields };++ },++++ /** Save (create or replace) the owner's working copy. baseRevision defaults to the current head. */++ saveDraft({ entityId, owner, content, fields = {}, meta = {}, baseRevision } = {}) {++ assertEntityId(entityId);++ if (typeof owner !== 'string' || !owner) throw new TypeError('owner must be a subject string');++ if (typeof content !== 'string') throw new TypeError('content must be a string');++ const base = baseRevision == null ? headNumber(entityId) : assertRevisionNumber(baseRevision, 'baseRevision');++ q.upsertDraft.run({ entity_id: entityId, owner, base_revision: base, content, fields: JSON.stringify(fields || {}), meta: JSON.stringify(meta || {}), now: clock() });++ return store.getDraft(entityId, owner);++ },++++ getDraft(entityId, owner) {++ const row = q.draft.get(assertEntityId(entityId), String(owner));++ if (!row) return null;++ return {++ entityId: row.entity_id, owner: row.owner, baseRevision: row.base_revision, content: row.content,++ fields: parseJson(row.fields, {}), meta: parseJson(row.meta, {}),++ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),++ stale: row.base_revision !== headNumber(row.entity_id),++ };++ },++++ drafts(entityId) {++ return q.draftsFor.all(assertEntityId(entityId)).map((row) => store.getDraft(row.entity_id, row.owner));++ },++++ discardDraft(entityId, owner) { return q.deleteDraft.run(assertEntityId(entityId), String(owner)).changes > 0; },++++ /**++ * Turn a draft into a revision, based on the revision the draft started from. If someone++ * published in between, this is a 412 conflict and the draft is kept.++ */++ commitDraft({ entityId, owner, message = null, author } = {}) {++ return tx(() => {++ const d = q.draft.get(assertEntityId(entityId), String(owner));++ if (!d) throw new PublishingError(404, 'draft.not_found', `No draft of ${entityId} for ${owner}`);++ const out = insertRevision({++ entityId, expectedRevision: d.base_revision, content: d.content, fields: parseJson(d.fields, {}),++ meta: parseJson(d.meta, {}), author: author || owner, message, kind: 'edit',++ });++ q.deleteDraft.run(entityId, owner);++ return out;++ })();++ },++++ /**++ * Explicit, audited erasure of every revision and draft of one entity (legal removal).++ * The purge row stays as the record that it happened.++ */++ purgeEntity(entityId, { reason, purgedBy = null } = {}) {++ if (!reason) throw new TypeError('a purge needs a recorded reason');++ return tx(() => {++ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());++ const n = db.prepare(`DELETE FROM ${T} WHERE entity_id = ?`).run(entityId).changes;++ db.prepare(`DELETE FROM ${D} WHERE entity_id = ?`).run(entityId);++ return { deleted: n };++ })();++ },++ };++ return store;++}++++module.exports = { createRevisionStore, diffText, PublishingError, KINDS };nnew file mode 100644@@@ -0,0 +1,205 @@++'use strict';++/**++ * openvibe-publishing/schedule — scheduled publish/unpublish jobs that survive worker restarts.++ *++ * const { createScheduler } = require('openvibe-publishing/schedule');++ * const sched = createScheduler(db, { prefix: 'blog', leaseMs: 60000, now: () => Date.now() });++ * sched.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.parse('2026-10-01T09:00:00Z'), revision: 3 });++ * // in the product's worker loop:++ * await sched.runDue({ worker: 'blog-1', handler: async (job) => { publishPost(job.entityId, job.revision); } });++ *++ * Table <prefix>_schedule_jobs. Guarantees:++ * - schedule() is idempotent: the same (entity, action, runAt, revision) — or the same explicit++ * `key` — returns the existing job instead of adding a second one.++ * - claim() takes a lease (lease_owner, lease_until). A worker that dies mid-job leaves the lease++ * to expire, and the next claim re-runs the job: at-least-once. The handler must therefore be++ * re-run safe ("make revision N the published one" is; "append a publish" is not). Each job++ * carries a stable `key` the handler can use as its own idempotency key.++ * - complete()/fail() only count when the caller still holds the lease, so a worker whose lease++ * was taken over cannot overwrite the new owner's result.++ * - failures retry with backoff up to maxAttempts, then the job is 'failed' (the product emits++ * its `<product>.schedule.failed` event from runDue's summary).++ * The clock is injectable (`now`), so tests and replays are deterministic.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, newId, parseJson, assertEntityId } = require('./internal');++++const ACTIONS = ['publish', 'unpublish'];++const STATUSES = ['pending', 'running', 'done', 'failed', 'cancelled'];++++function shape(row) {++ if (!row) return null;++ return {++ id: row.id,++ key: row.idem_key,++ entityId: row.entity_id,++ action: row.action,++ revision: row.revision,++ runAt: new Date(row.run_at).toISOString(),++ status: row.status,++ attempts: row.attempts,++ leaseOwner: row.lease_owner,++ leaseUntil: row.lease_until == null ? null : new Date(row.lease_until).toISOString(),++ lastError: row.last_error,++ result: parseJson(row.result, null),++ createdAt: new Date(row.created_at).toISOString(),++ updatedAt: new Date(row.updated_at).toISOString(),++ };++}++++function toMs(v, name) {++ const t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : Date.parse(v);++ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a date, ISO string or epoch milliseconds`);++ return t;++}++++function createScheduler(db, { prefix, now, leaseMs = 60000, maxAttempts = 5, backoffMs = [5000, 30000, 120000, 600000] } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ if (!(leaseMs > 0)) throw new TypeError('leaseMs must be positive');++ const J = `${prefix}_schedule_jobs`;++++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${J} (++ id TEXT PRIMARY KEY,++ idem_key TEXT NOT NULL UNIQUE,++ entity_id TEXT NOT NULL,++ action TEXT NOT NULL CHECK (action IN ('publish','unpublish')),++ revision INTEGER,++ run_at INTEGER NOT NULL,++ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),++ attempts INTEGER NOT NULL DEFAULT 0,++ lease_owner TEXT,++ lease_until INTEGER,++ last_error TEXT,++ result TEXT,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++ );++ CREATE INDEX IF NOT EXISTS ${J}_due ON ${J} (status, run_at);++ CREATE INDEX IF NOT EXISTS ${J}_entity ON ${J} (entity_id, run_at);++ `);++++ const q = {++ byId: db.prepare(`SELECT * FROM ${J} WHERE id = ?`),++ byKey: db.prepare(`SELECT * FROM ${J} WHERE idem_key = ?`),++ insert: db.prepare(`INSERT OR IGNORE INTO ${J} (id, idem_key, entity_id, action, revision, run_at, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),++ due: db.prepare(`SELECT * FROM ${J} WHERE (status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now)++ ORDER BY run_at, id LIMIT @limit`),++ take: db.prepare(`UPDATE ${J} SET status = 'running', lease_owner = @worker, lease_until = @until, attempts = attempts + 1, updated_at = @now++ WHERE id = @id AND ((status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now))`),++ complete: db.prepare(`UPDATE ${J} SET status = 'done', result = @result, lease_owner = NULL, lease_until = NULL, last_error = NULL, updated_at = @now++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),++ retry: db.prepare(`UPDATE ${J} SET status = 'pending', run_at = @runAt, last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),++ dead: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),++ abandon: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = 'lease expired on every attempt', lease_owner = NULL, lease_until = NULL, updated_at = @now++ WHERE id = @id AND status = 'running' AND lease_until <= @now`),++ cancel: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending'`),++ cancelEntity: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE entity_id = ? AND status = 'pending' AND (? IS NULL OR action = ?)`),++ forEntity: db.prepare(`SELECT * FROM ${J} WHERE entity_id = ? ORDER BY run_at, id`),++ };++++ function backoff(attempts) {++ return backoffMs[Math.min(attempts - 1, backoffMs.length - 1)] || 0;++ }++++ function claimDue(worker, limit) {++ if (typeof worker !== 'string' || !worker) throw new TypeError('worker must be a non-empty string');++ return db.transaction(() => {++ const t = clock();++ const rows = q.due.all({ now: t, limit: Math.max(1, Math.min(100, limit)) });++ const jobs = [];++ const abandoned = [];++ for (const r of rows) {++ // A job whose workers keep dying stops after maxAttempts instead of looping forever.++ if (r.status === 'running' && r.attempts >= maxAttempts) {++ if (q.abandon.run({ id: r.id, now: t }).changes === 1) abandoned.push(shape(q.byId.get(r.id)));++ continue;++ }++ if (q.take.run({ id: r.id, worker, until: t + leaseMs, now: t }).changes === 1) jobs.push(shape(q.byId.get(r.id)));++ }++ return { jobs, abandoned };++ }).immediate();++ }++++ const api = {++ table: J,++ ACTIONS,++ STATUSES,++++ /** Idempotent. Returns { job, created }. */++ schedule({ entityId, action, runAt, revision = null, key } = {}) {++ assertEntityId(entityId);++ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);++ const at = toMs(runAt, 'runAt');++ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');++ const idem = key ? String(key) : `${entityId}:${action}:${at}:${revision == null ? '-' : revision}`;++ const t = clock();++ const info = q.insert.run(newId('job', t), idem, entityId, action, revision, at, t, t);++ return { job: shape(q.byKey.get(idem)), created: info.changes === 1 };++ },++++ get(id) { return shape(q.byId.get(String(id))); },++++ jobs(entityId) { return q.forEntity.all(assertEntityId(entityId)).map(shape); },++++ /** Cancel one pending job (a running job finishes or its lease expires; it cannot be cancelled mid-flight). */++ cancel(id) { return q.cancel.run(clock(), String(id)).changes > 0; },++++ /** Cancel every pending job of an entity, optionally only one action. Returns the count. */++ cancelPending(entityId, action = null) {++ return q.cancelEntity.run(clock(), assertEntityId(entityId), action, action).changes;++ },++++ /** Lease up to `limit` due jobs (pending and due, or running with an expired lease). */++ claim({ worker, limit = 10 } = {}) { return claimDue(worker, limit).jobs; },++++ /** true when recorded; false when this worker no longer holds the lease. */++ complete(id, worker, result = null) {++ return q.complete.run({ id: String(id), worker, result: result == null ? null : JSON.stringify(result), now: clock() }).changes === 1;++ },++++ /** Returns { status: 'retry' | 'failed' | 'lost' }. */++ fail(id, worker, error) {++ const row = q.byId.get(String(id));++ if (!row || row.status !== 'running' || row.lease_owner !== worker) return { status: 'lost' };++ const t = clock();++ const msg = String(error && error.message ? error.message : error || 'failed').slice(0, 1000);++ if (row.attempts >= maxAttempts) {++ q.dead.run({ id: row.id, worker, error: msg, now: t });++ return { status: 'failed', job: shape(q.byId.get(row.id)) };++ }++ q.retry.run({ id: row.id, worker, error: msg, runAt: t + backoff(row.attempts), now: t });++ return { status: 'retry', job: shape(q.byId.get(row.id)) };++ },++++ /**++ * Claim and run due jobs one by one. handler(job) may be async; a throw counts as a failure.++ * Returns { done: [job], retried: [job], failed: [job], lost: [job] }.++ */++ async runDue({ worker, handler, limit = 10 } = {}) {++ if (typeof handler !== 'function') throw new TypeError('handler must be a function');++ const { jobs, abandoned } = claimDue(worker, limit);++ const summary = { done: [], retried: [], failed: [...abandoned], lost: [] };++ for (const job of jobs) {++ try {++ const result = await handler(job);++ if (api.complete(job.id, worker, result === undefined ? null : result)) summary.done.push(api.get(job.id));++ else summary.lost.push(api.get(job.id));++ } catch (err) {++ const f = api.fail(job.id, worker, err);++ if (f.status === 'failed') summary.failed.push(f.job);++ else if (f.status === 'retry') summary.retried.push(f.job);++ else summary.lost.push(api.get(job.id));++ }++ }++ return summary;++ },++ };++ return api;++}++++module.exports = { createScheduler, ACTIONS, STATUSES, PublishingError };nnew file mode 100644@@@ -0,0 +1,570 @@++'use strict';++/**++ * openvibe-publishing/seo — the deterministic indexability gate and the discovery artifacts that++ * obey it: canonical URLs, history-aware redirects, meta/robots tags, sitemaps, RSS/Atom/JSON++ * Feed, and JSON-LD built only from fields the product actually has.++ *++ * const seo = require('openvibe-publishing/seo');++ * const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 3 },++ * { policy: { minWords: 150, requireSources: true } });++ * // → { indexable: true, listable: true, robots: 'index, follow', reasons: [], codes: [], canonical, gate }++ * seo.metaTags({ title, description, decision }) // robots come from the decision, never a default++ * seo.sitemap([{ loc, lastmod, decision }]) // only indexable entries++ * seo.atomFeed(channel, [{ id, url, title, published, decision, … }]) // only listable items++ * seo.structuredData.article({ headline, url, datePublished, authors }) // missing → omitted++ *++ * Generic primitives (head tag layout, sitemap XML, robots.txt, JSON-LD script escaping) are the++ * network's single implementation in openvibe-shared/seo; this module adds the publication rules.++ *++ * THE GATE. evaluate(facts, { policy, now }) is a pure function: the same facts, policy and `now`++ * always give the same decision, and every "no" names a stable reason code (REASONS, in evaluation++ * order). Each reason has an effect:++ * hidden — not indexable and not listable: never in search, sitemaps or feeds++ * noindex — may be served and listed in feeds, but never indexed or put in a sitemap++ * Unknown fact names throw (a typo must not silently pass the gate), and so do missing facts a++ * rule needs — there is no default that makes content indexable.++ */++const sharedSeo = require('openvibe-shared/seo');++const { assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');++const { wordCount } = require('./ssr');++++const GATE = 'openvibe-publishing/seo@1';++++const REASONS = Object.freeze([++ { code: 'deleted', effect: 'hidden', description: 'The resource is deleted.' },++ { code: 'takedown', effect: 'hidden', description: 'Removed by a moderation, legal or owner takedown.' },++ { code: 'private', effect: 'hidden', description: 'Visibility is private.' },++ { code: 'gated', effect: 'hidden', description: 'Only entitled members (e.g. VIP) may read it.' },++ { code: 'unlisted', effect: 'hidden', description: 'Reachable by link only; never listed or indexed.' },++ { code: 'draft', effect: 'hidden', description: 'Not published yet (draft or scheduled).' },++ { code: 'unpublished', effect: 'hidden', description: 'Was published, has been unpublished.' },++ { code: 'ai_generated_unreviewed', effect: 'hidden', description: 'AI-generated and no human review is recorded.' },++ { code: 'stub_provider', effect: 'hidden', description: 'Produced while only the AI stub provider was available, and not reviewed.' },++ { code: 'unreviewed_sensitive', effect: 'hidden', description: 'Sensitive or regulated category without a recorded review.' },++ { code: 'retracted', effect: 'noindex', description: 'Retracted; kept visible with its correction, not indexed.' },++ { code: 'expired', effect: 'noindex', description: 'Past its expiry time.' },++ { code: 'stale_price', effect: 'noindex', description: 'A price is shown without a recent enough observation time.' },++ { code: 'duplicate_of', effect: 'noindex', description: 'Duplicates another canonical page.' },++ { code: 'thin', effect: 'noindex', description: 'Below the product\'s minimum amount of text.' },++ { code: 'unsourced', effect: 'noindex', description: 'Fewer citations than the product requires.' },++ { code: 'unsupported_claims', effect: 'noindex', description: 'Contains claims flagged as lacking a source.' },++ { code: 'missing_canonical', effect: 'noindex', description: 'No canonical URL was provided.' },++ { code: 'noindex_requested', effect: 'noindex', description: 'The author or an editor asked for noindex.' },++]);++const REASON_BY_CODE = Object.freeze(Object.fromEntries(REASONS.map((r) => [r.code, r])));++++const STATES = ['draft', 'scheduled', 'published', 'unpublished', 'deleted'];++const VISIBILITIES = ['public', 'unlisted', 'private', 'gated'];++const FACT_KEYS = new Set([++ 'state', 'visibility', 'takedown', 'canonicalUrl', 'duplicateOf', 'text', 'wordCount', 'citationCount',++ 'unsupportedClaims', 'authorship', 'stubProvider', 'sensitive', 'category', 'sensitiveReviewed',++ 'expiresAt', 'price', 'retracted', 'noindex',++]);++const DEFAULT_POLICY = Object.freeze({++ minWords: 150,++ requireSources: false,++ minSources: 1,++ priceMaxAgeMs: 24 * 60 * 60 * 1000,++ sensitiveCategories: [],++});++++function timeOf(value, name) {++ const t = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);++ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a valid date`);++ return t;++}++++/**++ * The indexability decision. facts (see FACT_KEYS):++ * state (required) draft | scheduled | published | unpublished | deleted++ * visibility (required) public | unlisted | private | gated++ * canonicalUrl, duplicateOf (URL of the original this duplicates)++ * text or wordCount (required when policy.minWords > 0)++ * citationCount (required when policy.requireSources)++ * unsupportedClaims number of claims flagged unsourced++ * authorship { mode: human|ai|hybrid|imported, reviewed: bool } (authorship.gateFacts)++ * stubProvider, sensitive, category, sensitiveReviewed, takedown, retracted, noindex (booleans/strings)++ * expiresAt instant; price { observedAt } — both need options.now++ */++function evaluate(facts, { policy = {}, now } = {}) {++ if (!facts || typeof facts !== 'object') throw new TypeError('facts must be an object');++ for (const k of Object.keys(facts)) if (!FACT_KEYS.has(k)) throw new TypeError(`unknown fact "${k}"`);++ const p = { ...DEFAULT_POLICY, ...policy };++ if (!STATES.includes(facts.state)) throw new TypeError(`facts.state must be one of ${STATES.join(', ')}`);++ if (!VISIBILITIES.includes(facts.visibility)) throw new TypeError(`facts.visibility must be one of ${VISIBILITIES.join(', ')}`);++ const nowMs = now == null ? null : timeOf(now, 'now');++ const needNow = (what) => { if (nowMs == null) throw new TypeError(`${what} needs options.now (the gate has no hidden clock)`); return nowMs; };++++ const found = new Map();++ const hit = (code, detail = null) => { if (!found.has(code)) found.set(code, detail); };++++ if (facts.state === 'deleted') hit('deleted');++ if (facts.takedown) hit('takedown');++ if (facts.visibility === 'private') hit('private');++ if (facts.visibility === 'gated') hit('gated');++ if (facts.visibility === 'unlisted') hit('unlisted');++ if (facts.state === 'draft' || facts.state === 'scheduled') hit('draft', facts.state);++ if (facts.state === 'unpublished') hit('unpublished');++++ const a = facts.authorship;++ if (a != null) {++ if (typeof a !== 'object' || !a.mode) throw new TypeError('facts.authorship must be { mode, reviewed }');++ if (a.mode === 'ai' && !a.reviewed) hit('ai_generated_unreviewed');++ }++ const reviewed = Boolean(a && a.reviewed);++ if (facts.stubProvider && !reviewed) hit('stub_provider');++++ const sensitive = Boolean(facts.sensitive) || (facts.category != null && p.sensitiveCategories.includes(facts.category));++ if (sensitive && !facts.sensitiveReviewed) hit('unreviewed_sensitive', facts.category != null ? String(facts.category) : null);++++ if (facts.retracted) hit('retracted');++ if (facts.expiresAt != null && timeOf(facts.expiresAt, 'expiresAt') <= needNow('expiresAt')) hit('expired', new Date(timeOf(facts.expiresAt, 'expiresAt')).toISOString());++ if (facts.price != null) {++ const observed = facts.price && facts.price.observedAt;++ if (observed == null || observed === '') hit('stale_price', 'price has no observation time');++ else if (needNow('price.observedAt') - timeOf(observed, 'price.observedAt') > p.priceMaxAgeMs) hit('stale_price', `observed ${new Date(timeOf(observed, 'price.observedAt')).toISOString()}`);++ }++ if (facts.duplicateOf && facts.duplicateOf !== facts.canonicalUrl) hit('duplicate_of', String(facts.duplicateOf));++++ if (p.minWords > 0) {++ let words;++ if (Number.isInteger(facts.wordCount)) words = facts.wordCount;++ else if (typeof facts.text === 'string') words = wordCount(facts.text);++ else throw new TypeError('facts.text or facts.wordCount is required when policy.minWords > 0');++ if (words < p.minWords) hit('thin', `${words} of ${p.minWords} words`);++ }++ if (p.requireSources) {++ if (!Number.isInteger(facts.citationCount)) throw new TypeError('facts.citationCount is required when policy.requireSources');++ if (facts.citationCount < p.minSources) hit('unsourced', `${facts.citationCount} of ${p.minSources} sources`);++ }++ if (Number(facts.unsupportedClaims) > 0) hit('unsupported_claims', `${Number(facts.unsupportedClaims)} claims`);++ if (!facts.canonicalUrl) hit('missing_canonical');++ if (facts.noindex) hit('noindex_requested');++++ const reasons = REASONS.filter((r) => found.has(r.code)).map((r) => ({ code: r.code, effect: r.effect, detail: found.get(r.code) }));++ const hidden = reasons.some((r) => r.effect === 'hidden');++ const indexable = reasons.length === 0;++ return {++ gate: GATE,++ indexable,++ listable: !hidden,++ robots: indexable ? 'index, follow' : hidden ? 'noindex, nofollow' : 'noindex, follow',++ canonical: found.has('duplicate_of') ? String(facts.duplicateOf) : (facts.canonicalUrl || null),++ reasons,++ codes: reasons.map((r) => r.code),++ };++}++++function requireDecision(decision, where) {++ if (!decision || typeof decision.indexable !== 'boolean' || typeof decision.robots !== 'string') {++ throw new TypeError(`${where} needs the gate's decision (seo.evaluate): publication state, not rendering, decides indexability`);++ }++ return decision;++}++++// ---- Canonical URLs and redirects --------------------------------------------------------------++++/**++ * Absolute canonical URL: https/http origin + path segments (each encoded), no fragment, no++ * trailing slash (except the root), and only whitelisted query parameters, sorted.++ * canonicalUrl('https://openvibe.wiki', ['spaces', 'food', 'pages', 'Crème brûlée'])++ * canonicalUrl('https://openvibe.blog', '/alex/posts/bread?page=2&utm_source=x', { query: ['page'] })++ */++function canonicalUrl(origin, path = '/', { query = [] } = {}) {++ let base;++ try { base = new URL(String(origin)); } catch { throw new TypeError('origin must be an absolute URL'); }++ if (base.protocol !== 'https:' && base.protocol !== 'http:') throw new TypeError('origin must be http(s)');++ let pathname;++ let search = '';++ 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);++ 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));++ if (keep.length) search = '?' + new URLSearchParams(keep).toString();++ }++ pathname = pathname.replace(/\/{2,}/g, '/');++ if (pathname.length > 1) pathname = pathname.replace(/\/+$/, '');++ return `${base.protocol}//${base.host.toLowerCase()}${pathname}${search}`;++}++++function normalizePath(p) {++ const s = String(p == null ? '' : p).split('#')[0].split('?')[0];++ if (!s.startsWith('/') || s.startsWith('//')) throw new TypeError('paths must be site-relative and start with one "/"');++ return s.length > 1 ? s.replace(/\/+$/, '') : s;++}++++/**++ * History-aware redirects in <prefix>_redirects: every path an entity ever had points at the++ * entity, and resolve() answers with the entity's *current* path, so chains collapse into one 301.++ */++function createRedirectStore(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const R = `${prefix}_redirects`;++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${R} (++ from_path TEXT PRIMARY KEY,++ entity_id TEXT NOT NULL,++ reason TEXT,++ created_at INTEGER NOT NULL++ );++ CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id);++ `);++ const q = {++ put: db.prepare(`INSERT INTO ${R} (from_path, entity_id, reason, created_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (from_path) DO UPDATE SET entity_id = excluded.entity_id, reason = excluded.reason, created_at = excluded.created_at`),++ del: db.prepare(`DELETE FROM ${R} WHERE from_path = ?`),++ get: db.prepare(`SELECT * FROM ${R} WHERE from_path = ?`),++ history: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY created_at, from_path`),++ };++ return {++ table: R,++ /** An entity moved from oldPath to newPath (slug rename, space move). */++ recordMove(entityId, oldPath, newPath, { reason = 'renamed' } = {}) {++ assertEntityId(entityId);++ const from = normalizePath(oldPath);++ const to = normalizePath(newPath);++ db.transaction(() => {++ q.del.run(to); // the new path is live: it must not redirect anywhere++ if (from !== to) q.put.run(from, entityId, reason, clock());++ })();++ },++ /** Claim a path for a (new) entity: removes any redirect that used it. */++ release(path) { return q.del.run(normalizePath(path)).changes > 0; },++ /**++ * currentPath(entityId) → the entity's live path, or null when it is gone.++ * → null (not a historical path) | { status: 301, location } | { status: 410 }++ */++ resolve(path, { currentPath } = {}) {++ if (typeof currentPath !== 'function') throw new TypeError('currentPath(entityId) is required');++ let from;++ try { from = normalizePath(path); } catch { return null; }++ const row = q.get.get(from);++ if (!row) return null;++ const target = currentPath(row.entity_id);++ if (target == null) return { status: 410, entityId: row.entity_id };++ const location = normalizePath(target);++ if (location === from) return null;++ return { status: 301, location, entityId: row.entity_id };++ },++ history(entityId) { return q.history.all(assertEntityId(entityId)).map((r) => ({ path: r.from_path, reason: r.reason, at: new Date(r.created_at).toISOString() })); },++ };++}++++// ---- Meta tags ---------------------------------------------------------------------------------++++/** <meta name="robots"> from the decision. */++function robotsMeta(decision) {++ return `<meta name="robots" content="${sharedSeo.esc(requireDecision(decision, 'robotsMeta').robots)}">`;++}++++/** X-Robots-Tag header value for non-HTML representations (JSON, feeds of private collections). */++function xRobotsTag(decision) { return requireDecision(decision, 'xRobotsTag').robots; }++++/**++ * The page's <head> SEO block (openvibe-shared/seo headTags) with robots and canonical taken from++ * the decision. Options are headTags' (title, description, image, type, siteName, jsonLd, …);++ * the canonical defaults to decision.canonical (the original, for a duplicate).++ */++function metaTags({ decision, canonical, ...rest } = {}) {++ requireDecision(decision, 'metaTags');++ return sharedSeo.headTags({ ...rest, canonical: decision.canonical || canonical, robots: decision.robots });++}++++// ---- Sitemaps ----------------------------------------------------------------------------------++++/**++ * entries: [{ loc (absolute), lastmod? (a real revision time), decision, images? }]++ * → { files: [xml], count, skipped: [{ loc, codes }] }. Only indexable entries are written;++ * a missing lastmod is left out, never filled with "now". Split every maxUrls (50,000).++ */++function sitemap(entries = [], { maxUrls = 50000 } = {}) {++ const keep = [];++ const skipped = [];++ for (const e of entries) {++ if (!e || !e.loc) throw new TypeError('every sitemap entry needs loc');++ const d = requireDecision(e.decision, 'sitemap entries');++ if (!d.indexable || !/^https?:\/\//i.test(e.loc)) { skipped.push({ loc: e.loc, codes: d.codes || [] }); continue; }++ const u = { loc: d.canonical || e.loc };++ if (e.lastmod != null && e.lastmod !== '') u.lastmod = new Date(timeOf(e.lastmod, 'lastmod')).toISOString();++ if (Array.isArray(e.images) && e.images.length) u.images = e.images;++ keep.push(u);++ }++ const size = Math.max(1, Math.min(50000, Math.floor(maxUrls) || 50000));++ const files = [];++ for (let i = 0; i < keep.length; i += size) files.push(sharedSeo.sitemapXml(keep.slice(i, i + size)));++ if (!files.length) files.push(sharedSeo.sitemapXml([]));++ return { files, count: keep.length, skipped };++}++++/** Sitemap index over per-section sitemaps: [{ loc, lastmod? }]. */++function sitemapIndex(maps = []) { return sharedSeo.sitemapIndexXml(maps); }++++/** robots.txt (openvibe-shared/seo), with the sitemap locations declared. */++function robotsTxt(opts = {}) { return sharedSeo.robotsTxt(opts); }++++// ---- Feeds -------------------------------------------------------------------------------------++++const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))++ // Characters XML 1.0 forbids.++ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');++const isoOr = (v, name) => (v == null || v === '' ? null : new Date(timeOf(v, name)).toISOString());++++function feedItems(items) {++ const out = [];++ const skipped = [];++ for (const it of items) {++ if (!it || it.id == null || !it.url) throw new TypeError('every feed item needs a stable id and a canonical url');++ const d = requireDecision(it.decision, 'feed items');++ if (!d.listable) { skipped.push({ id: String(it.id), codes: d.codes }); continue; }++ out.push({++ id: String(it.id), url: it.url, title: it.title == null ? null : String(it.title),++ summary: it.summary == null ? null : String(it.summary), contentHtml: it.contentHtml == null ? null : String(it.contentHtml),++ published: isoOr(it.published, 'published'), updated: isoOr(it.updated, 'updated'),++ authors: (it.authors || []).filter((a) => a && a.name).map((a) => ({ name: String(a.name), url: a.url || null })),++ tags: (it.tags || []).filter((t) => t != null && t !== '').map(String),++ image: it.image || null,++ });++ }++ return { items: out, skipped };++}++++function requireChannel(c, keys) {++ for (const k of keys) if (!c || !c[k]) throw new TypeError(`feed channel needs ${k}`);++}++++/** RSS 2.0. channel: { title, link, description, feedUrl?, language?, updated? }. */++function rssFeed(channel, items = []) {++ requireChannel(channel, ['title', 'link', 'description']);++ const { items: list } = feedItems(items);++ const out = ['<?xml version="1.0" encoding="UTF-8"?>',++ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">',++ '<channel>', `<title>${xml(channel.title)}</title>`, `<link>${xml(channel.link)}</link>`, `<description>${xml(channel.description)}</description>`];++ if (channel.feedUrl) out.push(`<atom:link href="${xml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`);++ if (channel.language) out.push(`<language>${xml(channel.language)}</language>`);++ if (channel.updated) out.push(`<lastBuildDate>${new Date(timeOf(channel.updated, 'channel.updated')).toUTCString()}</lastBuildDate>`);++ for (const it of list) {++ const parts = [];++ if (it.title) parts.push(`<title>${xml(it.title)}</title>`);++ parts.push(`<link>${xml(it.url)}</link>`, `<guid isPermaLink="false">${xml(it.id)}</guid>`);++ if (it.summary) parts.push(`<description>${xml(it.summary)}</description>`);++ if (it.contentHtml) parts.push(`<content:encoded>${xml(it.contentHtml)}</content:encoded>`);++ if (it.published) parts.push(`<pubDate>${new Date(it.published).toUTCString()}</pubDate>`);++ for (const a of it.authors) parts.push(`<dc:creator>${xml(a.name)}</dc:creator>`);++ for (const t of it.tags) parts.push(`<category>${xml(t)}</category>`);++ out.push(`<item>${parts.join('')}</item>`);++ }++ out.push('</channel>', '</rss>', '');++ return out.join('\n');++}++++const atomId = (id) => (/^[a-z][a-z0-9+.-]*:/i.test(id) ? id : `urn:openvibe:${id}`);++++/**++ * Atom 1.0. channel: { title, link, feedUrl, id?, updated?, author? { name, url }, subtitle? }.++ * Entries with neither `updated` nor `published` are left out (Atom requires a date and this++ * module will not invent one). The feed's <updated> is channel.updated or the newest entry.++ */++function atomFeed(channel, items = []) {++ requireChannel(channel, ['title', 'link', 'feedUrl']);++ const { items: list } = feedItems(items);++ const dated = list.filter((it) => it.updated || it.published);++ const newest = dated.map((it) => it.updated || it.published).sort().pop();++ const updated = channel.updated ? new Date(timeOf(channel.updated, 'channel.updated')).toISOString() : newest;++ if (!updated) throw new TypeError('an Atom feed needs channel.updated or at least one dated entry');++ const out = ['<?xml version="1.0" encoding="UTF-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">',++ `<id>${xml(atomId(channel.id || channel.feedUrl))}</id>`, `<title>${xml(channel.title)}</title>`,++ `<link rel="alternate" type="text/html" href="${xml(channel.link)}"/>`, `<link rel="self" type="application/atom+xml" href="${xml(channel.feedUrl)}"/>`,++ `<updated>${updated}</updated>`];++ if (channel.subtitle) out.push(`<subtitle>${xml(channel.subtitle)}</subtitle>`);++ if (channel.author && channel.author.name) out.push(`<author><name>${xml(channel.author.name)}</name>${channel.author.url ? `<uri>${xml(channel.author.url)}</uri>` : ''}</author>`);++ for (const it of dated) {++ const parts = [`<id>${xml(atomId(it.id))}</id>`, `<title>${xml(it.title || '')}</title>`, `<link rel="alternate" type="text/html" href="${xml(it.url)}"/>`,++ `<updated>${it.updated || it.published}</updated>`];++ if (it.published) parts.push(`<published>${it.published}</published>`);++ for (const a of it.authors) parts.push(`<author><name>${xml(a.name)}</name>${a.url ? `<uri>${xml(a.url)}</uri>` : ''}</author>`);++ for (const t of it.tags) parts.push(`<category term="${xml(t)}"/>`);++ if (it.summary) parts.push(`<summary>${xml(it.summary)}</summary>`);++ if (it.contentHtml) parts.push(`<content type="html">${xml(it.contentHtml)}</content>`);++ out.push(`<entry>${parts.join('')}</entry>`);++ }++ out.push('</feed>', '');++ return out.join('\n');++}++++/** JSON Feed 1.1 (as an object; JSON.stringify it). channel: { title, link, feedUrl, description?, language?, authors? }. */++function jsonFeed(channel, items = []) {++ requireChannel(channel, ['title', 'link', 'feedUrl']);++ const { items: list } = feedItems(items);++ const feed = { version: 'https://jsonfeed.org/version/1.1', title: String(channel.title), home_page_url: channel.link, feed_url: channel.feedUrl };++ if (channel.description) feed.description = String(channel.description);++ if (channel.language) feed.language = String(channel.language);++ const chAuthors = (channel.authors || []).filter((a) => a && a.name).map((a) => compact({ name: a.name, url: a.url }));++ if (chAuthors.length) feed.authors = chAuthors;++ feed.items = list.map((it) => compact({++ id: it.id, url: it.url, title: it.title,++ content_html: it.contentHtml || undefined,++ content_text: it.contentHtml ? undefined : (it.summary || it.title || ''),++ summary: it.summary, image: it.image,++ date_published: it.published, date_modified: it.updated,++ authors: it.authors.length ? it.authors.map((a) => compact({ name: a.name, url: a.url })) : undefined,++ tags: it.tags.length ? it.tags : undefined,++ }, { keepEmptyString: ['content_text'] }));++ return feed;++}++++/** <link rel="alternate"> tags that advertise feeds: [{ type: 'rss'|'atom'|'json', href, title }]. */++function feedLinks(feeds = []) {++ const types = { rss: 'application/rss+xml', atom: 'application/atom+xml', json: 'application/feed+json' };++ return feeds.filter((f) => f && f.href && types[f.type])++ .map((f) => `<link rel="alternate" type="${types[f.type]}" href="${sharedSeo.esc(f.href)}"${f.title ? ` title="${sharedSeo.esc(f.title)}"` : ''}>`).join('\n');++}++++// ---- Structured data ---------------------------------------------------------------------------++++/**++ * Recursively drops null / undefined / '' / NaN / empty arrays / empty objects, and objects whose++ * only keys are '@type' / '@context' / '@id'. Nothing is ever added.++ */++function compact(value, { keepEmptyString = [] } = {}) {++ const walk = (v, key) => {++ if (v == null) return undefined;++ if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;++ if (typeof v === 'string') return v === '' && !keepEmptyString.includes(key) ? undefined : v;++ if (Array.isArray(v)) { const a = v.map((x) => walk(x, key)).filter((x) => x !== undefined); return a.length ? a : undefined; }++ if (typeof v === 'object') {++ const o = {};++ for (const [k, x] of Object.entries(v)) { const w = walk(x, k); if (w !== undefined) o[k] = w; }++ const meaningful = Object.keys(o).filter((k) => k !== '@type' && k !== '@context' && k !== '@id');++ return meaningful.length ? o : undefined;++ }++ return v;++ };++ return walk(value, null);++}++++const iso = (v, name) => (v == null || v === '' ? undefined : new Date(timeOf(v, name)).toISOString());++const num = (v) => (v == null || v === '' ? undefined : (Number.isFinite(Number(v)) ? Number(v) : undefined));++const CTX = 'https://schema.org';++++function person(a) {++ if (!a) return undefined;++ if (typeof a === 'string') return { '@type': 'Person', name: a };++ return { '@type': a.type === 'Organization' ? 'Organization' : 'Person', name: a.name, url: a.url };++}++++function org(o) {++ if (!o) return undefined;++ return { '@type': 'Organization', name: o.name, url: o.url, logo: o.logo ? { '@type': 'ImageObject', url: o.logo } : undefined };++}++++/** schema.org Rating, or undefined unless a numeric value is provided. No default bestRating. */++function rating(r) {++ if (!r || num(r.value) === undefined) return undefined;++ return { '@type': 'Rating', ratingValue: num(r.value), bestRating: num(r.best), worstRating: num(r.worst) };++}++++/** AggregateRating only with a numeric value AND a positive count of ratings or reviews. */++function aggregateRating(r) {++ if (!r || num(r.value) === undefined) return undefined;++ const ratingCount = Number.isInteger(r.count) && r.count > 0 ? r.count : undefined;++ const reviewCount = Number.isInteger(r.reviewCount) && r.reviewCount > 0 ? r.reviewCount : undefined;++ if (ratingCount === undefined && reviewCount === undefined) return undefined;++ return { '@type': 'AggregateRating', ratingValue: num(r.value), ratingCount, reviewCount, bestRating: num(r.best), worstRating: num(r.worst) };++}++++const AVAILABILITY = {++ in_stock: 'https://schema.org/InStock', out_of_stock: 'https://schema.org/OutOfStock', preorder: 'https://schema.org/PreOrder',++ discontinued: 'https://schema.org/Discontinued', limited: 'https://schema.org/LimitedAvailability', sold_out: 'https://schema.org/SoldOut',++ online_only: 'https://schema.org/OnlineOnly', in_store_only: 'https://schema.org/InStoreOnly',++};++const CONDITION = { new: 'https://schema.org/NewCondition', used: 'https://schema.org/UsedCondition', refurbished: 'https://schema.org/RefurbishedCondition', damaged: 'https://schema.org/DamagedCondition' };++++/** Offer only with a numeric price and a 3-letter currency. Availability only from a known value. */++function offer(o) {++ if (!o || num(o.price) === undefined || !/^[A-Z]{3}$/.test(String(o.priceCurrency || ''))) return undefined;++ return {++ '@type': 'Offer', price: num(o.price), priceCurrency: o.priceCurrency, url: o.url,++ availability: AVAILABILITY[o.availability], itemCondition: CONDITION[o.condition],++ priceValidUntil: o.priceValidUntil ? iso(o.priceValidUntil, 'priceValidUntil').slice(0, 10) : undefined,++ validFrom: iso(o.validFrom, 'validFrom'), validThrough: iso(o.validThrough, 'validThrough'),++ seller: o.seller ? org(o.seller) : undefined,++ };++}++++function citationList(list) {++ return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));++}++++const structuredData = {++ /**++ * Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.++ * { type, headline, url, description, datePublished, dateModified, authors: [ {name,url,type} | 'Name' ],++ * publisher: { name, url, logo }, image, section, keywords[], inLanguage, citations: [{ url, title }], wordCount }++ */++ article(a = {}) {++ if (!a.headline || !a.url) return null;++ const type = ['Article', 'BlogPosting', 'NewsArticle'].includes(a.type) ? a.type : 'Article';++ return compact({++ '@context': CTX, '@type': type, '@id': `${a.url}#article`, headline: sharedSeo.clip(a.headline, 110), url: a.url,++ mainEntityOfPage: a.url, description: a.description, datePublished: iso(a.datePublished, 'datePublished'),++ dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),++ image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,++ inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,++ });++ },++++ /** A single review. Needs the reviewed item's name; the rating appears only if provided. */++ review(r = {}) {++ if (!r.itemReviewed || !r.itemReviewed.name) return null;++ return compact({++ '@context': CTX, '@type': 'Review', url: r.url,++ itemReviewed: { '@type': r.itemReviewed.type || 'Thing', name: r.itemReviewed.name, url: r.itemReviewed.url },++ author: person(r.author), datePublished: iso(r.datePublished, 'datePublished'), reviewBody: r.body,++ reviewRating: rating(r.rating), publisher: org(r.publisher),++ });++ },++++ /** Product with offers / aggregateRating only where the data exists. Missing name → null. */++ product(p = {}) {++ if (!p.name) return null;++ return compact({++ '@context': CTX, '@type': 'Product', name: p.name, url: p.url, description: p.description, image: p.image,++ brand: p.brand ? { '@type': 'Brand', name: p.brand } : undefined, sku: p.sku, gtin: p.gtin, mpn: p.mpn,++ offers: (p.offers || []).map(offer), aggregateRating: aggregateRating(p.aggregateRating),++ });++ },++++ /** An entity with an aggregate rating (Reviews). Missing name → null; missing counts → no rating. */++ ratedThing(t = {}) {++ if (!t.name) return null;++ return compact({ '@context': CTX, '@type': t.type || 'Thing', name: t.name, url: t.url, description: t.description, aggregateRating: aggregateRating(t.aggregateRating) });++ },++++ breadcrumbs(items = []) {++ const list = items.filter((it) => it && it.name);++ if (!list.length) return null;++ return compact({ '@context': CTX, '@type': 'BreadcrumbList', itemListElement: list.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) });++ },++++ webPage(w = {}) {++ if (!w.url) return null;++ return compact({ '@context': CTX, '@type': w.type || 'WebPage', '@id': `${w.url}#page`, url: w.url, name: w.name, description: w.description, dateModified: iso(w.dateModified, 'dateModified'), inLanguage: w.inLanguage });++ },++};++++/** <script type="application/ld+json"> (safe against </script> break-out); '' for null. */++function jsonLdScript(obj) { return obj ? sharedSeo.jsonLdTag(obj) : ''; }++++module.exports = {++ GATE, REASONS, REASON_BY_CODE, STATES, VISIBILITIES, DEFAULT_POLICY,++ evaluate, canonicalUrl, createRedirectStore, normalizePath,++ metaTags, robotsMeta, xRobotsTag,++ sitemap, sitemapIndex, robotsTxt,++ rssFeed, atomFeed, jsonFeed, feedLinks,++ structuredData, jsonLdScript, compact,++};nnew file mode 100644@@@ -0,0 +1,253 @@++'use strict';++/**++ * openvibe-publishing/ssr — small helpers for server-rendered pages that are useful without JS.++ *++ * const ssr = require('openvibe-publishing/ssr');++ * ssr.html`<h1>${title}</h1>${ssr.raw(ssr.renderMarkdown(body))}` // values escaped unless raw()++ * ssr.paginate({ page: 2, perPage: 20, total: 95, href: (p) => `/tags/bread?page=${p}` })++ * ssr.paginationHtml(pager) ssr.breadcrumbsHtml([{ name, url }, …])++ * ssr.diffHtml(revs.diff(id, 1, 2, { mode: 'word' }).content)++ * ssr.timeTag('2026-09-20T10:00:00Z') // '' when the date is unknown — never a guessed date++ *++ * Markdown safety model: the source is never trusted as HTML. Text is escaped before any tag is++ * written, and the only tags produced are this file's own (p, br, h2–h6, blockquote, ul/ol/li, hr,++ * pre/code, strong, em, del, a). Links keep http(s), mailto, same-site paths and #fragments only.++ * No images (media goes through openvibe-publishing/media by object id), tables or raw HTML.++ * The Markdown renderer follows the approach of OpenVibe.Community's server/render/markdown.js (MIT),++ * without the syntax highlighter, with configurable link rel and heading level.++ */++++const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };++function escapeHtml(value) {++ return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ESC[c]);++}++const escapeAttr = escapeHtml;++++class Raw {++ constructor(s) { this.html = String(s == null ? '' : s); }++ toString() { return this.html; }++}++/** Mark trusted HTML (output of this module, or your own template) so html`` does not escape it. */++function raw(s) { return s instanceof Raw ? s : new Raw(s); }++++function interp(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.html;++ if (Array.isArray(v)) return v.map(interp).join('');++ return escapeHtml(v);++}++++/** Tagged template: every interpolated value is escaped unless wrapped in raw(). Returns a Raw. */++function html(strings, ...values) {++ let out = strings[0];++ for (let i = 0; i < values.length; i++) out += interp(values[i]) + strings[i + 1];++ return new Raw(out);++}++++// ---- Markdown ----------------------------------------------------------------------------------++++const MAX_SOURCE = 200000;++const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})\s*$/;++const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;++const QUOTE = /^ {0,3}>\s?(.*)$/;++const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;++const UL = /^ {0,3}[-*+]\s+(.*)$/;++const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;++const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;++++function decodeBasic(s) {++ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));++}++++function emphasis(s) {++ return s++ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')++ .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')++ .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')++ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')++ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');++}++++function inline(src, opts) {++ const held = [];++ const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;++ const relAttr = opts.rel ? ` rel="${escapeHtml(opts.rel)}"` : '';++ let s = String(src).replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, (_m, _t, code) => hold(`<code>${escapeHtml(code)}</code>`));++ s = escapeHtml(s);++ s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {++ const href = decodeBasic(url);++ if (!SAFE_URL.test(href)) return m;++ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${emphasis(text)}</a>`);++ });++ s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {++ let url = m;++ let tail = '';++ for (;;) {++ const ent = url.match(/&(quot|#39|gt|lt);$/);++ if (ent) { tail = ent[0] + tail; url = url.slice(0, -ent[0].length); continue; }++ if (/[.,;:!?)\]'"]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); continue; }++ break;++ }++ if (url.length <= 8) return m;++ const href = decodeBasic(url);++ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${escapeHtml(href)}</a>`) + tail;++ });++ s = emphasis(s).replace(/\n/g, '<br>\n');++ for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => held[Number(n)] || '');++ return s;++}++++function startsBlock(line) {++ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);++}++++function blocks(lines, depth, opts) {++ const out = [];++ let i = 0;++ while (i < lines.length) {++ const line = lines[i];++ if (!line.trim()) { i++; continue; }++ let m = line.match(FENCE);++ if (m) {++ const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);++ const body = [];++ i++;++ while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);++ i++;++ const lang = m[2] ? ` class="language-${escapeHtml(m[2].toLowerCase())}"` : '';++ out.push(`<pre><code${lang}>${escapeHtml(body.join('\n'))}</code></pre>`);++ continue;++ }++ if (HR.test(line)) { out.push('<hr>'); i++; continue; }++ m = line.match(HEADING);++ if (m) {++ const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);++ out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);++ i++;++ continue;++ }++ if (QUOTE.test(line)) {++ const inner = [];++ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);++ out.push(depth < 3 ? `<blockquote>${blocks(inner, depth + 1, opts)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'), opts)}</p></blockquote>`);++ continue;++ }++ if (UL.test(line) || OL.test(line)) {++ const ordered = !UL.test(line);++ const re = ordered ? OL : UL;++ const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;++ const items = [];++ while (i < lines.length && lines[i].trim()) {++ const mm = lines[i].match(re);++ if (mm) items.push(ordered ? mm[2] : mm[1]);++ else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;++ else break;++ i++;++ }++ const tag = ordered ? 'ol' : 'ul';++ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it, opts)}</li>`).join('')}</${tag}>`);++ continue;++ }++ const para = [];++ while (i < lines.length && lines[i].trim() && (!para.length || !startsBlock(lines[i]))) para.push(lines[i++]);++ out.push(`<p>${inline(para.join('\n'), opts)}</p>`);++ }++ return out.join('\n');++}++++/**++ * Markdown → safe HTML. Options: rel (link rel, default 'nofollow ugc noopener' — pass 'noopener'++ * for editorial text you vouch for), headingShift (default 1: '#' becomes <h2>, the page owns <h1>).++ */++function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift = 1 } = {}) {++ const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');++ return blocks(text.split('\n'), 0, { rel, headingShift });++}++++/** Markdown → plain text for descriptions, feeds and index documents (the caller escapes it). */++function markdownToText(source, max = Infinity) {++ const t = String(source == null ? '' : source).replace(/\u0000/g, '')++ .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')++ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1')++ .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')++ .replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')++ .replace(/(\*\*|__|~~|`)/g, '')++ .replace(/\s+/g, ' ')++ .trim();++ return t.length > max ? `${t.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : t;++}++++/** Words in a text (Unicode letters/numbers runs) — the SEO gate's thin-content measure. */++function wordCount(text) {++ const m = String(text == null ? '' : text).match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu);++ return m ? m.length : 0;++}++++// ---- Pagination, breadcrumbs, diff, time -------------------------------------------------------++++/**++ * Server pagination. href(page) builds each page's URL. Out-of-range pages are clamped and++ * reported (`outOfRange: true`) so the caller can answer 404 instead of an empty page.++ */++function paginate({ page = 1, perPage = 20, total = 0, href } = {}) {++ if (typeof href !== 'function') throw new TypeError('href(page) is required');++ const per = Math.max(1, Math.min(500, Math.floor(Number(perPage)) || 20));++ const count = Math.max(0, Math.floor(Number(total)) || 0);++ const pages = Math.max(1, Math.ceil(count / per));++ const asked = Math.floor(Number(page)) || 1;++ const current = Math.min(Math.max(1, asked), pages);++ const window = [];++ for (let p = Math.max(1, current - 2); p <= Math.min(pages, current + 2); p++) window.push({ page: p, href: href(p), current: p === current });++ return {++ page: current, perPage: per, total: count, pages, offset: (current - 1) * per, limit: per,++ outOfRange: asked !== current,++ prev: current > 1 ? { page: current - 1, href: href(current - 1) } : null,++ next: current < pages ? { page: current + 1, href: href(current + 1) } : null,++ first: { page: 1, href: href(1) }, last: { page: pages, href: href(pages) },++ window,++ };++}++++/** Plain links (rel=prev/next) that work without JavaScript. */++function paginationHtml(p, { label = 'Pagination' } = {}) {++ if (!p || p.pages <= 1) return '';++ const parts = [];++ if (p.prev) parts.push(`<a href="${escapeHtml(p.prev.href)}" rel="prev">← Newer</a>`);++ if (p.window[0] && p.window[0].page > 1) parts.push(`<a href="${escapeHtml(p.first.href)}">1</a>`, p.window[0].page > 2 ? '<span aria-hidden="true">…</span>' : '');++ for (const w of p.window) parts.push(w.current ? `<span aria-current="page">${w.page}</span>` : `<a href="${escapeHtml(w.href)}">${w.page}</a>`);++ const lastShown = p.window[p.window.length - 1];++ if (lastShown && lastShown.page < p.pages) parts.push(lastShown.page < p.pages - 1 ? '<span aria-hidden="true">…</span>' : '', `<a href="${escapeHtml(p.last.href)}">${p.pages}</a>`);++ if (p.next) parts.push(`<a href="${escapeHtml(p.next.href)}" rel="next">Older →</a>`);++ return `<nav class="ov-pagination" aria-label="${escapeHtml(label)}">${parts.filter(Boolean).join(' ')}</nav>`;++}++++/** Breadcrumb trail: items [{ name, url }], the last one is the current page. */++function breadcrumbsHtml(items = [], { label = 'Breadcrumb' } = {}) {++ const list = items.filter((it) => it && it.name);++ if (!list.length) return '';++ const li = list.map((it, i) => (i === list.length - 1 || !it.url++ ? `<li${i === list.length - 1 ? ' aria-current="page"' : ''}>${escapeHtml(it.name)}</li>`++ : `<li><a href="${escapeHtml(it.url)}">${escapeHtml(it.name)}</a></li>`));++ return `<nav class="ov-breadcrumbs" aria-label="${escapeHtml(label)}"><ol>${li.join('')}</ol></nav>`;++}++++/** A diff (from revisions.diff(...).content or diffText) as <ins>/<del> markup inside <pre>. */++function diffHtml(result) {++ const ops = result && result.ops ? result.ops : [];++ const body = ops.map((o) => (o.op === 'insert' ? `<ins>${escapeHtml(o.text)}</ins>` : o.op === 'delete' ? `<del>${escapeHtml(o.text)}</del>` : escapeHtml(o.text))).join('');++ return `<pre class="ov-diff ov-diff-${result && result.mode === 'word' ? 'word' : 'line'}">${body}</pre>`;++}++++/** <time> for a known instant; '' for an unknown one (the page must not guess a date). */++function timeTag(value, { label } = {}) {++ if (value == null || value === '') return '';++ const d = new Date(value);++ if (Number.isNaN(d.getTime())) return '';++ const iso = d.toISOString();++ return `<time datetime="${iso}">${escapeHtml(label || iso.slice(0, 10))}</time>`;++}++++module.exports = {++ escapeHtml, escapeAttr, html, raw, renderMarkdown, markdownToText, wordCount,++ paginate, paginationHtml, breadcrumbsHtml, diffHtml, timeTag,++};nnew file mode 100644@@@ -0,0 +1,213 @@++'use strict';++/**++ * openvibe-publishing/taxonomy — tags, terms with slugs and hierarchical categories.++ *++ * const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');++ * const tax = createTaxonomy(db, { prefix: 'blog' }); // blog_terms, blog_term_links++ * const cooking = tax.ensureTerm({ vocabulary: 'category', name: 'Cooking' });++ * const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: cooking.id });++ * tax.setTerms('post_1', 'tag', ['sourdough', 'Rye Bread']); // names or term ids++ * tax.ancestors(bread.id); // [Cooking, Bread] — breadcrumbs++ * tax.entitiesFor(cooking.id, { includeDescendants: true });++ *++ * Vocabularies are free-form names ('tag', 'category', 'series', …) chosen by the product. Slugs++ * are unique per vocabulary. Hierarchy is a parent pointer; cycles are refused.++ */++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');++++const VOCAB_RE = /^[a-z][a-z0-9_]{0,39}$/;++++/** "Crème Brûlée & Co." → "creme-brulee-co". Throws when nothing sluggable remains. */++function slugify(text, { max = 80 } = {}) {++ const s = String(text == null ? '' : text)++ .normalize('NFKD')++ .replace(/[̀-ͯ]/g, '')++ .toLowerCase()++ .replace(/['’]/g, '')++ .replace(/[^\p{L}\p{N}]+/gu, '-')++ .replace(/^-+|-+$/g, '')++ .slice(0, max)++ .replace(/-+$/g, '');++ if (!s) throw new TypeError(`cannot make a slug from ${JSON.stringify(text)}`);++ return s;++}++++function shape(row) {++ if (!row) return null;++ return { id: row.id, vocabulary: row.vocabulary, slug: row.slug, name: row.name, parentId: row.parent_id, description: row.description };++}++++function createTaxonomy(db, { prefix, now } = {}) {++ assertDb(db);++ assertPrefix(prefix);++ const clock = clockOf(now);++ const T = `${prefix}_terms`;++ const L = `${prefix}_term_links`;++++ db.exec(`++ CREATE TABLE IF NOT EXISTS ${T} (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ vocabulary TEXT NOT NULL,++ slug TEXT NOT NULL,++ name TEXT NOT NULL,++ parent_id INTEGER REFERENCES ${T}(id),++ description TEXT,++ created_at INTEGER NOT NULL,++ UNIQUE (vocabulary, slug)++ );++ CREATE INDEX IF NOT EXISTS ${T}_parent ON ${T} (parent_id);++ CREATE TABLE IF NOT EXISTS ${L} (++ entity_id TEXT NOT NULL,++ term_id INTEGER NOT NULL REFERENCES ${T}(id),++ position INTEGER NOT NULL DEFAULT 0,++ created_at INTEGER NOT NULL,++ PRIMARY KEY (entity_id, term_id)++ );++ CREATE INDEX IF NOT EXISTS ${L}_term ON ${L} (term_id);++ `);++++ const q = {++ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),++ bySlug: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? AND slug = ?`),++ insert: db.prepare(`INSERT INTO ${T} (vocabulary, slug, name, parent_id, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`),++ setParent: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE id = ?`),++ rename: db.prepare(`UPDATE ${T} SET name = ? WHERE id = ?`),++ children: db.prepare(`SELECT * FROM ${T} WHERE parent_id = ? ORDER BY name`),++ vocab: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? ORDER BY name`),++ ancestors: db.prepare(`WITH RECURSIVE up(id, parent_id, depth) AS (++ SELECT id, parent_id, 0 FROM ${T} WHERE id = ?++ UNION ALL SELECT t.id, t.parent_id, up.depth + 1 FROM ${T} t JOIN up ON t.id = up.parent_id WHERE up.depth < 64)++ SELECT t.* FROM up JOIN ${T} t ON t.id = up.id ORDER BY up.depth DESC`),++ descendants: db.prepare(`WITH RECURSIVE down(id, depth) AS (++ SELECT id, 0 FROM ${T} WHERE id = ?++ UNION ALL SELECT t.id, down.depth + 1 FROM ${T} t JOIN down ON t.parent_id = down.id WHERE down.depth < 64)++ SELECT t.* FROM down JOIN ${T} t ON t.id = down.id WHERE down.depth > 0 ORDER BY t.name`),++ linksFor: db.prepare(`SELECT t.*, l.position FROM ${L} l JOIN ${T} t ON t.id = l.term_id WHERE l.entity_id = ? ORDER BY t.vocabulary, l.position, t.name`),++ unlinkVocab: db.prepare(`DELETE FROM ${L} WHERE entity_id = ? AND term_id IN (SELECT id FROM ${T} WHERE vocabulary = ?)`),++ link: db.prepare(`INSERT OR IGNORE INTO ${L} (entity_id, term_id, position, created_at) VALUES (?, ?, ?, ?)`),++ unlinkTerm: db.prepare(`DELETE FROM ${L} WHERE term_id = ?`),++ reparentChildren: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE parent_id = ?`),++ deleteTerm: db.prepare(`DELETE FROM ${T} WHERE id = ?`),++ };++++ function vocabOf(v) {++ if (typeof v !== 'string' || !VOCAB_RE.test(v)) throw new TypeError(`vocabulary must match ${VOCAB_RE}`);++ return v;++ }++++ function mustGet(id) {++ const row = q.byId.get(Number(id));++ if (!row) throw new PublishingError(404, 'term.not_found', `No term ${id}`);++ return row;++ }++++ function checkParent(termId, parentId, vocabulary) {++ if (parentId == null) return null;++ const parent = mustGet(parentId);++ if (parent.vocabulary !== vocabulary) throw new PublishingError(400, 'term.parent_vocabulary', 'A parent must be in the same vocabulary');++ if (termId != null) {++ const chain = q.ancestors.all(parent.id).map((r) => r.id);++ if (chain.includes(Number(termId))) throw new PublishingError(400, 'term.cycle', 'That parent would create a cycle');++ }++ return parent.id;++ }++++ const api = {++ tables: { terms: T, links: L },++++ get(id) { return shape(q.byId.get(Number(id))); },++ bySlug(vocabulary, slug) { return shape(q.bySlug.get(vocabOf(vocabulary), String(slug))); },++++ /** Get-or-create by slug (derived from name unless given). The existing term is returned unchanged. */++ ensureTerm({ vocabulary, name, slug, parentId = null, description = null } = {}) {++ vocabOf(vocabulary);++ const label = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 200);++ if (!label) throw new TypeError('name is required');++ const s = slug ? slugify(slug) : slugify(label);++ return db.transaction(() => {++ const existing = q.bySlug.get(vocabulary, s);++ if (existing) return shape(existing);++ const pid = checkParent(null, parentId, vocabulary);++ const info = q.insert.run(vocabulary, s, label, pid, description, clock());++ return shape(q.byId.get(info.lastInsertRowid));++ })();++ },++++ rename(id, name) {++ mustGet(id);++ const label = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 200);++ if (!label) throw new TypeError('name is required');++ q.rename.run(label, Number(id));++ return api.get(id);++ },++++ setParent(id, parentId) {++ const term = mustGet(id);++ if (parentId != null && Number(parentId) === term.id) throw new PublishingError(400, 'term.cycle', 'A term cannot be its own parent');++ q.setParent.run(checkParent(term.id, parentId, term.vocabulary), term.id);++ return api.get(id);++ },++++ /** Root first, the term itself last: a breadcrumb trail. */++ ancestors(id) { return q.ancestors.all(Number(id)).map(shape); },++ descendants(id) { return q.descendants.all(Number(id)).map(shape); },++ children(id) { return q.children.all(Number(id)).map(shape); },++++ /** Nested tree of a vocabulary: [{ ...term, children: [...] }]. */++ tree(vocabulary) {++ const rows = q.vocab.all(vocabOf(vocabulary)).map((r) => ({ ...shape(r), children: [] }));++ const byId = new Map(rows.map((r) => [r.id, r]));++ const roots = [];++ for (const r of rows) (r.parentId != null && byId.has(r.parentId) ? byId.get(r.parentId).children : roots).push(r);++ return roots;++ },++++ terms(vocabulary) { return q.vocab.all(vocabOf(vocabulary)).map(shape); },++++ /** Replace the entity's terms in one vocabulary. Items are term ids or names (created on demand). */++ setTerms(entityId, vocabulary, items = []) {++ assertEntityId(entityId);++ vocabOf(vocabulary);++ return db.transaction(() => {++ q.unlinkVocab.run(entityId, vocabulary);++ const t = clock();++ const seen = new Set();++ items.forEach((item, i) => {++ const term = typeof item === 'number' ? mustGet(item) : q.bySlug.get(vocabulary, slugify(item)) || q.byId.get(api.ensureTerm({ vocabulary, name: item }).id);++ if (term.vocabulary !== vocabulary) throw new PublishingError(400, 'term.vocabulary_mismatch', `Term ${term.id} is not a ${vocabulary}`);++ if (seen.has(term.id)) return;++ seen.add(term.id);++ q.link.run(entityId, term.id, i, t);++ });++ return api.termsFor(entityId, vocabulary);++ })();++ },++++ termsFor(entityId, vocabulary) {++ const rows = q.linksFor.all(assertEntityId(entityId));++ return rows.filter((r) => !vocabulary || r.vocabulary === vocabulary).map(shape);++ },++++ /** Entity ids tagged with the term (and, optionally, any of its descendants). */++ entitiesFor(termId, { includeDescendants = false, limit = 1000 } = {}) {++ const ids = [Number(termId), ...(includeDescendants ? q.descendants.all(Number(termId)).map((r) => r.id) : [])];++ const marks = ids.map(() => '?').join(',');++ return db.prepare(`SELECT DISTINCT entity_id FROM ${L} WHERE term_id IN (${marks}) ORDER BY entity_id LIMIT ?`)++ .all(...ids, Math.max(1, Math.min(10000, limit))).map((r) => r.entity_id);++ },++++ /** Remove a term: its children move up to its parent, its links are dropped. */++ remove(id) {++ const term = mustGet(id);++ db.transaction(() => {++ q.reparentChildren.run(term.parent_id, term.id);++ q.unlinkTerm.run(term.id);++ q.deleteTerm.run(term.id);++ })();++ return true;++ },++ };++ return api;++}++++module.exports = { createTaxonomy, slugify };nnew file mode 100644@@@ -0,0 +1,726 @@++{++ "name": "openvibe-publishing",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-publishing",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ },++ "devDependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1",++ "better-sqlite3": "^11.10.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "better-sqlite3": ">=11"++ },++ "peerDependenciesMeta": {++ "better-sqlite3": {++ "optional": true++ }++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "dev": true,++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "dev": true,++ "license": "ISC"++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "dev": true,++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "dev": true,++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "dev": true,++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "dev": true,++ "license": "ISC"++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "dev": true,++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "dev": true,++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.7.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",++ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",++ "dev": true,++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "dev": true,++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "dev": true,++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "dev": true,++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,55 @@++{++ "name": "openvibe-publishing",++ "version": "0.1.0",++ "description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",++ "main": "index.js",++ "exports": {++ ".": "./index.js",++ "./package.json": "./package.json",++ "./revisions": "./lib/revisions.js",++ "./schedule": "./lib/schedule.js",++ "./taxonomy": "./lib/taxonomy.js",++ "./citations": "./lib/citations.js",++ "./media": "./lib/media.js",++ "./discussion": "./lib/discussion.js",++ "./seo": "./lib/seo.js",++ "./authorship": "./lib/authorship.js",++ "./index-hooks": "./lib/index-hooks.js",++ "./ssr": "./lib/ssr.js"++ },++ "files": [++ "index.js",++ "lib/",++ "docs/",++ "CHANGELOG.md"++ ],++ "scripts": {++ "test": "node test/run.js"++ },++ "engines": {++ "node": ">=20"++ },++ "repository": {++ "type": "git",++ "url": "git+https://github.com/OpenVibers/OpenVibe.Publishing.git"++ },++ "homepage": "https://github.com/OpenVibers/OpenVibe.Publishing#readme",++ "license": "MIT",++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ },++ "peerDependencies": {++ "better-sqlite3": ">=11"++ },++ "peerDependenciesMeta": {++ "better-sqlite3": {++ "optional": true++ }++ },++ "devDependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1",++ "better-sqlite3": "^11.10.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ }++}nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++const assert = require('assert');++const { openDb, fakeClock, suite } = require('./helpers/db');++const authorship = require('../lib/authorship');++const seo = require('../lib/seo');++++const { test, run } = suite();++const USER = 'usr_01J8Z6Q3KX0000000000000000';++const WF = { id: 'wiki.generate_page', version: 3, runId: 'run_01J8Z6Q3KX0000000000000000', model: 'stub' };++const facts = (extra) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/x', wordCount: 500, ...extra });++++test('records: ai/hybrid need a workflow run, human cannot carry one', () => {++ assert.deepStrictEqual(authorship.record({ mode: 'human', authors: [USER] }), { mode: 'human', authors: [USER] });++ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });++ assert.deepStrictEqual(ai.workflow, WF);++ assert.throws(() => authorship.record({ mode: 'ai' }), (e) => e.code === 'authorship.workflow_required');++ assert.throws(() => authorship.record({ mode: 'hybrid', authors: [USER] }), (e) => e.code === 'authorship.workflow_required');++ assert.throws(() => authorship.record({ mode: 'human', authors: [USER], workflow: WF }), (e) => e.code === 'authorship.workflow_on_human');++ assert.throws(() => authorship.record({ mode: 'human' }), (e) => e.code === 'authorship.author_required');++ assert.throws(() => authorship.record({ mode: 'imported' }), (e) => e.code === 'authorship.import_source_required');++ assert.throws(() => authorship.record({ mode: 'robot' }), /mode/);++ assert.throws(() => authorship.record({ mode: 'ai', workflow: { id: 'Bad Id', runId: 'r' } }), /workflow.id/);++ assert.throws(() => authorship.record({ mode: 'human', authors: ['42'] }), /subject/);++});++++test('AI-generated defaults to draft + noindex until a person reviews it', () => {++ const db = openDb('auth');++ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now: fakeClock() });++ const rec = authorship.record({ mode: 'ai', workflow: WF });++ assert.deepStrictEqual(authorship.initialState(rec), { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });++ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'ai_generated_unreviewed' });++ const before = seo.evaluate(facts(authorship.gateFacts(rec, reviews.latest('pg', 1))));++ assert.deepStrictEqual(before.codes, ['ai_generated_unreviewed']);++++ assert.throws(() => reviews.record({ entityId: 'pg', revision: 1, reviewer: 'svc:wiki', decision: 'approved' }), (e) => e.code === 'review.reviewer_not_person');++ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'rejected', note: 'wrong facts' });++ assert.strictEqual(authorship.canPublish(rec, reviews.latest('pg', 1)).ok, false);++ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'approved' });++ const review = reviews.latest('pg', 1);++ assert.strictEqual(authorship.canPublish(rec, review).ok, true);++ assert.strictEqual(seo.evaluate(facts(authorship.gateFacts(rec, review))).indexable, true);++ assert.strictEqual(reviews.latest('pg', 2), null, 'a review covers one revision only');++ assert.strictEqual(reviews.history('pg').length, 2);++ assert.throws(() => db.prepare("UPDATE wiki_page_reviews SET decision = 'approved'").run(), /immutable/);++});++++test('stub-provider output is held like AI output', () => {++ const rec = authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF, stubProvider: true });++ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'stub_provider' });++ assert.deepStrictEqual(seo.evaluate(facts(authorship.gateFacts(rec))).codes, ['stub_provider']);++});++++test('human and hybrid content publishes without an AI review', () => {++ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'human', authors: [USER] })).ok, true);++ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).ok, true);++});++++test('disclosure labels name the workflow and the source; human content has none', () => {++ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'human', authors: [USER] })), null);++ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });++ assert.deepStrictEqual(authorship.disclosure(ai), {++ mode: 'ai', short: 'AI-generated',++ long: 'AI-generated from goosely\'s stream by workflow wiki.generate_page v3, not yet reviewed by a person.',++ });++ assert.match(authorship.disclosure(ai, { decision: 'approved', reviewer: USER }).long, /reviewed by a person\.$/);++ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).short, 'Written with AI assistance');++ const imp = authorship.record({ mode: 'imported', importedFrom: { label: 'the legacy Live pastes', originalAuthor: 'goosely' } });++ assert.strictEqual(authorship.disclosure(imp).long, 'Imported from the legacy Live pastes; originally by goosely.');++});++++run();nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++const assert = require('assert');++const { openDb, fakeClock, suite } = require('./helpers/db');++const { createRevisionStore } = require('../lib/revisions');++const { createCitationStore, gateFacts } = require('../lib/citations');++++const { test, run } = suite();++++function setup() {++ const db = openDb('cite');++ const now = fakeClock();++ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });++ const cites = createCitationStore(db, { prefix: 'wiki', now, revisions });++ revisions.create({ entityId: 'pg', expectedRevision: 0, content: 'Rye is a grass.' });++ revisions.create({ entityId: 'pg', expectedRevision: 1, content: 'Rye is a grass grown as a grain.' });++ revisions.create({ entityId: 'pg', expectedRevision: 2, content: 'Rye is a cereal.' });++ return { db, now, revisions, cites };++}++++test('a citation records source, retrieval time, quote span and license on one revision', () => {++ const { cites } = setup();++ const c = cites.attach({++ entityId: 'pg', revision: 1, sourceItemId: 'src_01J8Z6Q3KX0000000000000001', url: 'https://example.org/rye',++ title: 'Rye', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass', start: 0, end: 14 },++ licenseNote: 'CC BY-SA 4.0', anchor: '1', attachedBy: 'usr_01J8Z6Q3KX0000000000000000',++ });++ assert.strictEqual(c.revision, 1);++ assert.strictEqual(c.retrievedAt, '2026-09-20T10:00:00.000Z');++ assert.deepStrictEqual(c.quote, { text: 'Rye is a grass', start: 0, end: 14 });++ assert.strictEqual(c.licenseNote, 'CC BY-SA 4.0');++ assert.strictEqual(cites.table, 'wiki_citations');++});++++test('nothing is filled in: unknown retrieval time and title stay null', () => {++ const { cites } = setup();++ const c = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/x' });++ assert.strictEqual(c.retrievedAt, null);++ assert.strictEqual(c.title, null);++ assert.strictEqual(c.licenseNote, null);++ assert.strictEqual(c.quote, null);++ assert.deepStrictEqual(gateFacts([c]), { citationCount: 1, datedCitationCount: 0 });++});++++test('citations stay attached to the revision that used them after later revisions drop them', () => {++ const { cites } = setup();++ const a = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });++ const b = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/b' });++ const carried = cites.carryForward({ entityId: 'pg', fromRevision: 1, toRevision: 2, ids: [a.id] });++ assert.strictEqual(carried.length, 1);++ assert.strictEqual(carried[0].carriedFrom, a.id);++ // revision 3 reuses nothing++ assert.deepStrictEqual(cites.forRevision('pg', 3), []);++ assert.deepStrictEqual(cites.forRevision('pg', 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);++ assert.deepStrictEqual(cites.forRevision('pg', 2).map((c) => c.url), ['https://example.org/a']);++ assert.strictEqual(cites.history('pg').length, 3);++ assert.ok(cites.get(b.id));++});++++test('citation rows cannot be updated or deleted outside an audited purge', () => {++ const { db, cites } = setup();++ cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });++ assert.throws(() => db.prepare("UPDATE wiki_citations SET url = 'https://evil.test'").run(), /immutable/);++ assert.throws(() => db.prepare('DELETE FROM wiki_citations').run(), /never deleted/);++ assert.deepStrictEqual(cites.purgeEntity('pg', { reason: 'legal' }), { deleted: 1 });++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' }), (e) => e.status === 410);++});++++test('validation: a source is required, URLs are http(s), revisions must exist, spans are ordered', () => {++ const { cites } = setup();++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1 }), (e) => e.code === 'citation.no_source');++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'javascript:alert(1)' }), /http/);++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 9, url: 'https://example.org' }), (e) => e.code === 'revision.not_found');++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', quote: { start: 5, end: 2 } }), /span/);++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', retrievedAt: 'yesterday' }), /retrievedAt/);++});++++test('bySourceItem finds every revision citing a source (correction propagation)', () => {++ const { cites } = setup();++ cites.attach({ entityId: 'pg', revision: 1, sourceItemId: 'src_A' });++ cites.attach({ entityId: 'pg', revision: 3, sourceItemId: 'src_A' });++ assert.deepStrictEqual(cites.bySourceItem('src_A').map((c) => c.revision), [1, 3]);++});++++run();nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++const assert = require('assert');++const http = require('http');++const contracts = require('openvibe-contracts');++const { openDb, fakeClock, suite } = require('./helpers/db');++const { createDiscussionClient, createDiscussionRefs, entityRef } = require('../lib/discussion');++++const { test, run } = suite();++const USER = 'usr_01J8Z6Q3KX0000000000000000';++++/** A stand-in for OpenVibe.Community's POST /api/v1/comments/threads/resolve (get-or-create). */++function fakeCommunity() {++ const threads = new Map();++ const requests = [];++ let mode = 'ok';++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ requests.push({ method: req.method, url: req.url, headers: req.headers, body });++ const send = (status, obj, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(JSON.stringify(obj)); };++ if (mode === 'down') return send(503, { type: 'x', title: 'Service Unavailable', status: 503, code: 'service.unavailable', error: 'down' }, 'application/problem+json');++ if (req.method !== 'POST' || req.url !== '/api/v1/comments/threads/resolve') return send(404, { code: 'route.not_found' });++ if (req.headers.authorization !== 'Bearer svc-token') return send(401, { code: 'auth.invalid_token', error: 'no' });++ const { ref } = JSON.parse(body);++ if (!contracts.validate('common.entity-ref@1', ref).valid) return send(400, { code: 'ref.invalid' });++ const key = `${ref.service}/${ref.type}/${ref.id}`;++ if (threads.has(key)) return send(200, { thread: threads.get(key), created: false });++ const thread = { id: `thr_${threads.size + 1}`, ref, visibility: 'public', comment_count: 0 };++ threads.set(key, thread);++ return send(201, { thread, created: true });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`, requests, setMode: (m) => { mode = m; }, close: () => server.close(),++ })));++}++++const tokenClient = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }), invalidated: 0, invalidate() { this.invalidated++; } };++++test('resolveThread: get-or-create over the Community contract, with service auth and trace headers', async () => {++ const community = await fakeCommunity();++ try {++ const client = createDiscussionClient({ communityUrl: community.url + '/', tokenClient });++ const ref = { service: 'wiki', type: 'page', id: 'pg_1', label: 'Rye' };++ const first = await client.resolveThread(ref, { subject: USER, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', requestId: 'req-1' });++ const second = await client.resolveThread(ref);++ assert.deepStrictEqual(first, { threadId: 'thr_1', created: true, visibility: 'public' });++ assert.strictEqual(second.threadId, 'thr_1');++ assert.strictEqual(second.created, false);++ const h = community.requests[0].headers;++ assert.strictEqual(h['x-ov-subject'], USER);++ assert.strictEqual(h.traceparent, '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01');++ assert.strictEqual(h['x-openvibe-request-id'], 'req-1');++ assert.strictEqual(community.requests[1].headers['x-ov-subject'], undefined, 'acts as the service when no subject');++ } finally { community.close(); }++});++++test('failures are errors, never a fabricated thread', async () => {++ const community = await fakeCommunity();++ try {++ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });++ community.setMode('down');++ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 503 && e.code === 'service.unavailable');++ const bad = createDiscussionClient({ communityUrl: community.url, tokenClient: { authHeaders: async () => ({ Authorization: 'Bearer wrong' }), invalidate() { this.hit = true; } } });++ community.setMode('ok');++ await assert.rejects(bad.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 401 && e.code === 'auth.invalid_token');++ const nowhere = createDiscussionClient({ communityUrl: 'http://127.0.0.1:1', tokenClient, timeoutMs: 500 });++ await assert.rejects(nowhere.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.code === 'discussion.unavailable');++ await assert.rejects(client.resolveThread({ service: 'Blog!', type: 'post', id: 'p' }), /service/);++ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }, { subject: '42' }), /subject/);++ } finally { community.close(); }++});++++test('the local table stores the reference only — no comment content columns — and resolves once', async () => {++ const community = await fakeCommunity();++ try {++ const db = openDb('disc');++ const refs = createDiscussionRefs(db, { prefix: 'wiki', now: fakeClock() });++ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });++ const ref = { service: 'wiki', type: 'page', id: 'pg_9' };++ const a = await refs.threadFor('pg_9', ref, { client });++ const b = await refs.threadFor('pg_9', ref, { client });++ assert.strictEqual(a.cached, false);++ assert.strictEqual(b.cached, true);++ assert.strictEqual(a.threadId, b.threadId);++ assert.strictEqual(community.requests.length, 1);++ const cols = db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort();++ assert.deepStrictEqual(cols, ['entity_id', 'ref', 'resolved_at', 'thread_id']);++ assert.ok(contracts.validate('common.entity-ref@1', refs.get('pg_9').ref).valid);++ } finally { community.close(); }++});++++test('entityRef validation matches common.entity-ref@1', () => {++ const r = entityRef({ service: 'news', type: 'story', id: 'st_1', label: 'x'.repeat(500), revision: 3 });++ assert.strictEqual(r.label.length, 200);++ assert.ok(contracts.validate('common.entity-ref@1', r).valid);++});++++run();nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/** A fresh on-disk SQLite database in a temp directory, removed at exit. */++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++++const dirs = [];++process.on('exit', () => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });++++function tempDir(tag = 'pub') {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ovpub-${tag}-`));++ dirs.push(dir);++ return dir;++}++++function openDb(tag) {++ const db = new Database(path.join(tempDir(tag), 'test.db'));++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ return db;++}++++/** A controllable clock. */++function fakeClock(start = Date.parse('2026-09-22T12:00:00Z')) {++ let t = start;++ const now = () => t;++ now.advance = (ms) => { t += ms; return t; };++ now.set = (ms) => { t = ms; return t; };++ return now;++}++++/** Tiny async-aware test runner: test(name, fn) then run(). */++function suite() {++ const tests = [];++ return {++ test(name, fn) { tests.push({ name, fn }); },++ async run() {++ let failed = 0;++ for (const t of tests) {++ try { await t.fn(); console.log(` ok ${t.name}`); } catch (err) { failed++; console.log(` FAIL ${t.name}\n${err && err.stack || err}`); }++ }++ console.log(`${tests.length - failed}/${tests.length} passed`);++ if (failed) process.exit(1);++ },++ };++}++++module.exports = { openDb, tempDir, fakeClock, suite };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const Ajv = require('ajv/dist/2020');++const addFormats = require('ajv-formats');++const contracts = require('openvibe-contracts');++const hooks = require('../lib/index-hooks');++const seo = require('../lib/seo');++const { suite } = require('./helpers/db');++++const { test, run } = suite();++const USER = 'usr_01J8Z6Q3KX0000000000000000';++const ajv = new Ajv({ allErrors: true, strict: false });++addFormats(ajv);++const validateDoc = ajv.compile(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'contracts-proposal', 'search.index-document.v1.json'), 'utf8')));++++const facts = { state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', wordCount: 400, citationCount: 2 };++const decision = seo.evaluate(facts, { policy: { requireSources: true } });++const input = (extra = {}) => ({++ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published', visibility: 'public',++ canonicalUrl: 'https://openvibe.wiki/p/rye', title: 'Rye', summary: 'A cereal grass.', body: 'Rye is a grass grown as a grain.',++ facets: { space: 'food', tags: ['grain', 'bread'] },++ provenance: { authorship: { mode: 'human' }, citations: [{ sourceItemId: 'src_1', url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] },++ decision, publishedAt: '2026-09-20T12:00:00Z', updatedAt: '2026-09-21T12:00:00Z', ...extra,++});++++function withEventId(env) { return { ...env, event_id: contracts.ids.newId('event') }; }++++test('a published revision becomes a full index document with every §12.3 field', () => {++ const doc = hooks.buildIndexDocument(input());++ for (const k of ['owner_service', 'resource_type', 'resource_id', 'revision', 'visibility', 'acl', 'canonical_url', 'title', 'summary', 'body', 'facets', 'provenance', 'publication_state', 'deleted', 'indexability']) {++ assert.ok(k in doc, `missing ${k}`);++ }++ assert.deepStrictEqual(doc.acl, { public: true });++ assert.deepStrictEqual(doc.indexability, { indexable: true, listable: true, reasons: [], gate: 'openvibe-publishing/seo@1' });++ assert.deepStrictEqual(doc.provenance.citations, [{ source_item_id: 'src_1', url: 'https://example.org/rye', retrieved_at: '2026-09-20T10:00:00.000Z' }]);++ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));++});++++test('deleted, unpublished, draft and unlisted resources become tombstones with no content', () => {++ for (const extra of [{ deleted: true }, { state: 'unpublished' }, { state: 'draft' }, { visibility: 'unlisted' }]) {++ const doc = hooks.buildIndexDocument(input(extra));++ assert.strictEqual(doc.deleted, true, JSON.stringify(extra));++ for (const k of ['title', 'summary', 'body', 'canonical_url', 'acl']) assert.ok(!(k in doc), `${k} leaked in a tombstone`);++ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));++ }++});++++test('private content needs an ACL of subjects; gated content needs entitlements', () => {++ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv })), /acl.subjects/);++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: ['everyone'] } })), /acl.subjects/);++ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));++ assert.deepStrictEqual(doc.acl, { public: false, subjects: [USER] });++ assert.strictEqual(doc.indexability.listable, false);++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv })), /entitlements/);++ const vip = hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv, acl: { entitlements: ['vip.tier.gold'] } }));++ assert.deepStrictEqual(vip.acl, { public: false, entitlements: ['vip.tier.gold'] });++ assert.ok(validateDoc(vip));++ assert.throws(() => hooks.buildIndexDocument(input({ decision: undefined })), /decision/);++});++++test('actionFor maps state transitions to event actions', () => {++ const pub = { state: 'published', visibility: 'public', revision: 1 };++ assert.strictEqual(hooks.actionFor(null, pub), 'published');++ assert.strictEqual(hooks.actionFor({ state: 'draft', revision: 1 }, pub), 'published');++ assert.strictEqual(hooks.actionFor(pub, { ...pub, revision: 2 }), 'updated');++ assert.strictEqual(hooks.actionFor(pub, { ...pub, visibility: 'private' }), 'updated');++ assert.strictEqual(hooks.actionFor(pub, pub), null);++ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'unpublished' }), 'unpublished');++ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'deleted' }), 'deleted');++ assert.strictEqual(hooks.actionFor({ state: 'deleted' }, { state: 'deleted' }), null);++ assert.strictEqual(hooks.actionFor({ state: 'draft' }, { state: 'draft' }), null);++});++++test('publication events are valid contracts envelopes: <product>.<type>.<action>', () => {++ const doc = hooks.buildIndexDocument(input());++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: doc, decision, now: Date.parse('2026-09-22T12:00:00Z') });++ assert.strictEqual(env.event_type, 'wiki.page.published');++ assert.strictEqual(env.visibility, 'public');++ assert.deepStrictEqual(env.actor, { type: 'user', id: USER });++ assert.deepStrictEqual(env.subject, { type: 'page', id: 'pg_1', revision: 4 });++ assert.strictEqual(env.payload.document, doc);++ assert.ok(!('event_id' in env), 'the outbox assigns event_id');++ const check = contracts.validate('events.event-envelope@1', withEventId(env));++ assert.ok(check.valid, JSON.stringify(check.errors));++++ const tomb = hooks.buildIndexDocument(input({ deleted: true }));++ const del = hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: 'svc:blog', document: tomb });++ assert.strictEqual(del.event_type, 'blog.post.deleted');++ assert.strictEqual(del.visibility, 'internal');++ assert.deepStrictEqual(del.actor, { type: 'service', id: 'blog' });++ assert.ok(contracts.validate('events.event-envelope@1', withEventId(del)).valid);++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: USER, document: doc }), /tombstone/);++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: tomb }), /published document/);++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'moved', id: 'pg_1', revision: 4, actor: USER, document: doc }), /action/);++});++++test('events about private or non-listable content are internal', () => {++ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });++ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: doc });++ assert.strictEqual(env.visibility, 'internal');++ const unlisted = hooks.buildIndexDocument(input({ visibility: 'unlisted' }));++ const env2 = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: unlisted });++ assert.strictEqual(env2.visibility, 'internal');++ assert.ok(contracts.validate('events.event-envelope@1', withEventId(env2)).valid);++});++++run();nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++const assert = require('assert');++const { openDb, fakeClock, suite } = require('./helpers/db');++const { createAttachmentStore, mediaRef, figureHtml, isMediaId } = require('../lib/media');++const contracts = require('openvibe-contracts');++++const { test, run } = suite();++const MED = 'med_01J8Z6Q3KX0000000000000000';++const MED2 = 'med_01J8Z6Q3KX0000000000000001';++++test('attachments reference Media ids and validate as Contracts MediaRef', () => {++ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });++ const a = media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', alt: 'A loaf' });++ assert.strictEqual(a.state, 'unverified');++ assert.strictEqual(a.broken, false);++ assert.ok(contracts.validate('media.media-ref@1', mediaRef(a)).valid);++ assert.ok(isMediaId('legacy:live:vod:123'));++ assert.throws(() => media.attach({ entityId: 'post_1', mediaId: 'https://cdn.example/x.png' }), (e) => e.code === 'media.invalid_id');++ assert.strictEqual(media.table, 'blog_post_attachments');++});++++test('a deleted Media object surfaces an explicit broken-asset state', () => {++ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });++ media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', caption: 'Crumb' });++ media.attach({ entityId: 'post_2', mediaId: MED });++ media.attach({ entityId: 'post_2', mediaId: MED2 });++ assert.deepStrictEqual(media.markBroken(MED, 'deleted'), ['post_1', 'post_2']);++ const [att] = media.list('post_1');++ assert.strictEqual(att.state, 'broken');++ assert.strictEqual(att.brokenReason, 'deleted');++ assert.ok(att.checkedAt);++ assert.strictEqual(media.broken('post_2').length, 1);++ const html = figureHtml(att, { urlFor: () => 'https://openvibe.media/o/x' });++ assert.match(html, /data-state="broken"/);++ assert.match(html, /no longer available/);++ assert.doesNotMatch(html, /<img/, 'no image and no guessed URL for a broken object');++ assert.match(html, /<figcaption>Crumb<\/figcaption>/);++});++++test('verify(): 404 → broken, found → available, outage → unchanged (check_failed)', async () => {++ const media = createAttachmentStore(openDb('media'), { prefix: 'wiki_page', now: fakeClock() });++ media.attach({ entityId: 'pg', mediaId: MED });++ media.attach({ entityId: 'pg', mediaId: MED2 });++ const out = await media.verify('pg', { resolve: async (id) => (id === MED ? { exists: true } : { exists: false, reason: 'not_found' }) });++ assert.deepStrictEqual(out.map((o) => o.outcome), ['available', 'broken']);++ const outage = await media.verify('pg', { resolve: async () => { throw new Error('ECONNREFUSED'); } });++ assert.deepStrictEqual(outage.map((o) => o.outcome), ['check_failed', 'check_failed']);++ assert.deepStrictEqual(media.list('pg').map((a) => a.state), ['available', 'broken'], 'an outage changes nothing');++ media.markAvailable(MED2);++ assert.strictEqual(media.list('pg')[1].state, 'available');++});++++test('figureHtml escapes alt/caption and needs a URL builder for available media', () => {++ const att = { mediaId: MED, state: 'available', alt: '"><script>x</script>', caption: '<b>c</b>' };++ const html = figureHtml(att, { urlFor: (id) => `https://openvibe.media/o/${id}` });++ assert.doesNotMatch(html, /<script>/);++ assert.match(html, /<b>c<\/b>/);++ assert.throws(() => figureHtml(att), /urlFor/);++});++++run();nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++/** Package shape: every subpath export loads alone, the root is lazy, no server code needs a browser. */++const assert = require('assert');++const { spawnSync } = require('child_process');++const path = require('path');++const pkg = require('../package.json');++const { suite } = require('./helpers/db');++++const { test, run } = suite();++const ROOT = path.join(__dirname, '..');++const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json');++++test('every subpath export resolves to an existing module', () => {++ assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),++ ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);++ for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);++});++++test('each module loads alone in a fresh process, without better-sqlite3 being required', () => {++ for (const s of SUBPATHS) {++ const code = `const Module = require('module'); const orig = Module._load;++ Module._load = function (req, ...rest) { if (req === 'better-sqlite3') throw new Error('loaded better-sqlite3'); return orig.call(this, req, ...rest); };++ require('openvibe-publishing/${s.slice(2)}'); console.log('ok');`;++ const r = spawnSync(process.execPath, ['-e', code], { cwd: ROOT, encoding: 'utf8' });++ assert.strictEqual(r.status, 0, `${s}: ${r.stderr}`);++ }++});++++test('the root export is lazy and exposes every module', () => {++ const r = spawnSync(process.execPath, ['-e', `const p = require('openvibe-publishing');++ const before = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;++ const names = Object.keys(p); p.seo.evaluate; const after = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;++ console.log(JSON.stringify({ names, before, after, v: p.version }));`], { cwd: ROOT, encoding: 'utf8' });++ assert.strictEqual(r.status, 0, r.stderr);++ const out = JSON.parse(r.stdout);++ assert.strictEqual(out.before, 0);++ assert.ok(out.after > 0);++ assert.strictEqual(out.v, pkg.version);++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);++});++++test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {++ const fs = require('fs');++ for (const f of fs.readdirSync(path.join(ROOT, 'lib'))) {++ const src = fs.readFileSync(path.join(ROOT, 'lib', f), 'utf8');++ assert.doesNotMatch(src, /\.listen\(|new Database\(|process\.env|require\('better-sqlite3'\)/, `${f} must take the consumer's handle, not open its own`);++ }++});++++run();nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++const assert = require('assert');++const { openDb, fakeClock, suite } = require('./helpers/db');++const { createRevisionStore, diffText } = require('../lib/revisions');++const { formatLines } = require('../lib/diff');++++const { test, run } = suite();++const USER = 'user:usr_01J8Z6Q3KX0000000000000000';++++test('create → edit → revert keeps an immutable lineage with parent pointers', () => {++ const db = openDb('rev');++ const revs = createRevisionStore(db, { prefix: 'wiki_page', now: fakeClock() });++ const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Bread is baked.\n', fields: { title: 'Bread' }, author: USER }).revision;++ const r2 = revs.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Bread is baked dough.\nIt is old.\n', fields: { title: 'Bread' }, author: USER }).revision;++ assert.strictEqual(r1.number, 1);++ assert.strictEqual(r1.parentId, null);++ assert.strictEqual(r2.parentId, r1.id);++ assert.strictEqual(r2.parentNumber, 1);++ const r3 = revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: USER }).revision;++ assert.strictEqual(r3.number, 3);++ assert.strictEqual(r3.kind, 'revert');++ assert.strictEqual(r3.revertedTo, 1);++ assert.strictEqual(r3.parentId, r2.id);++ assert.strictEqual(r3.content, r1.content);++ assert.deepStrictEqual(revs.lineage('pg_1').map((r) => r.number), [3, 2, 1]);++ assert.strictEqual(revs.get('pg_1', 2).content, 'Bread is baked dough.\nIt is old.\n', 'revert did not rewrite history');++ assert.deepStrictEqual(Object.keys(revs.tables), ['revisions', 'drafts', 'purges']);++ assert.strictEqual(revs.tables.revisions, 'wiki_page_revisions');++});++++test('rows are immutable in SQLite itself (UPDATE and DELETE abort)', () => {++ const db = openDb('rev');++ const revs = createRevisionStore(db, { prefix: 'blog_post' });++ revs.create({ entityId: 'p', expectedRevision: 0, content: 'x' });++ assert.throws(() => db.prepare('UPDATE blog_post_revisions SET content = ?').run('y'), /immutable/);++ assert.throws(() => db.prepare('DELETE FROM blog_post_revisions').run(), /never deleted/);++ assert.strictEqual(revs.head('p').content, 'x');++});++++test('optimistic concurrency: a stale expectedRevision is a 412 conflict', () => {++ const db = openDb('rev');++ const revs = createRevisionStore(db, { prefix: 'wiki_page' });++ revs.create({ entityId: 'pg', expectedRevision: 0, content: 'a' });++ revs.create({ entityId: 'pg', expectedRevision: 1, content: 'b' });++ let err;++ try { revs.create({ entityId: 'pg', expectedRevision: 1, content: 'c' }); } catch (e) { err = e; }++ assert.ok(err, 'conflict thrown');++ assert.strictEqual(err.status, 412);++ assert.strictEqual(err.code, 'revision.conflict');++ assert.strictEqual(err.expected, 1);++ assert.strictEqual(err.current, 2);++ assert.strictEqual(revs.headNumber('pg'), 2);++ assert.throws(() => revs.create({ entityId: 'new', content: 'x' }), /expectedRevision/, 'expectedRevision is required');++});++++test('a second connection on the same file with a stale base revision gets 412', () => {++ const db = openDb('race');++ const other = new (require('better-sqlite3'))(db.name);++ const a = createRevisionStore(db, { prefix: 'wiki_page' });++ const b = createRevisionStore(other, { prefix: 'wiki_page' });++ a.create({ entityId: 'pg', expectedRevision: 0, content: 'base' });++ a.create({ entityId: 'pg', expectedRevision: 1, content: 'from a' });++ assert.throws(() => b.create({ entityId: 'pg', expectedRevision: 1, content: 'from b' }), (e) => e.status === 412);++ other.close();++});++++test('unchanged edits do not create empty revisions', () => {++ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });++ revs.create({ entityId: 'e', expectedRevision: 0, content: 'same', fields: { a: 1, b: 2 } });++ const again = revs.create({ entityId: 'e', expectedRevision: 1, content: 'same', fields: { b: 2, a: 1 } });++ assert.strictEqual(again.created, false);++ assert.strictEqual(revs.headNumber('e'), 1);++});++++test('line and word diffs', () => {++ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });++ revs.create({ entityId: 'e', expectedRevision: 0, content: 'one\ntwo\nthree\n', fields: { title: 'A' } });++ revs.create({ entityId: 'e', expectedRevision: 1, content: 'one\n2\nthree\nfour\n', fields: { title: 'B', tag: 'x' } });++ const d = revs.diff('e', 1, 2);++ assert.strictEqual(d.content.mode, 'line');++ assert.deepStrictEqual(d.content.ops, [++ { op: 'equal', text: 'one\n' }, { op: 'delete', text: 'two\n' }, { op: 'insert', text: '2\n' },++ { op: 'equal', text: 'three\n' }, { op: 'insert', text: 'four\n' },++ ]);++ assert.strictEqual(d.content.added, 2);++ assert.strictEqual(d.content.removed, 1);++ assert.deepStrictEqual(d.fields, [{ field: 'tag', from: null, to: 'x' }, { field: 'title', from: 'A', to: 'B' }]);++ assert.strictEqual(formatLines(d.content), ' one\n- two\n+ 2\n three\n+ four');++++ const w = diffText('The quick brown fox', 'The slow brown fox jumps', { mode: 'word' });++ const rebuiltOld = w.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join('');++ const rebuiltNew = w.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join('');++ assert.strictEqual(rebuiltOld, 'The quick brown fox');++ assert.strictEqual(rebuiltNew, 'The slow brown fox jumps');++ assert.ok(w.ops.some((o) => o.op === 'delete' && o.text === 'quick'));++ assert.ok(w.ops.some((o) => o.op === 'insert' && o.text === 'slow'));++});++++test('diff reconstructs both sides on random inputs', () => {++ let seed = 7;++ const rnd = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; };++ const words = ['a', 'b', 'c', 'd', 'e'];++ for (let i = 0; i < 200; i++) {++ const mk = () => Array.from({ length: Math.floor(rnd() * 12) }, () => words[Math.floor(rnd() * words.length)]).join('\n');++ const a = mk();++ const b = mk();++ const r = diffText(a, b, { mode: 'line' });++ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);++ assert.strictEqual(r.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join(''), b);++ }++});++++test('a huge rewrite falls back to an approximate diff instead of exhausting memory', () => {++ const a = Array.from({ length: 3000 }, (_, i) => `a${i}`).join('\n');++ const b = Array.from({ length: 3000 }, (_, i) => `b${i}`).join('\n');++ const r = diffText(a, b, { maxEdits: 100 });++ assert.strictEqual(r.approximate, true);++ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);++});++++test('drafts: save, commit on the base revision, conflict keeps the draft', () => {++ const revs = createRevisionStore(openDb('rev'), { prefix: 'blog_post', now: fakeClock() });++ revs.create({ entityId: 'p1', expectedRevision: 0, content: 'v1' });++ const d = revs.saveDraft({ entityId: 'p1', owner: USER, content: 'my edit' });++ assert.strictEqual(d.baseRevision, 1);++ assert.strictEqual(d.stale, false);++ revs.create({ entityId: 'p1', expectedRevision: 1, content: 'someone else' });++ assert.strictEqual(revs.getDraft('p1', USER).stale, true);++ assert.throws(() => revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.status === 412);++ assert.ok(revs.getDraft('p1', USER), 'draft survives a conflict');++ revs.saveDraft({ entityId: 'p1', owner: USER, content: 'rebased edit', baseRevision: 2 });++ const out = revs.commitDraft({ entityId: 'p1', owner: USER, message: 'rebased' });++ assert.strictEqual(out.revision.number, 3);++ assert.strictEqual(out.revision.author, USER);++ assert.strictEqual(revs.getDraft('p1', USER), null);++});++++test('purge is explicit, audited and final', () => {++ const db = openDb('rev');++ const revs = createRevisionStore(db, { prefix: 'w' });++ revs.create({ entityId: 'gone', expectedRevision: 0, content: 'x' });++ revs.create({ entityId: 'kept', expectedRevision: 0, content: 'y' });++ assert.throws(() => revs.purgeEntity('gone'), /reason/);++ assert.deepStrictEqual(revs.purgeEntity('gone', { reason: 'legal request #12', purgedBy: USER }), { deleted: 1 });++ assert.strictEqual(revs.head('gone'), null);++ assert.ok(revs.head('kept'));++ assert.strictEqual(db.prepare('SELECT reason FROM w_revision_purges WHERE entity_id = ?').get('gone').reason, 'legal request #12');++ assert.throws(() => revs.create({ entityId: 'gone', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);++ assert.throws(() => db.prepare("DELETE FROM w_revisions WHERE entity_id = 'kept'").run(), /never deleted/);++});++++test('prefix and handle are validated; schema creation is idempotent', () => {++ const db = openDb('rev');++ assert.throws(() => createRevisionStore(db, { prefix: 'Bad-Prefix' }), /prefix/);++ assert.throws(() => createRevisionStore(db, { prefix: 'x; DROP TABLE y' }), /prefix/);++ assert.throws(() => createRevisionStore({}, { prefix: 'ok' }), /better-sqlite3/);++ createRevisionStore(db, { prefix: 'twice' });++ createRevisionStore(db, { prefix: 'twice' });++});++++run();nnew file mode 100644@@@ -0,0 +1,62 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, a few at a++ * time, and fails if any of them fails.++ *++ * npm test # everything++ * npm test -- seo revisions # only files whose name contains one of the words++ *++ * Plain Node: temp SQLite files, in-process HTTP, no network.++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const queue = [...files];++ const results = [];++ await Promise.all(Array.from({ length: PARALLEL }, async () => {++ while (queue.length) {++ const r = await runOne(queue.shift());++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);++ }++ }));++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-40).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++const assert = require('assert');++const Database = require('better-sqlite3');++const { openDb, fakeClock, suite } = require('./helpers/db');++const { createScheduler } = require('../lib/schedule');++++const { test, run } = suite();++const T0 = Date.parse('2026-10-01T09:00:00Z');++++test('schedule() is idempotent for the same job', () => {++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0 - 60000) });++ const a = s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 3 });++ const b = s.schedule({ entityId: 'post_1', action: 'publish', runAt: new Date(T0).toISOString(), revision: 3 });++ assert.strictEqual(a.created, true);++ assert.strictEqual(b.created, false);++ assert.strictEqual(a.job.id, b.job.id);++ assert.strictEqual(s.jobs('post_1').length, 1);++ assert.strictEqual(s.table, 'blog_schedule_jobs');++});++++test('jobs are not due before runAt, and run once when due', async () => {++ const clock = fakeClock(T0 - 60000);++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock });++ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 2 });++ let calls = 0;++ const handler = () => { calls++; return { published: 2 }; };++ assert.deepStrictEqual((await s.runDue({ worker: 'w1', handler })).done, []);++ clock.set(T0);++ const out = await s.runDue({ worker: 'w1', handler });++ assert.strictEqual(out.done.length, 1);++ assert.deepStrictEqual(out.done[0].result, { published: 2 });++ await s.runDue({ worker: 'w1', handler });++ assert.strictEqual(calls, 1);++});++++test('a worker that dies mid-job: the lease expires and a restarted worker re-runs it; the effect happens once', async () => {++ const clock = fakeClock(T0);++ const db = openDb('sch');++ const s = createScheduler(db, { prefix: 'blog', now: clock, leaseMs: 30000 });++ // the product's own state, updated idempotently by the handler++ db.exec('CREATE TABLE posts (id TEXT PRIMARY KEY, published_revision INTEGER, publish_count INTEGER NOT NULL DEFAULT 0)');++ db.prepare("INSERT INTO posts (id) VALUES ('post_1')").run();++ const handler = (job) => {++ const info = db.prepare('UPDATE posts SET published_revision = ?, publish_count = publish_count + 1 WHERE id = ? AND published_revision IS NOT ?').run(job.revision, job.entityId, job.revision);++ return { changed: info.changes };++ };++ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 4 });++++ // worker A claims, applies the effect, then "crashes" before complete()++ const [claimed] = s.claim({ worker: 'A' });++ handler(claimed);++ assert.strictEqual(s.get(claimed.id).status, 'running');++++ // restart: a new scheduler instance on a new connection (like a new process)++ const s2 = createScheduler(new Database(db.name), { prefix: 'blog', now: clock, leaseMs: 30000 });++ assert.strictEqual(s2.claim({ worker: 'B' }).length, 0, 'lease still held');++ clock.advance(30001);++ const out = await s2.runDue({ worker: 'B', handler });++ assert.strictEqual(out.done.length, 1);++ assert.strictEqual(out.done[0].attempts, 2);++ assert.deepStrictEqual(out.done[0].result, { changed: 0 });++ const post = db.prepare("SELECT * FROM posts WHERE id = 'post_1'").get();++ assert.strictEqual(post.published_revision, 4);++ assert.strictEqual(post.publish_count, 1, 'published exactly once');++ // the crashed worker coming back cannot overwrite the result++ assert.strictEqual(s.complete(claimed.id, 'A', { late: true }), false);++ assert.deepStrictEqual(s.get(claimed.id).result, { changed: 0 });++});++++test('failures retry with backoff, then fail for good', async () => {++ const clock = fakeClock(T0);++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, backoffMs: [1000] });++ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 });++ const boom = () => { throw new Error('media service down'); };++ let out = await s.runDue({ worker: 'w', handler: boom });++ assert.strictEqual(out.retried.length, 1);++ assert.strictEqual(out.retried[0].lastError, 'media service down');++ assert.strictEqual((await s.runDue({ worker: 'w', handler: boom })).retried.length, 0, 'waits for backoff');++ clock.advance(1000);++ out = await s.runDue({ worker: 'w', handler: boom });++ assert.strictEqual(out.failed.length, 1);++ assert.strictEqual(out.failed[0].status, 'failed');++});++++test('a job whose workers keep crashing is abandoned after maxAttempts and reported as failed', async () => {++ const clock = fakeClock(T0);++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, leaseMs: 1000 });++ s.schedule({ entityId: 'p', action: 'publish', runAt: T0, revision: 1 });++ s.claim({ worker: 'a' }); clock.advance(1001);++ s.claim({ worker: 'b' }); clock.advance(1001);++ const out = await s.runDue({ worker: 'c', handler: () => assert.fail('must not run a third time') });++ assert.strictEqual(out.failed.length, 1);++ assert.match(out.failed[0].lastError, /lease expired/);++});++++test('cancel and cancelPending', () => {++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0) });++ const { job } = s.schedule({ entityId: 'p', action: 'publish', runAt: T0 + 1000, revision: 1 });++ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 5000 });++ assert.strictEqual(s.cancel(job.id), true);++ assert.strictEqual(s.cancelPending('p', 'unpublish'), 1);++ assert.deepStrictEqual(s.jobs('p').map((j) => j.status), ['cancelled', 'cancelled']);++});++++test('validation', () => {++ const s = createScheduler(openDb('sch'), { prefix: 'blog' });++ assert.throws(() => s.schedule({ entityId: 'p', action: 'delete', runAt: T0 }), /action/);++ assert.throws(() => s.schedule({ entityId: 'p', action: 'publish', runAt: 'soon' }), /runAt/);++ assert.throws(() => s.claim({}), /worker/);++});++++run();nnew file mode 100644@@@ -0,0 +1,294 @@++'use strict';++const assert = require('assert');++const { openDb, fakeClock, suite } = require('./helpers/db');++const seo = require('../lib/seo');++++const { test, run } = suite();++const NOW = Date.parse('2026-09-22T12:00:00Z');++const LONG = 'word '.repeat(200);++const base = (extra = {}) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', text: LONG, ...extra });++++// ---- the gate ----------------------------------------------------------------------------------++++test('published, sourced, non-duplicate content is indexable', () => {++ const d = seo.evaluate(base({ citationCount: 2 }), { policy: { requireSources: true } });++ assert.deepStrictEqual(d, {++ gate: 'openvibe-publishing/seo@1', indexable: true, listable: true, robots: 'index, follow',++ canonical: 'https://openvibe.wiki/p/rye', reasons: [], codes: [],++ });++});++++test('every reason code fires on its own fact, with a stable effect', () => {++ const policy = { requireSources: true, sensitiveCategories: ['health'] };++ const cases = [++ ['deleted', { state: 'deleted' }, 'hidden'],++ ['takedown', { takedown: true }, 'hidden'],++ ['private', { visibility: 'private' }, 'hidden'],++ ['gated', { visibility: 'gated' }, 'hidden'],++ ['unlisted', { visibility: 'unlisted' }, 'hidden'],++ ['draft', { state: 'draft' }, 'hidden'],++ ['draft', { state: 'scheduled' }, 'hidden'],++ ['unpublished', { state: 'unpublished' }, 'hidden'],++ ['ai_generated_unreviewed', { authorship: { mode: 'ai', reviewed: false } }, 'hidden'],++ ['stub_provider', { stubProvider: true }, 'hidden'],++ ['unreviewed_sensitive', { category: 'health' }, 'hidden'],++ ['unreviewed_sensitive', { sensitive: true }, 'hidden'],++ ['retracted', { retracted: true }, 'noindex'],++ ['expired', { expiresAt: '2026-09-01T00:00:00Z' }, 'noindex'],++ ['stale_price', { price: { observedAt: '2026-09-01T00:00:00Z' } }, 'noindex'],++ ['stale_price', { price: {} }, 'noindex'],++ ['duplicate_of', { duplicateOf: 'https://openvibe.wiki/p/rye-grain' }, 'noindex'],++ ['thin', { text: 'too short' }, 'noindex'],++ ['unsourced', { citationCount: 0 }, 'noindex'],++ ['unsupported_claims', { unsupportedClaims: 2 }, 'noindex'],++ ['missing_canonical', { canonicalUrl: undefined }, 'noindex'],++ ['noindex_requested', { noindex: true }, 'noindex'],++ ];++ for (const [code, facts, effect] of cases) {++ const f = base({ citationCount: 1, ...facts });++ if (f.canonicalUrl === undefined) delete f.canonicalUrl;++ const d = seo.evaluate(f, { policy, now: NOW });++ assert.deepStrictEqual(d.codes, [code], `${JSON.stringify(facts)} → ${d.codes}`);++ assert.strictEqual(d.reasons[0].effect, effect);++ assert.strictEqual(d.indexable, false);++ assert.strictEqual(d.listable, effect !== 'hidden');++ assert.strictEqual(d.robots, effect === 'hidden' ? 'noindex, nofollow' : 'noindex, follow');++ assert.ok(seo.REASON_BY_CODE[code].description);++ }++});++++test('the gate is deterministic: same input → same output, reasons in REASONS order', () => {++ const facts = base({ state: 'draft', visibility: 'private', text: 'short', noindex: true, citationCount: 0, authorship: { mode: 'ai', reviewed: false }, duplicateOf: 'https://x.test/o' });++ const opts = { policy: { requireSources: true }, now: NOW };++ const a = seo.evaluate(facts, opts);++ const b = seo.evaluate(JSON.parse(JSON.stringify(facts)), opts);++ assert.deepStrictEqual(a, b);++ assert.deepStrictEqual(a.codes, ['private', 'draft', 'ai_generated_unreviewed', 'duplicate_of', 'thin', 'unsourced', 'noindex_requested']);++ const order = seo.REASONS.map((r) => r.code);++ assert.deepStrictEqual([...a.codes].sort((x, y) => order.indexOf(x) - order.indexOf(y)), a.codes);++ assert.strictEqual(a.canonical, 'https://x.test/o', 'a duplicate canonicalises to its original');++ assert.strictEqual(a.reasons.find((r) => r.code === 'thin').detail, '1 of 150 words');++});++++test('reviews lift the AI/stub/sensitive holds; fresh prices and future expiry pass', () => {++ const d = seo.evaluate(base({++ authorship: { mode: 'ai', reviewed: true }, stubProvider: true, sensitive: true, sensitiveReviewed: true,++ expiresAt: '2026-12-01T00:00:00Z', price: { observedAt: NOW - 3600e3 },++ }), { now: NOW });++ assert.strictEqual(d.indexable, true);++ assert.strictEqual(seo.evaluate(base({ authorship: { mode: 'hybrid', reviewed: false } })).indexable, true);++});++++test('no silent pass: unknown facts, missing facts and time facts without `now` throw', () => {++ assert.throws(() => seo.evaluate(base({ visiblity: 'public' })), /unknown fact "visiblity"/);++ assert.throws(() => seo.evaluate({ visibility: 'public', text: LONG }), /state/);++ assert.throws(() => seo.evaluate({ state: 'published', text: LONG }), /visibility/);++ const { text, ...noText } = base();++ assert.throws(() => seo.evaluate(noText), /text or facts.wordCount/);++ assert.throws(() => seo.evaluate(base(), { policy: { requireSources: true } }), /citationCount/);++ assert.throws(() => seo.evaluate(base({ expiresAt: '2026-01-01' })), /needs options.now/);++ assert.throws(() => seo.evaluate(base({ price: { observedAt: NOW } })), /needs options.now/);++ assert.strictEqual(seo.evaluate(noText, { policy: { minWords: 0 } }).indexable, true);++ assert.strictEqual(seo.evaluate(base({ text: undefined, wordCount: 151 })).indexable, true);++});++++// ---- canonical URLs and redirects --------------------------------------------------------------++++test('canonicalUrl', () => {++ assert.strictEqual(seo.canonicalUrl('https://OpenVibe.Wiki', ['spaces', 'food', 'Crème brûlée']), 'https://openvibe.wiki/spaces/food/Cr%C3%A8me%20br%C3%BBl%C3%A9e');++ 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/);++ assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);++});++++test('history-aware redirects: every old slug 301s straight to the current path; gone → 410', () => {++ const redirects = seo.createRedirectStore(openDb('seo'), { prefix: 'wiki_page', now: fakeClock() });++ const current = { pg_1: '/p/rye' };++ const currentPath = (id) => current[id] || null;++ redirects.recordMove('pg_1', '/p/ryegrass', '/p/rye-grain');++ redirects.recordMove('pg_1', '/p/rye-grain', '/p/rye');++ assert.deepStrictEqual(redirects.resolve('/p/ryegrass', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });++ assert.deepStrictEqual(redirects.resolve('/p/rye-grain/?x=1', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });++ assert.strictEqual(redirects.resolve('/p/rye', { currentPath }), null);++ assert.strictEqual(redirects.resolve('/p/unknown', { currentPath }), null);++ // renamed back to an old slug: that slug is live again and must not redirect++ redirects.recordMove('pg_1', '/p/rye', '/p/ryegrass');++ current.pg_1 = '/p/ryegrass';++ assert.strictEqual(redirects.resolve('/p/ryegrass', { currentPath }), null);++ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }).location, '/p/ryegrass');++ assert.deepStrictEqual(redirects.history('pg_1').map((h) => h.path).sort(), ['/p/rye', '/p/rye-grain']);++ delete current.pg_1;++ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }), { status: 410, entityId: 'pg_1' });++ assert.strictEqual(redirects.table, 'wiki_page_redirects');++ assert.throws(() => redirects.recordMove('pg_1', 'no-slash', '/x'), /start with/);++});++++// ---- meta tags ---------------------------------------------------------------------------------++++test('meta tags take robots and canonical from the decision, and refuse to render without one', () => {++ const ok = seo.evaluate(base());++ const html = seo.metaTags({ title: 'Rye', description: 'A grass.', decision: ok });++ assert.match(html, /<meta name="robots" content="index, follow">/);++ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye">/);++ const dup = seo.evaluate(base({ duplicateOf: 'https://openvibe.wiki/p/original' }));++ assert.match(seo.metaTags({ title: 'Rye', decision: dup }), /rel="canonical" href="https:\/\/openvibe.wiki\/p\/original"/);++ assert.match(seo.metaTags({ title: 'x', decision: seo.evaluate(base({ visibility: 'private' })) }), /content="noindex, nofollow"/);++ assert.throws(() => seo.metaTags({ title: 'Rye' }), /decision/);++ assert.strictEqual(seo.robotsMeta(dup), '<meta name="robots" content="noindex, follow">');++ assert.strictEqual(seo.xRobotsTag(ok), 'index, follow');++});++++// ---- sitemaps ----------------------------------------------------------------------------------++++test('sitemaps contain only indexable URLs and never invent lastmod', () => {++ const entries = [++ { loc: 'https://openvibe.wiki/p/a', lastmod: '2026-09-20T08:00:00Z', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/a' })) },++ { loc: 'https://openvibe.wiki/p/b', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/b' })) },++ { loc: 'https://openvibe.wiki/p/draft', lastmod: '2026-09-21', decision: seo.evaluate(base({ state: 'draft' })) },++ { loc: 'https://openvibe.wiki/p/private', decision: seo.evaluate(base({ visibility: 'private' })) },++ { loc: 'https://openvibe.wiki/p/thin', decision: seo.evaluate(base({ text: 'short' })) },++ ];++ const out = seo.sitemap(entries);++ assert.strictEqual(out.count, 2);++ assert.strictEqual(out.files.length, 1);++ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/a<\/loc><lastmod>2026-09-20<\/lastmod>/);++ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/b<\/loc><\/url>/, 'no lastmod for b');++ assert.doesNotMatch(out.files[0], /draft|private|thin/);++ assert.deepStrictEqual(out.skipped.map((s) => s.codes), [['draft'], ['private'], ['thin']]);++ assert.throws(() => seo.sitemap([{ loc: 'https://x.test/' }]), /decision/);++ const many = Array.from({ length: 5 }, (_, i) => ({ loc: `https://x.test/${i}`, decision: seo.evaluate(base({ canonicalUrl: `https://x.test/${i}` })) }));++ assert.strictEqual(seo.sitemap(many, { maxUrls: 2 }).files.length, 3);++ assert.match(seo.sitemapIndex([{ loc: 'https://x.test/sitemap-1.xml' }]), /<sitemapindex/);++ assert.match(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }), /Sitemap: https:\/\/x.test\/sitemap.xml/);++});++++// ---- feeds -------------------------------------------------------------------------------------++++function feedFixture() {++ const ok = seo.evaluate(base());++ const thin = seo.evaluate(base({ text: 'short' }));++ return [++ { id: 'tag:openvibe.blog,2026:post/1', url: 'https://openvibe.blog/a/1', title: 'Rye <bread> & co', summary: 'About rye', contentHtml: '<p>Rye</p>', published: '2026-09-20T08:00:00Z', updated: '2026-09-21T08:00:00Z', authors: [{ name: 'Alex', url: 'https://openvibe.blog/a' }], tags: ['bread'], decision: ok },++ { id: 'post-2', url: 'https://openvibe.blog/a/2', title: 'Undated and authorless', decision: thin },++ { id: 'post-3', url: 'https://openvibe.blog/a/3', title: 'Private', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'private' })) },++ { id: 'post-4', url: 'https://openvibe.blog/a/4', title: 'VIP', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'gated' })) },++ ];++}++const channel = { title: 'Alex bakes', link: 'https://openvibe.blog/a', feedUrl: 'https://openvibe.blog/a/feed.xml', description: 'Bread notes' };++++test('RSS: only listable items; no invented pubDate or author; XML escaped', () => {++ const xml = seo.rssFeed(channel, feedFixture());++ assert.match(xml, /<title>Rye <bread> & co<\/title>/);++ assert.match(xml, /<pubDate>Sun, 20 Sep 2026 08:00:00 GMT<\/pubDate>/);++ assert.match(xml, /<dc:creator>Alex<\/dc:creator>/);++ assert.strictEqual((xml.match(/<item>/g) || []).length, 2, 'the thin post is listable, private and VIP are not');++ assert.doesNotMatch(xml, /Private|VIP/);++ const item2 = xml.split('<item>')[2];++ assert.doesNotMatch(item2, /pubDate|dc:creator/);++ assert.doesNotMatch(xml, /lastBuildDate/, 'no channel date unless provided');++ assert.throws(() => seo.rssFeed({ title: 'x', link: 'https://x.test' }, []), /description/);++});++++test('Atom: undated entries are left out instead of dated "now"; feed updated = newest entry', () => {++ const xml = seo.atomFeed(channel, feedFixture());++ assert.strictEqual((xml.match(/<entry>/g) || []).length, 1);++ assert.match(xml, /<updated>2026-09-21T08:00:00.000Z<\/updated>/);++ assert.match(xml, /<id>tag:openvibe.blog,2026:post\/1<\/id>/);++ assert.doesNotMatch(xml, /Undated/);++ assert.throws(() => seo.atomFeed(channel, [feedFixture()[1]]), /needs channel.updated/);++ assert.match(seo.atomFeed({ ...channel, updated: '2026-09-22T00:00:00Z' }, [feedFixture()[1]]), /<feed/);++});++++test('JSON Feed 1.1: only provided fields', () => {++ const f = seo.jsonFeed(channel, feedFixture());++ assert.strictEqual(f.version, 'https://jsonfeed.org/version/1.1');++ assert.strictEqual(f.items.length, 2);++ assert.deepStrictEqual(Object.keys(f.items[1]).sort(), ['content_text', 'id', 'title', 'url']);++ assert.strictEqual(f.items[0].date_published, '2026-09-20T08:00:00.000Z');++ assert.deepStrictEqual(f.items[0].authors, [{ name: 'Alex', url: 'https://openvibe.blog/a' }]);++ assert.throws(() => seo.jsonFeed(channel, [{ id: 'x', url: 'https://x.test' }]), /decision/);++ assert.match(seo.feedLinks([{ type: 'atom', href: '/feed.atom', title: 'Atom' }]), /application\/atom\+xml/);++});++++// ---- structured data ---------------------------------------------------------------------------++++/** Every leaf in the output must come from the input (or be a schema.org constant). */++function leaves(obj, out = [], key = null) {++ if (obj == null) return out;++ if (Array.isArray(obj)) { for (const x of obj) leaves(x, out, key); return out; }++ if (typeof obj === 'object') { for (const [k, v] of Object.entries(obj)) leaves(v, out, k); return out; }++ out.push({ key, value: obj });++ return out;++}++function assertOnlyFrom(output, input, allowed = []) {++ const inputs = new Set(leaves(input).map((l) => String(l.value)));++ for (const l of leaves(output)) {++ if (['@context', '@type', '@id'].includes(l.key)) continue;++ if (allowed.includes(String(l.value))) continue;++ assert.ok(inputs.has(String(l.value)) || inputs.has(new Date(l.value).toISOString()) || [...inputs].some((v) => !Number.isNaN(Date.parse(v)) && Date.parse(v) === Date.parse(l.value)),++ `invented value ${l.key}=${JSON.stringify(l.value)}`);++ }++}++function hasKey(obj, name) { return JSON.stringify(obj).includes(`"${name}"`); }++++test('article: a missing author or date is omitted, never defaulted', () => {++ const input = { type: 'BlogPosting', headline: 'Rye', url: 'https://openvibe.blog/a/1' };++ const ld = seo.structuredData.article(input);++ assert.deepStrictEqual(ld, { '@context': 'https://schema.org', '@type': 'BlogPosting', '@id': 'https://openvibe.blog/a/1#article', headline: 'Rye', url: 'https://openvibe.blog/a/1', mainEntityOfPage: 'https://openvibe.blog/a/1' });++ for (const k of ['author', 'datePublished', 'dateModified', 'publisher', 'image', 'citation']) assert.ok(!hasKey(ld, k), `${k} must be absent`);++ const full = { type: 'NewsArticle', headline: 'Rye', url: 'https://openvibe.news/s/1', datePublished: '2026-09-20T08:00:00Z', authors: [{ name: 'Sam' }, { name: '' }],++ publisher: { name: 'OpenVibe News' }, citations: [{ url: 'https://example.org/src', title: 'Source' }, { title: 'no url' }] };++ const ld2 = seo.structuredData.article(full);++ assert.deepStrictEqual(ld2.author, [{ '@type': 'Person', name: 'Sam' }], 'nameless author dropped');++ assert.deepStrictEqual(ld2.citation, [{ '@type': 'CreativeWork', url: 'https://example.org/src', name: 'Source' }]);++ assertOnlyFrom(ld2, full);++ assert.strictEqual(seo.structuredData.article({ url: 'https://x.test' }), null, 'no headline → no Article');++});++++test('review: no rating, no reviewRating; no bestRating default', () => {++ const input = { itemReviewed: { type: 'Product', name: 'Bread knife' }, author: { name: 'Sam' }, body: 'Sharp.' };++ const ld = seo.structuredData.review(input);++ assert.ok(!hasKey(ld, 'reviewRating'));++ assert.ok(!hasKey(ld, 'ratingValue'));++ assert.ok(!hasKey(ld, 'datePublished'));++ const rated = seo.structuredData.review({ ...input, rating: { value: 4 } });++ assert.deepStrictEqual(rated.reviewRating, { '@type': 'Rating', ratingValue: 4 }, 'bestRating not assumed to be 5');++ assertOnlyFrom(rated, { ...input, rating: { value: 4 } });++ assert.strictEqual(seo.structuredData.review({ body: 'x' }), null);++});++++test('aggregate ratings need a value AND a real count; otherwise nothing', () => {++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5 } }).aggregateRating, undefined);++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5, count: 0 } }).aggregateRating, undefined);++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { count: 12 } }).aggregateRating, undefined);++ const ok = seo.structuredData.ratedThing({ type: 'LocalBusiness', name: 'Cafe', aggregateRating: { value: 4.5, count: 12, best: 5 } });++ assert.deepStrictEqual(ok.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.5, ratingCount: 12, bestRating: 5 });++});++++test('product: no price or currency → no Offer; no availability → none assumed', () => {++ const noPrice = seo.structuredData.product({ name: 'Rye flour', offers: [{ priceCurrency: 'EUR', availability: 'in_stock' }, { price: 3.2 }] });++ assert.ok(!hasKey(noPrice, 'offers'));++ assert.ok(!hasKey(noPrice, 'price'));++ assert.ok(!hasKey(noPrice, 'aggregateRating'));++ const input = { name: 'Rye flour', url: 'https://openvibe.deals/o/1', offers: [{ price: '3.20', priceCurrency: 'EUR', url: 'https://shop.example/rye' }] };++ const priced = seo.structuredData.product(input);++ assert.deepStrictEqual(priced.offers, [{ '@type': 'Offer', price: 3.2, priceCurrency: 'EUR', url: 'https://shop.example/rye' }]);++ assert.ok(!hasKey(priced, 'availability'), 'availability never assumed');++ assertOnlyFrom(priced, input, ['3.2']);++ const avail = seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'out_of_stock' }] });++ assert.strictEqual(avail.offers[0].availability, 'https://schema.org/OutOfStock');++ assert.ok(!hasKey(seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'probably' }] }), 'availability'));++});++++test('breadcrumbs, webPage and the JSON-LD script tag', () => {++ const bc = seo.structuredData.breadcrumbs([{ name: 'Food', url: 'https://w.test/food' }, { name: 'Rye' }]);++ assert.deepStrictEqual(bc.itemListElement[1], { '@type': 'ListItem', position: 2, name: 'Rye' });++ assert.strictEqual(seo.structuredData.webPage({ url: 'https://w.test/' })['@type'], 'WebPage');++ const tag = seo.jsonLdScript(seo.structuredData.article({ headline: '</script><script>alert(1)</script>', url: 'https://w.test/' }));++ assert.doesNotMatch(tag, /<\/script><script>/);++ assert.strictEqual(seo.jsonLdScript(null), '');++ assert.deepStrictEqual(seo.compact({ a: '', b: [null, { '@type': 'X' }], c: NaN, d: 0, e: false }), { d: 0, e: false });++});++++run();nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++const assert = require('assert');++const ssr = require('../lib/ssr');++const { diffText } = require('../lib/diff');++const { suite } = require('./helpers/db');++++const { test, run } = suite();++++test('html`` escapes interpolations unless raw()', () => {++ const name = '<img src=x onerror=alert(1)>';++ const out = String(ssr.html`<h1>${name}</h1>${ssr.raw('<em>ok</em>')}${null}${['a', '<b>']}`);++ assert.strictEqual(out, '<h1><img src=x onerror=alert(1)></h1><em>ok</em>a<b>');++ assert.strictEqual(ssr.escapeHtml(`"'&`), '"'&');++});++++test('markdown: the supported subset renders; raw HTML and unsafe links do not', () => {++ const src = [++ '# Title', '', 'Some **bold**, *em*, ~~gone~~ and `code <x>`.', '',++ '- one', '- two', '', '1. first', '2. second', '', '> quoted', '', '---', '',++ '```js', 'const a = "<b>";', '```', '',++ '[site](https://openvibe.wiki/p) [bad](javascript:alert(1)) <script>alert(1)</script>',++ 'Bare https://example.org/x?a=1&b=2.',++ ].join('\n');++ const html = ssr.renderMarkdown(src);++ assert.match(html, /<h2>Title<\/h2>/, '# is demoted: the page owns <h1>');++ assert.match(html, /<strong>bold<\/strong>, <em>em<\/em>, <del>gone<\/del> and <code>code <x><\/code>/);++ assert.match(html, /<ul><li>one<\/li><li>two<\/li><\/ul>/);++ assert.match(html, /<ol><li>first<\/li><li>second<\/li><\/ol>/);++ assert.match(html, /<blockquote><p>quoted<\/p><\/blockquote>/);++ assert.match(html, /<pre><code class="language-js">const a = "<b>";<\/code><\/pre>/);++ assert.match(html, /<a href="https:\/\/openvibe.wiki\/p" rel="nofollow ugc noopener">site<\/a>/);++ assert.doesNotMatch(html, /href="javascript/);++ assert.doesNotMatch(html, /<script>/);++ assert.match(html, /<a href="https:\/\/example.org\/x\?a=1&b=2" rel="nofollow ugc noopener">https:\/\/example.org\/x\?a=1&b=2<\/a>\./);++ assert.match(ssr.renderMarkdown('[a](/local)', { rel: 'noopener' }), /<a href="\/local" rel="noopener">a<\/a>/);++ assert.doesNotMatch(ssr.renderMarkdown('[a](//evil.test)'), /<a /);++ assert.doesNotMatch(ssr.renderMarkdown('[x](https://a.test/"onmouseover="alert(1))'), /" onmouseover|"onmouseover="alert/);++});++++test('markdownToText and wordCount', () => {++ assert.strictEqual(ssr.markdownToText('# Hi\n\nSome **bold** [link](https://x.test).\n\n```\ncode\n```'), 'Hi Some bold link. code');++ assert.strictEqual(ssr.markdownToText('one two three four', 10), 'one two…');++ assert.strictEqual(ssr.wordCount("It's a rye-bread loaf, 2 kg."), 6);++ assert.strictEqual(ssr.wordCount(''), 0);++});++++test('pagination is plain links and clamps out-of-range pages', () => {++ const p = ssr.paginate({ page: 3, perPage: 10, total: 95, href: (n) => `/tags/rye?page=${n}` });++ assert.strictEqual(p.pages, 10);++ assert.strictEqual(p.offset, 20);++ assert.strictEqual(p.prev.href, '/tags/rye?page=2');++ const html = ssr.paginationHtml(p);++ assert.match(html, /<a href="\/tags\/rye\?page=2" rel="prev">/);++ assert.match(html, /<span aria-current="page">3<\/span>/);++ assert.match(html, /<a href="\/tags\/rye\?page=10">10<\/a>/);++ const far = ssr.paginate({ page: 99, perPage: 10, total: 95, href: (n) => `?p=${n}` });++ assert.strictEqual(far.page, 10);++ assert.strictEqual(far.outOfRange, true);++ assert.strictEqual(ssr.paginationHtml(ssr.paginate({ total: 3, href: String })), '');++});++++test('breadcrumbs, diff markup and honest <time>', () => {++ const bc = ssr.breadcrumbsHtml([{ name: 'Food', url: '/food' }, { name: '<Rye>', url: '/food/rye' }]);++ assert.strictEqual(bc, '<nav class="ov-breadcrumbs" aria-label="Breadcrumb"><ol><li><a href="/food">Food</a></li><li aria-current="page"><Rye></li></ol></nav>');++ const d = ssr.diffHtml(diffText('a <b>', 'a <i>', { mode: 'word' }));++ assert.match(d, /<del>b<\/del><ins>i<\/ins>/);++ assert.doesNotMatch(d, /<b>/);++ assert.strictEqual(ssr.timeTag(null), '');++ assert.strictEqual(ssr.timeTag('not a date'), '');++ assert.strictEqual(ssr.timeTag('2026-09-20T10:00:00Z'), '<time datetime="2026-09-20T10:00:00.000Z">2026-09-20</time>');++});++++run();nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++const assert = require('assert');++const { openDb, suite } = require('./helpers/db');++const { createTaxonomy, slugify } = require('../lib/taxonomy');++++const { test, run } = suite();++++test('slugify', () => {++ assert.strictEqual(slugify('Crème Brûlée & Co.'), 'creme-brulee-co');++ assert.strictEqual(slugify("Baker's Guide"), 'bakers-guide');++ assert.strictEqual(slugify('日本 料理'), '日本-料理');++ assert.throws(() => slugify('!!!'), /slug/);++ assert.ok(slugify('x'.repeat(200)).length <= 80);++});++++test('terms are unique per vocabulary slug; ensureTerm is get-or-create', () => {++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });++ const a = tax.ensureTerm({ vocabulary: 'tag', name: 'Sourdough' });++ const b = tax.ensureTerm({ vocabulary: 'tag', name: 'sourdough' });++ const c = tax.ensureTerm({ vocabulary: 'category', name: 'Sourdough' });++ assert.strictEqual(a.id, b.id);++ assert.notStrictEqual(a.id, c.id);++ assert.strictEqual(tax.bySlug('tag', 'sourdough').name, 'Sourdough');++ assert.deepStrictEqual(tax.tables, { terms: 'blog_terms', links: 'blog_term_links' });++});++++test('hierarchical categories: ancestors (breadcrumbs), descendants, tree, cycles refused', () => {++ const tax = createTaxonomy(openDb('tax'), { prefix: 'wiki' });++ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });++ const baking = tax.ensureTerm({ vocabulary: 'category', name: 'Baking', parentId: food.id });++ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: baking.id });++ assert.deepStrictEqual(tax.ancestors(bread.id).map((t) => t.name), ['Food', 'Baking', 'Bread']);++ assert.deepStrictEqual(tax.descendants(food.id).map((t) => t.name).sort(), ['Baking', 'Bread']);++ const tree = tax.tree('category');++ assert.strictEqual(tree.length, 1);++ assert.strictEqual(tree[0].children[0].children[0].name, 'Bread');++ assert.throws(() => tax.setParent(food.id, bread.id), (e) => e.code === 'term.cycle');++ assert.throws(() => tax.setParent(food.id, food.id), (e) => e.code === 'term.cycle');++ const tag = tax.ensureTerm({ vocabulary: 'tag', name: 'x' });++ assert.throws(() => tax.setParent(tag.id, food.id), (e) => e.code === 'term.parent_vocabulary');++});++++test('setTerms replaces one vocabulary; entitiesFor includes descendants on request', () => {++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });++ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });++ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });++ tax.setTerms('post_1', 'tag', ['Rye', 'rye', 'Starter']);++ tax.setTerms('post_1', 'category', [bread.id]);++ tax.setTerms('post_2', 'category', [food.id]);++ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['rye', 'starter']);++ tax.setTerms('post_1', 'tag', ['Starter']);++ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['starter']);++ assert.strictEqual(tax.termsFor('post_1', 'category').length, 1, 'other vocabularies untouched');++ assert.deepStrictEqual(tax.entitiesFor(food.id), ['post_2']);++ assert.deepStrictEqual(tax.entitiesFor(food.id, { includeDescendants: true }), ['post_1', 'post_2']);++ assert.throws(() => tax.setTerms('post_1', 'tag', [bread.id]), (e) => e.code === 'term.vocabulary_mismatch');++});++++test('remove re-parents children and drops links', () => {++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });++ const a = tax.ensureTerm({ vocabulary: 'category', name: 'A' });++ const b = tax.ensureTerm({ vocabulary: 'category', name: 'B', parentId: a.id });++ const c = tax.ensureTerm({ vocabulary: 'category', name: 'C', parentId: b.id });++ tax.setTerms('e', 'category', [b.id]);++ tax.remove(b.id);++ assert.strictEqual(tax.get(c.id).parentId, a.id);++ assert.deepStrictEqual(tax.termsFor('e'), []);++ assert.strictEqual(tax.rename(a.id, ' All ').name, 'All');++});++++run();nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++/**++ * Wave 15 exit proof: two different products share revision, citation, SEO-gate and feed code++ * without sharing a database or an authority.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { tempDir, fakeClock, suite } = require('./helpers/db');++const { createWiki } = require('../examples/two-products/wiki/app');++const { createBlog } = require('../examples/two-products/blog/app');++++const { test, run } = suite();++const ALEX = 'usr_01J8Z6Q3KX0000000000000000';++const EDITOR = 'usr_01J8Z6Q3KX0000000000000001';++const LONG = 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.';++const MED = 'med_01J8Z6Q3KX0000000000000000';++++function tablesOf(db) {++ return db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).sort();++}++++function listen(handler) {++ return new Promise((resolve) => {++ const server = http.createServer(handler).listen(0, '127.0.0.1', () => {++ const base = `http://127.0.0.1:${server.address().port}`;++ resolve({ base, close: () => server.close(), get: (p) => fetch(base + p, { redirect: 'manual' }) });++ });++ });++}++++function setup() {++ const clock = fakeClock(Date.parse('2026-09-22T12:00:00Z'));++ const wikiDir = tempDir('wiki');++ const blogDir = tempDir('blog');++ const wiki = createWiki({ dbPath: path.join(wikiDir, 'wiki.db'), now: clock });++ const blog = createBlog({ dbPath: path.join(blogDir, 'blog.db'), now: clock });++ return { clock, wiki, blog, wikiFile: path.join(wikiDir, 'wiki.db'), blogFile: path.join(blogDir, 'blog.db') };++}++++test('same code: both products run the very same package modules', () => {++ const { wiki, blog } = setup();++ const revisionsFile = require.resolve('openvibe-publishing/revisions');++ assert.strictEqual(revisionsFile, path.join(__dirname, '..', 'lib', 'revisions.js'));++ for (const mod of ['revisions', 'citations', 'seo', 'ssr']) {++ const file = require.resolve(`openvibe-publishing/${mod}`);++ const users = Object.values(require.cache).filter((m) => m.children.some((c) => c.filename === file)).map((m) => path.basename(path.dirname(m.filename)));++ assert.ok(users.includes('wiki') && users.includes('blog'), `${mod} is loaded by both apps (${users})`);++ }++ wiki.close(); blog.close();++});++++test('no shared database: two files, each holding only its own product\'s tables', () => {++ const { wiki, blog, wikiFile, blogFile } = setup();++ assert.notStrictEqual(fs.realpathSync(wikiFile), fs.realpathSync(blogFile));++ const wt = tablesOf(wiki.db);++ const bt = tablesOf(blog.db);++ assert.ok(wt.length > 5 && bt.length > 5);++ assert.ok(wt.every((n) => n.startsWith('wiki_')), `wiki db: ${wt.filter((n) => !n.startsWith('wiki_'))}`);++ assert.ok(bt.every((n) => n.startsWith('blog_')), `blog db: ${bt.filter((n) => !n.startsWith('blog_'))}`);++ for (const t of ['wiki_page_revisions', 'wiki_citations', 'wiki_page_redirects', 'wiki_pages']) assert.ok(wt.includes(t), t);++ for (const t of ['blog_post_revisions', 'blog_post_citations', 'blog_schedule_jobs', 'blog_terms', 'blog_post_attachments', 'blog_posts']) assert.ok(bt.includes(t), t);++ const attached = wiki.db.prepare('PRAGMA database_list').all();++ assert.deepStrictEqual(attached.map((d) => d.name), ['main'], 'nothing attached across products');++ wiki.close(); blog.close();++});++++test('each product owns its state: writes in one never appear in the other', () => {++ const { wiki, blog } = setup();++ const { id: pageId } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] });++ wiki.publish(pageId, { revision: 1, actor: ALEX });++ const { id: postId } = blog.draft({ title: 'First loaf', body: LONG, authorName: 'Alex', tags: ['rye'] });++ assert.strictEqual(wiki.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_revisions').get().n, 1);++ assert.strictEqual(blog.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions').get().n, 1);++ assert.strictEqual(wiki.revisions.head(postId), null);++ assert.strictEqual(blog.revisions.head(pageId), null);++ assert.strictEqual(blog.citations.history(postId).length, 0);++ assert.strictEqual(wiki.citations.history(pageId).length, 1);++ wiki.close(); blog.close();++});++++test('revisions and citations: edit, diff, revert; citations stay on the revision that used them', () => {++ const { wiki, clock } = setup();++ const { id } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/a' }, { url: 'https://example.org/b' }] });++ const [a] = wiki.citations.forRevision(id, 1);++ clock.advance(1000);++ const r2 = wiki.edit(id, { expectedRevision: 1, body: LONG.replace('grass', 'cereal grass'), author: ALEX, keepSources: [a.id] });++ assert.throws(() => wiki.edit(id, { expectedRevision: 1, body: 'lost update', author: ALEX }), (e) => e.status === 412);++ const diff = wiki.revisions.diff(id, 1, r2, { mode: 'word' });++ assert.ok(diff.content.ops.some((o) => o.op === 'insert' && o.text.includes('cereal')));++ assert.deepStrictEqual(wiki.citations.forRevision(id, 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);++ assert.deepStrictEqual(wiki.citations.forRevision(id, r2).map((c) => c.url), ['https://example.org/a']);++ const r3 = wiki.revert(id, { toRevision: 1, expectedRevision: r2, author: ALEX });++ assert.strictEqual(wiki.revisions.get(id, r3).content, LONG);++ assert.deepStrictEqual(wiki.citations.forRevision(id, r3).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);++ assert.deepStrictEqual(wiki.revisions.lineage(id).map((r) => r.number), [3, 2, 1]);++ wiki.close();++});++++test('one gate, two editorial policies: the wiki needs sources, the blog does not', () => {++ const { wiki, blog } = setup();++ const { id: unsourced } = wiki.createPage({ title: 'Barley', body: LONG, author: ALEX });++ wiki.publish(unsourced, { revision: 1, actor: ALEX });++ const { id: post, revision } = blog.draft({ title: 'Barley notes', body: LONG });++ blog.publishNow(post, revision);++ const [w] = wiki.published();++ const [b] = blog.published();++ assert.deepStrictEqual(w.decision.codes, ['unsourced']);++ assert.strictEqual(b.decision.indexable, true);++ assert.doesNotMatch(wiki.sitemapXml(), /barley/, 'unsourced page stays out of the sitemap');++ assert.match(wiki.atom(), /Barley/, 'but is listable in the feed (noindex, not hidden)');++ assert.match(blog.sitemapXml(), /posts\/barley-notes/);++ wiki.close(); blog.close();++});++++test('AI-generated wiki page: draft + noindex until a person reviews it', () => {++ const { wiki } = setup();++ const authorship = require('openvibe-publishing/authorship');++ const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 1, runId: 'run_1' } });++ const { id, initial } = wiki.createPage({ title: 'Spelt', body: LONG, author: EDITOR, authorship: rec, sources: [{ sourceItemId: 'src_9' }] });++ assert.deepStrictEqual(initial, { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });++ assert.throws(() => wiki.publish(id, { revision: 1, actor: EDITOR }), /ai_generated_unreviewed/);++ wiki.reviews.record({ entityId: id, revision: 1, reviewer: EDITOR, decision: 'approved' });++ wiki.publish(id, { revision: 1, actor: EDITOR });++ const [p] = wiki.published();++ assert.strictEqual(p.decision.indexable, true);++ assert.match(wiki.renderPage(p.page), /AI-generated by workflow wiki.generate_page v1, reviewed by a person/);++ wiki.close();++});++++test('scheduled blog publication is idempotent across a worker restart', async () => {++ const { blog, clock, blogFile } = setup();++ const { id, revision } = blog.draft({ title: 'Scheduled', body: LONG });++ const at = clock() + 3600e3;++ blog.schedulePublish(id, { revision, at });++ blog.schedulePublish(id, { revision, at });++ assert.strictEqual(blog.scheduler.jobs(id).length, 1);++ clock.set(at);++ const [job] = blog.scheduler.claim({ worker: 'w1' }); // worker 1 dies before finishing++ assert.ok(job);++ blog.close();++ const blog2 = createBlog({ dbPath: blogFile, now: clock }); // "restart"++ clock.advance(60001);++ const out = await blog2.runScheduled('w2');++ assert.strictEqual(out.done.length, 1);++ assert.strictEqual(blog2.published().length, 1);++ await blog2.runScheduled('w3');++ assert.strictEqual(blog2.scheduler.jobs(id)[0].attempts, 2);++ blog2.close();++});++++test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted leave feeds and sitemaps', async () => {++ const { wiki, blog, clock } = setup();++ const { id } = wiki.createPage({ title: 'Rye', body: `${LONG}\n\nSee [wheat](/p/wheat).`, author: ALEX, sources: [{ url: 'https://example.org/rye', title: 'Rye facts', retrievedAt: '2026-09-20T10:00:00Z', licenseNote: 'CC BY 4.0' }] });++ wiki.publish(id, { revision: 1, actor: ALEX });++ wiki.rename(id, 'Rye (grain)');++ const { id: post, revision } = blog.draft({ title: 'Crumb shots', body: LONG, authorName: 'Alex', tags: ['bread'] });++ blog.media.attach({ entityId: post, mediaId: MED, role: 'inline', alt: 'crumb' });++ blog.publishNow(post, revision);++ const w = await listen(wiki.handler);++ const b = await listen(blog.handler);++ try {++ let res = await w.get('/p/rye-grain');++ let html = await res.text();++ assert.strictEqual(res.status, 200);++ assert.match(html, /<h1>Rye<\/h1>/, 'renaming the slug does not touch the title field');++ assert.match(html, /grown extensively as a grain/, 'the content is in the HTML');++ assert.match(html, /<a href="https:\/\/example.org\/rye" rel="noopener">Rye facts<\/a>, retrieved <time datetime="2026-09-20T10:00:00.000Z">/);++ assert.match(html, /<meta name="robots" content="index, follow">/);++ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye-grain">/);++ assert.match(html, /application\/ld\+json/);++ assert.doesNotMatch(html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, ''), /<script/i, 'no JavaScript needed');++++ res = await w.get('/p/rye');++ assert.strictEqual(res.status, 301);++ assert.strictEqual(res.headers.get('location'), '/p/rye-grain');++++ res = await b.get('/posts/crumb-shots');++ html = await res.text();++ assert.strictEqual(res.status, 200);++ assert.match(html, /<img src="https:\/\/openvibe.media\/o\/med_/);++ blog.media.markBroken(MED, 'deleted');++ html = await (await b.get('/posts/crumb-shots')).text();++ assert.match(html, /data-state="broken"/);++ assert.doesNotMatch(html, /<img/);++++ // private: gone from page, feeds and sitemap; the event is internal++ wiki.setVisibility(id, 'private', { actor: ALEX });++ assert.strictEqual((await w.get('/p/rye-grain')).status, 404);++ assert.doesNotMatch(await (await w.get('/sitemap.xml')).text(), /rye/);++ assert.doesNotMatch(await (await w.get('/feed.atom')).text(), /<entry>/);++ assert.strictEqual(JSON.parse(await (await w.get('/feed.json')).text()).items.length, 0);++ const events = wiki.outbox();++ assert.deepStrictEqual(events.map((e) => e.event_type), ['wiki.page.published', 'wiki.page.updated']);++ assert.strictEqual(events[1].visibility, 'internal');++ assert.deepStrictEqual(events[1].payload.document.acl, { public: false, subjects: [ALEX] });++++ blog.setVisibility(post, 'gated');++ assert.strictEqual((await b.get('/posts/crumb-shots')).status, 404);++ assert.doesNotMatch(await (await b.get('/feed.xml')).text(), /Crumb/);++ assert.doesNotMatch(await (await b.get('/sitemap.xml')).text(), /crumb/);++++ // deleted: 410 on the page and on its old slug; tombstone event++ clock.advance(1000);++ wiki.remove(id, { actor: ALEX });++ assert.strictEqual((await w.get('/p/rye-grain')).status, 410);++ assert.strictEqual((await w.get('/p/rye')).status, 410);++ const last = wiki.outbox().pop();++ assert.strictEqual(last.event_type, 'wiki.page.deleted');++ assert.strictEqual(last.payload.document.deleted, true);++ assert.ok(!('body' in last.payload.document));++ } finally { w.close(); b.close(); wiki.close(); blog.close(); }++});++++test('feeds from both products carry only provided dates and authors', () => {++ const { wiki, blog } = setup();++ const { id } = wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });++ wiki.publish(id, { revision: 1, actor: ALEX });++ const { id: post, revision } = blog.draft({ title: 'No byline', body: LONG });++ blog.publishNow(post, revision);++ const rss = blog.rss();++ assert.doesNotMatch(rss, /dc:creator/, 'no author invented for a post without a byline');++ assert.match(rss, /<pubDate>Tue, 22 Sep 2026 12:00:00 GMT<\/pubDate>/);++ const jf = blog.jsonFeedDoc();++ assert.ok(!('authors' in jf.items[0]));++ const atom = wiki.atom();++ assert.doesNotMatch(atom, /<author>/);++ assert.match(atom, /<published>2026-09-22T12:00:00.000Z<\/published>/);++ wiki.close(); blog.close();++});++++run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # the consuming services run Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # The release artifact is GitHub's tarball of the tag (repository root = package root):
+ # install one built the same way into an empty project and load every entry point by name.
+ - name: install from a tag-style tarball
+ run: |
+ git archive --format=tar.gz --prefix=OpenVibe.Publishing-ci/ -o "$RUNNER_TEMP/openvibe-publishing.tgz" HEAD
+ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
+ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-publishing.tgz" better-sqlite3@11
+ node -e "
+ const p = require('openvibe-publishing');
+ for (const m of ['revisions','schedule','taxonomy','citations','media','discussion','seo','authorship','index-hooks','ssr']) require('openvibe-publishing/' + m);
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ p.revisions.createRevisionStore(db, { prefix: 'ci' }).create({ entityId: 'e', expectedRevision: 0, content: 'x' });
+ console.log('ok', require('openvibe-publishing/package.json').version);
+ "
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+node_modules/
+*.db
+*.db-shm
+*.db-wal
+.tmp/
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+# Changelog
+
+All notable changes to `openvibe-publishing`. Versions follow [semver](https://semver.org/): a
+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.
+
+## 0.1.0 — 2026-09-22
+
+First version (roadmap Wave 15). Packages only: no runtime, no domain, no database.
+
+### Added
+- `revisions`: drafts, immutable revisions with parent pointers (UPDATE/DELETE blocked by SQLite
+ triggers), optimistic concurrency with a 412 `revision.conflict`, line and word diffs (Myers, with
+ a bounded fallback), revert as a new revision, audited `purgeEntity`.
+- `schedule`: idempotent publish/unpublish jobs with leases; re-run safe after a worker crash;
+ retry with backoff; abandoned after `maxAttempts`; injectable clock.
+- `taxonomy`: slugs, get-or-create terms per vocabulary, hierarchical categories, entity links.
+- `citations`: append-only source references per revision (Sources item id / URL, retrieved_at,
+ quote span, license note), `carryForward`, lookups by source item.
+- `media`: attachments by Media object id with `unverified | available | broken` states and an
+ explicit broken placeholder in `figureHtml`.
+- `discussion`: Community thread resolution (`POST /api/v1/comments/threads/resolve`) with a
+ service token; stores thread ids only.
+- `seo`: the deterministic indexability gate (19 stable reason codes, `hidden`/`noindex` effects),
+ canonical URL builder, history-aware redirects, meta/robots tags, sitemaps, RSS 2.0, Atom 1.0,
+ JSON Feed 1.1, JSON-LD builders (Article/BlogPosting/NewsArticle, Review, Product/Offer,
+ AggregateRating, BreadcrumbList, WebPage) that omit anything not provided.
+- `authorship`: human / ai / hybrid / imported records with AI workflow + run references,
+ disclosure labels, append-only human review log; AI content starts as draft + noindex.
+- `index-hooks`: Search index documents and tombstones, `actionFor` transitions, and
+ `<product>.<type>.<action>` event envelopes valid against `events.event-envelope@1`.
+- `ssr`: escaping templates, safe Markdown subset, pagination, breadcrumbs, diff markup, `<time>`.
+- `examples/two-products`: a mini wiki and a mini blog with separate databases (the exit proof).
+- Proposed contract `docs/contracts-proposal/search.index-document.v1.json`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 OpenVibers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+# OpenVibe.Publishing
+
+> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
+> Deals, Coupons and Trade.
+
+**Status:** alpha, 0.1.0 (roadmap Wave 15). The modules are tested and the exit proof
+(`examples/two-products`) runs in `npm test`; not tagged or released yet. No product uses them in production yet: Wiki and Blog (Wave 16) are the
+first planned consumers.
+**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
+**License:** MIT, like OpenVibe.Shared.
+
+## The rule: packages, not an authority
+
+This repository is **code, never state**. It has no runtime, no port, no domain, no database and
+no API of its own, and it must never get one. Each product (Wiki, Blog, News, …) owns its
+publication state in its own database; these modules take the product's `better-sqlite3` handle
+and a table prefix, and create `<prefix>_…` tables *inside that product's database*. Two products
+share the code, not the data: `wiki.db` holds `wiki_*` tables, `blog.db` holds `blog_*` tables,
+and nothing is shared between them (proved by [test/two-products.test.js](test/two-products.test.js)).
+
+Following the plan (§12.4, §31.1, §31.5): share *how* something is done (revisions, the gate,
+feeds, indexing); never share *what is true* (which revision is live, who may read it, editorial
+rules). A generic `OpenVibe.Content` authority is explicitly not what this is.
+
+## Module map
+
+Every module is its own entry point and can be used alone.
+
+| Entry point | What it does | Tables (in the consumer's DB) |
+|---|---|---|
+| `openvibe-publishing/revisions` | Drafts, immutable revisions with parent pointers, optimistic concurrency (`expectedRevision` → 412 `revision.conflict`), line/word diff, revert as a new revision, audited purge | `<prefix>_revisions`, `<prefix>_drafts`, `<prefix>_revision_purges` |
+| `openvibe-publishing/schedule` | Scheduled publish/unpublish jobs: idempotent `schedule()`, lease-based `claim()`, re-run safe across worker restarts, retries with backoff, injectable clock | `<prefix>_schedule_jobs` |
+| `openvibe-publishing/taxonomy` | Tags and terms with slugs, hierarchical categories (ancestors for breadcrumbs, descendants, trees, cycle checks) | `<prefix>_terms`, `<prefix>_term_links` |
+| `openvibe-publishing/citations` | Source references on one revision: Sources item id and/or URL, `retrieved_at`, quote span, license note; append-only, never dropped by later revisions; `carryForward` to reuse | `<prefix>_citations`, `<prefix>_citation_purges` |
+| `openvibe-publishing/media` | Attachments by Media object id (`med_…` / `legacy:…`), explicit `broken` state when the object is gone, `verify()` that never flips state on an outage, `<figure>` rendering with an honest placeholder | `<prefix>_attachments` |
+| `openvibe-publishing/discussion` | Community comment-thread references: resolves via `POST /api/v1/comments/threads/resolve`, stores the thread id only (no comment content) | `<prefix>_discussion_refs` |
+| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |
+| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |
+| `openvibe-publishing/index-hooks` | Search index documents (and tombstones) from a published revision; `<product>.<type>.published\|updated\|unpublished\|deleted` event envelopes for the product's outbox | — |
+| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
+
+`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
+
+## Install
+
+Pin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):
+
+```json
+"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.1.0"
+```
+
+`better-sqlite3` (≥ 11) is a peer dependency: the product brings its own handle. The only runtime
+dependency is `openvibe-shared` (v1.0.0), whose `seo` module is the network's single implementation
+of head tags, sitemap XML, robots.txt and JSON-LD escaping; this package adds the publication rules on top.
+
+## The indexability gate
+
+```js
+const seo = require('openvibe-publishing/seo');
+const decision = seo.evaluate(
+ { state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 2, authorship: { mode: 'ai', reviewed: false } },
+ { policy: { minWords: 150, requireSources: true }, now: Date.now() },
+);
+// → { indexable: false, listable: false, robots: 'noindex, nofollow', codes: ['ai_generated_unreviewed'], reasons: [...], canonical, gate }
+```
+
+A pure function of (facts, policy, now): the same input always gives the same decision. Every "no"
+carries stable reason codes, in a fixed order (`seo.REASONS`):
+
+| Effect | Codes |
+|---|---|
+| **hidden** — never indexed, never in sitemaps, feeds or public search | `deleted`, `takedown`, `private`, `gated`, `unlisted`, `draft` (draft or scheduled), `unpublished`, `ai_generated_unreviewed`, `stub_provider`, `unreviewed_sensitive` |
+| **noindex** — may be served and listed in feeds, never indexed or put in a sitemap | `retracted`, `expired`, `stale_price`, `duplicate_of` (canonical points at the original), `thin`, `unsourced`, `unsupported_claims`, `missing_canonical`, `noindex_requested` |
+
+There is no default that makes content indexable: unknown fact names throw, a rule that needs a fact
+the caller did not give throws, and time rules need an explicit `now` (no hidden clock). Each product
+sets its own policy (`minWords`, `requireSources`, `minSources`, `priceMaxAgeMs`, `sensitiveCategories`).
+`metaTags`, `robotsMeta`, `sitemap`, the three feed builders and `buildIndexDocument` (for a
+published document) all require the decision, so an object without one cannot reach a crawler by omission.
+
+## Honesty guarantees (tested)
+
+- **Structured data** is generated only from provided fields. A missing rating, price, currency,
+ date or author is omitted, never defaulted (no `bestRating: 5`, no assumed `InStock`, no
+ "now" as a date, no placeholder author). An `AggregateRating` needs a real count; an `Offer` needs a
+ price and a currency. [test/seo.test.js](test/seo.test.js) checks every output leaf against the input.
+- **Feeds and sitemaps** never invent `pubDate`, `lastmod` or authors; an Atom entry without any date
+ is left out rather than dated "now".
+- **Citations** never get a retrieval time they did not have, and cannot be edited or deleted
+ (SQLite triggers) except by an audited purge.
+- **Media** outages are `check_failed`, not "gone" and not "fine".
+- **Discussion** failures are errors, never a fabricated thread.
+- **AI content** starts as draft + noindex: `authorship.canPublish()` refuses it and the gate hides it
+ (`ai_generated_unreviewed`) until a person (a `usr_` subject, never a service) records an approving
+ review. Products must call these; the package cannot stop a product that bypasses its own checks.
+
+## Events and Search
+
+`index-hooks.buildIndexDocument()` produces the Search document ([REF] §12.3 fields plus the gate
+decision) or a tombstone; the proposed schema is
+[docs/contracts-proposal/search.index-document.v1.json](docs/contracts-proposal/search.index-document.v1.json).
+`publicationEvent()` wraps it in an `events.event-envelope@1` envelope with `event_type`
+`<product>.<type>.<action>` and no `event_id` (the OpenVibe.Events outbox assigns one); events about
+private, gated or unlisted content are `internal`. Enqueue it with the Events outbox in the same
+transaction as the product's state change.
+
+## Exit proof: two products
+
+[examples/two-products](examples/two-products) holds a mini wiki and a mini blog, each with its own
+SQLite file and its own publication tables, both using revisions, citations, the gate and feeds with
+different editorial policies. `node examples/two-products/wiki/app.js` (port 4801) and
+`node examples/two-products/blog/app.js` (port 4811) serve them on temp databases.
+[test/two-products.test.js](test/two-products.test.js) shows they load the same modules, that each
+database holds only its own product's tables, that writes never cross, and that pages are useful
+without JavaScript, old slugs 301, private/VIP/deleted content leaves feeds and sitemaps, and
+scheduled publication survives a worker restart.
+
+## Development
+
+```bash
+fnm exec --using=22.22.1 npm install
+fnm exec --using=22.22.1 npm test # every test/*.test.js, temp databases, no network
+```
+
+## What it does not do
+
+- It does not own publication state, run workers, listen on a port, or talk to a database it was not handed.
+- It does not decide editorial rules: each product passes its own gate policy and its own permission checks.
+- It does not store or render comment content (Community owns it), media bytes (Media owns them) or
+ source items (Sources owns them); it stores references.
+- It is not a notification, search or event runtime: it builds the documents and envelopes those services consume.
+
+## Depends on
+
+- `openvibe-shared` v1.0.0 (`seo`), at runtime.
+- `better-sqlite3` ≥ 11, supplied by the consumer.
+- Contracts it produces for: `common.entity-ref@1`, `media.media-ref@1`, `events.event-envelope@1`
+ (validated in tests against `openvibe-contracts` v0.7.0), and the proposed `search.index-document@1`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "repository": "OpenVibers/OpenVibe.Publishing",
+ "stage": "alpha",
+ "code": true,
+ "wave": "W15",
+ "kind": "packages",
+ "package": "openvibe-publishing",
+ "version": "0.1.0",
+ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
+ "runtime": null,
+ "port": null,
+ "domain": null,
+ "database": null,
+ "modules": ["revisions", "schedule", "taxonomy", "citations", "media", "discussion", "seo", "authorship", "index-hooks", "ssr"],
+ "consumers": [],
+ "plannedConsumers": ["OpenVibe.Wiki", "OpenVibe.Blog", "OpenVibe.News", "OpenVibe.Reviews", "OpenVibe.Deals", "OpenVibe.Coupons", "OpenVibe.Trade"],
+ "exitProof": "examples/two-products + test/two-products.test.js",
+ "contracts": "openvibe-contracts v0.7.0 (tests); proposes search.index-document@1",
+ "released": false,
+ "updated": "2026-09-22"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/search/index-document.v1.json",
+ "title": "SearchIndexDocument",
+ "description": "PROPOSAL (openvibe-publishing 0.1.0, for OpenVibe.Contracts and OpenVibe.Search). The document a publication product sends to Search for one revision of one resource, or the tombstone that removes it. Owner, resource, revision, visibility + ACL, canonical URL, title/summary/body, facets, provenance, publication state, deletion marker ([REF] §12.3), plus the publishing SEO gate's indexability decision. Search must never return a document to a query that fails its ACL, and must not list a document whose indexability.listable is false on public surfaces.",
+ "oneOf": [
+ {
+ "title": "IndexDocument",
+ "type": "object",
+ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "visibility", "acl", "canonical_url", "title", "summary", "body", "facets", "provenance", "publication_state", "deleted", "indexability", "published_at", "updated_at"],
+ "additionalProperties": false,
+ "properties": {
+ "schema": { "const": "search.index-document@1" },
+ "owner_service": { "$ref": "#/$defs/service" },
+ "resource_type": { "$ref": "#/$defs/segment" },
+ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "revision": { "type": "integer", "minimum": 0 },
+ "visibility": { "enum": ["public", "private", "gated"] },
+ "acl": {
+ "oneOf": [
+ { "type": "object", "required": ["public"], "additionalProperties": false, "properties": { "public": { "const": true } } },
+ {
+ "type": "object", "required": ["public"], "additionalProperties": false,
+ "properties": {
+ "public": { "const": false },
+ "subjects": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^(usr|gst|app|mod)_[0-9A-HJKMNP-TV-Z]{26}$" } },
+ "entitlements": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }
+ },
+ "anyOf": [{ "required": ["subjects"] }, { "required": ["entitlements"] }]
+ }
+ ]
+ },
+ "canonical_url": { "type": "string", "pattern": "^https?://" },
+ "title": { "type": "string", "minLength": 1 },
+ "summary": { "type": ["string", "null"] },
+ "body": { "type": "string", "maxLength": 100000 },
+ "body_truncated": { "const": true },
+ "locale": { "type": "string" },
+ "facets": {
+ "type": "object",
+ "propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,39}$" },
+ "additionalProperties": {
+ "anyOf": [
+ { "type": ["string", "number", "boolean"] },
+ { "type": "array", "items": { "type": ["string", "number", "boolean"] } }
+ ]
+ }
+ },
+ "provenance": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "authorship": {
+ "type": "object", "required": ["mode"], "additionalProperties": false,
+ "properties": {
+ "mode": { "enum": ["human", "ai", "hybrid", "imported"] },
+ "reviewed": { "type": "boolean" },
+ "workflow": { "type": "object", "required": ["id", "run_id"], "additionalProperties": false, "properties": { "id": { "type": "string" }, "run_id": { "type": "string" } } }
+ }
+ },
+ "citations": {
+ "type": "array",
+ "items": {
+ "type": "object", "additionalProperties": false,
+ "properties": {
+ "source_item_id": { "type": "string" },
+ "url": { "type": "string" },
+ "retrieved_at": { "type": "string", "format": "date-time" },
+ "license_note": { "type": "string" }
+ },
+ "anyOf": [{ "required": ["source_item_id"] }, { "required": ["url"] }]
+ }
+ },
+ "derived_from": { "type": "object" }
+ }
+ },
+ "publication_state": { "const": "published" },
+ "deleted": { "const": false },
+ "indexability": {
+ "type": "object", "required": ["indexable", "listable", "reasons", "gate"], "additionalProperties": false,
+ "properties": {
+ "indexable": { "type": "boolean" },
+ "listable": { "type": "boolean" },
+ "reasons": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z_]*$" } },
+ "gate": { "type": ["string", "null"] }
+ }
+ },
+ "published_at": { "type": ["string", "null"], "format": "date-time" },
+ "updated_at": { "type": ["string", "null"], "format": "date-time" }
+ }
+ },
+ {
+ "title": "IndexTombstone",
+ "type": "object",
+ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "publication_state", "deleted", "updated_at"],
+ "additionalProperties": false,
+ "properties": {
+ "schema": { "const": "search.index-document@1" },
+ "owner_service": { "$ref": "#/$defs/service" },
+ "resource_type": { "$ref": "#/$defs/segment" },
+ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "revision": { "type": "integer", "minimum": 0 },
+ "publication_state": { "enum": ["draft", "scheduled", "published", "unpublished", "deleted"] },
+ "deleted": { "const": true },
+ "updated_at": { "type": ["string", "null"], "format": "date-time" }
+ }
+ }
+ ],
+ "$defs": {
+ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
+ "segment": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,39}$" }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+# Two products, one package, two databases
+
+The Wave 15 exit proof. `wiki/app.js` and `blog/app.js` are deliberately small products that
+import `openvibe-publishing/*` exactly as a real product would, each opening its **own** SQLite file:
+
+| | Mini wiki | Mini blog |
+|---|---|---|
+| Database | `wiki.db` — only `wiki_*` tables | `blog.db` — only `blog_*` tables |
+| Publication state | `wiki_pages` (owned by the app) | `blog_posts` (owned by the app) |
+| Revisions | `wiki_page_revisions` | `blog_post_revisions` |
+| Citations | `wiki_citations` (sources required) | `blog_post_citations` (optional) |
+| Gate policy | `minWords 30`, `requireSources` | `minWords 20` |
+| Feeds | Atom + JSON Feed | RSS + JSON Feed |
+| Also | redirects, AI authorship + review, index events | taxonomy, scheduling, media attachments |
+
+```bash
+node examples/two-products/wiki/app.js # http://127.0.0.1:4801/p/rye
+node examples/two-products/blog/app.js # http://127.0.0.1:4811/posts/first-loaf
+```
+
+Both use temp databases and made-up sample text. The assertions live in
+[../../test/two-products.test.js](../../test/two-products.test.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * Mini blog — the second example consumer of openvibe-publishing, with its own SQLite file and its
+ * own publication state (blog_posts). Different editorial rules from the wiki (sources optional,
+ * shorter minimum), same shared mechanics.
+ *
+ * node examples/two-products/blog/app.js # http://127.0.0.1:4811 with a temp database
+ *
+ * Uses: revisions (blog_post_*), citations (blog_post_citations), taxonomy (blog_terms), schedule
+ * (blog_schedule_jobs), media attachments (blog_post_attachments), seo gate + sitemap + RSS/JSON
+ * Feed, ssr.
+ */
+const http = require('http');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+const Database = require('better-sqlite3');
+const { createRevisionStore } = require('openvibe-publishing/revisions');
+const { createCitationStore } = require('openvibe-publishing/citations');
+const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
+const { createScheduler } = require('openvibe-publishing/schedule');
+const { createAttachmentStore, figureHtml } = require('openvibe-publishing/media');
+const seo = require('openvibe-publishing/seo');
+const ssr = require('openvibe-publishing/ssr');
+
+const POLICY = { minWords: 20, requireSources: false };
+
+function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'https://openvibe.media', now = () => Date.now() } = {}) {
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS blog_posts (
+ id TEXT PRIMARY KEY,
+ slug TEXT NOT NULL UNIQUE,
+ author_name TEXT,
+ visibility TEXT NOT NULL DEFAULT 'public',
+ state TEXT NOT NULL DEFAULT 'draft',
+ published_revision INTEGER,
+ published_at INTEGER,
+ updated_at INTEGER NOT NULL
+ );
+ `);
+ const revisions = createRevisionStore(db, { prefix: 'blog_post', now });
+ const citations = createCitationStore(db, { prefix: 'blog_post', now, revisions });
+ const taxonomy = createTaxonomy(db, { prefix: 'blog', now });
+ const scheduler = createScheduler(db, { prefix: 'blog', now, leaseMs: 60000 });
+ const media = createAttachmentStore(db, { prefix: 'blog_post', now });
+
+ const urlOf = (slug) => seo.canonicalUrl(origin, ['posts', slug]);
+ const getPost = (id) => db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);
+
+ function decide(post, rev) {
+ return seo.evaluate({
+ state: post.state, visibility: post.visibility, canonicalUrl: urlOf(post.slug),
+ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(post.id, rev.number).length,
+ }, { policy: POLICY, now: now() });
+ }
+
+ /** The idempotent effect a scheduled job applies: "revision N is the published one". */
+ function applyPublish(id, revision) {
+ const info = db.prepare("UPDATE blog_posts SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ? AND (state IS NOT 'published' OR published_revision IS NOT ?)")
+ .run(revision, now(), now(), id, revision);
+ return { changed: info.changes };
+ }
+
+ const api = {
+ db, revisions, citations, taxonomy, scheduler, media, policy: POLICY,
+
+ draft({ title, body, authorName = null, tags = [], sources = [] }) {
+ return db.transaction(() => {
+ const slug = slugify(title);
+ const id = `post_${slug}`;
+ db.prepare('INSERT INTO blog_posts (id, slug, author_name, updated_at) VALUES (?, ?, ?, ?)').run(id, slug, authorName, now());
+ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title } });
+ citations.attachMany(id, revision.number, sources);
+ taxonomy.setTerms(id, 'tag', tags);
+ return { id, revision: revision.number };
+ })();
+ },
+
+ edit(id, { expectedRevision, body, title }) {
+ const head = revisions.head(id);
+ return revisions.create({ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title } }).revision.number;
+ },
+
+ publishNow(id, revision) { return applyPublish(id, revision); },
+ schedulePublish(id, { revision, at }) { return scheduler.schedule({ entityId: id, action: 'publish', runAt: at, revision }); },
+ runScheduled(worker) {
+ return scheduler.runDue({ worker, handler: (job) => (job.action === 'publish' ? applyPublish(job.entityId, job.revision) : null) });
+ },
+ setVisibility(id, visibility) { db.prepare('UPDATE blog_posts SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id); },
+
+ published() {
+ return db.prepare("SELECT * FROM blog_posts WHERE state = 'published' ORDER BY published_at DESC, id").all().map((post) => {
+ const rev = revisions.get(post.id, post.published_revision);
+ return { post, rev, decision: decide(post, rev) };
+ });
+ },
+
+ feedItems() {
+ return api.published().map(({ post, rev, decision }) => ({
+ id: `tag:openvibe.blog,2026:post/${post.id}`, url: urlOf(post.slug), title: rev.fields.title,
+ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content),
+ published: post.published_at, updated: rev.createdAt, decision,
+ authors: post.author_name ? [{ name: post.author_name }] : [],
+ tags: taxonomy.termsFor(post.id, 'tag').map((t) => t.name),
+ }));
+ },
+ rss() { return seo.rssFeed({ title: 'Mini blog', link: `${origin}/`, description: 'Posts from the mini blog example', feedUrl: `${origin}/feed.xml` }, api.feedItems()); },
+ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini blog', link: `${origin}/`, feedUrl: `${origin}/feed.json` }, api.feedItems()); },
+ sitemapXml() { return seo.sitemap(api.published().map(({ post, rev, decision }) => ({ loc: urlOf(post.slug), lastmod: rev.createdAt, decision }))).files[0]; },
+
+ renderPost(post) {
+ const rev = revisions.get(post.id, post.published_revision);
+ const decision = decide(post, rev);
+ const figures = media.list(post.id).map((a) => ssr.raw(figureHtml(a, { urlFor: (id) => `${mediaOrigin}/o/${id}` })));
+ const tags = taxonomy.termsFor(post.id, 'tag');
+ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
+ jsonLd: seo.structuredData.article({ type: 'BlogPosting', headline: rev.fields.title, url: urlOf(post.slug),
+ datePublished: post.published_at ? new Date(post.published_at).toISOString() : null, dateModified: rev.createdAt,
+ authors: post.author_name ? [{ name: post.author_name }] : [], keywords: tags.map((t) => t.name) }) });
+ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}${ssr.raw(seo.feedLinks([{ type: 'rss', href: '/feed.xml', title: 'RSS' }]))}</head><body>
+<article><h1>${rev.fields.title}</h1><p>${post.author_name ? ssr.html`By ${post.author_name} · ` : ''}${ssr.raw(ssr.timeTag(post.published_at))}</p>
+${figures}
+${ssr.raw(ssr.renderMarkdown(rev.content))}
+<p>${tags.map((t) => ssr.html`<a href="/tags/${t.slug}">#${t.name}</a> `)}</p></article></body></html>`);
+ },
+
+ handler(req, res) {
+ const url = new URL(req.url, origin);
+ const send = (status, type, body) => { res.writeHead(status, { 'Content-Type': type }); res.end(body); };
+ if (url.pathname === '/feed.xml') return send(200, 'application/rss+xml', api.rss());
+ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
+ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
+ const m = url.pathname.match(/^\/posts\/([^/]+)$/);
+ const post = m && db.prepare('SELECT * FROM blog_posts WHERE slug = ?').get(decodeURIComponent(m[1]));
+ if (!post || post.state !== 'published' || post.visibility === 'private' || post.visibility === 'gated') return send(404, 'text/plain', 'Not found');
+ return send(200, 'text/html; charset=utf-8', api.renderPost(post));
+ },
+
+ close() { db.close(); },
+ };
+ return api;
+}
+
+module.exports = { createBlog };
+
+if (require.main === module) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-blog-'));
+ const blog = createBlog({ dbPath: path.join(dir, 'blog.db'), origin: 'http://127.0.0.1:4811' });
+ const { id, revision } = blog.draft({ title: 'First loaf', authorName: 'Alex', tags: ['bread', 'rye'],
+ body: 'My first rye loaf came out dense but tasty. Next time I will give the starter a longer rise and bake it a little hotter.' });
+ blog.publishNow(id, revision);
+ http.createServer(blog.handler).listen(4811, '127.0.0.1', () => console.log(`mini blog on http://127.0.0.1:4811/posts/first-loaf (db ${dir}/blog.db)`));
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+'use strict';
+/**
+ * Mini wiki — an example consumer of openvibe-publishing. It owns its own SQLite file and its own
+ * publication state (wiki_pages); the packages only supply the mechanics.
+ *
+ * node examples/two-products/wiki/app.js # http://127.0.0.1:4801 with a temp database
+ *
+ * Uses: revisions (wiki_page_*), citations (wiki_citations), seo gate + redirects + sitemap +
+ * Atom/JSON feeds, authorship (AI pages start as drafts), index-hooks (events into wiki_outbox), ssr.
+ */
+const http = require('http');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+const Database = require('better-sqlite3');
+// The package's public entry points, exactly as a product would import them.
+const { createRevisionStore } = require('openvibe-publishing/revisions');
+const { createCitationStore } = require('openvibe-publishing/citations');
+const seo = require('openvibe-publishing/seo');
+const authorship = require('openvibe-publishing/authorship');
+const hooks = require('openvibe-publishing/index-hooks');
+const ssr = require('openvibe-publishing/ssr');
+const { slugify } = require('openvibe-publishing/taxonomy');
+
+const POLICY = { minWords: 30, requireSources: true, minSources: 1 };
+
+function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date.now() } = {}) {
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS wiki_pages (
+ id TEXT PRIMARY KEY,
+ slug TEXT NOT NULL UNIQUE,
+ owner TEXT NOT NULL,
+ visibility TEXT NOT NULL DEFAULT 'public',
+ state TEXT NOT NULL DEFAULT 'draft',
+ published_revision INTEGER,
+ published_at INTEGER,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS wiki_outbox (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ envelope TEXT NOT NULL
+ );
+ `);
+ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
+ const citations = createCitationStore(db, { prefix: 'wiki', now, revisions });
+ const redirects = seo.createRedirectStore(db, { prefix: 'wiki_page', now });
+ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now });
+
+ const pathOf = (slug) => `/p/${slug}`;
+ const urlOf = (slug) => seo.canonicalUrl(origin, pathOf(slug));
+ const getPage = (id) => db.prepare('SELECT * FROM wiki_pages WHERE id = ?').get(id);
+ const bySlug = (slug) => db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug);
+
+ function decide(page, rev) {
+ const rec = rev.meta.authorship;
+ return seo.evaluate({
+ state: page.state, visibility: page.visibility, canonicalUrl: urlOf(page.slug),
+ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(page.id, rev.number).length,
+ ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
+ }, { policy: POLICY, now: now() });
+ }
+
+ function document(page, rev, decision) {
+ return hooks.buildIndexDocument({
+ service: 'wiki', type: 'page', id: page.id, revision: rev ? rev.number : 0, state: page.state, visibility: page.visibility,
+ canonicalUrl: urlOf(page.slug), title: rev ? rev.fields.title : page.slug, summary: rev ? ssr.markdownToText(rev.content, 160) : null,
+ body: rev ? ssr.markdownToText(rev.content) : '', decision, acl: { subjects: [page.owner] },
+ provenance: { authorship: rev && rev.meta.authorship, citations: rev ? citations.forRevision(page.id, rev.number) : [] },
+ publishedAt: page.published_at, updatedAt: page.updated_at,
+ });
+ }
+
+ function emit(before, page, actor) {
+ const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
+ const action = hooks.actionFor(before && { state: before.state, visibility: before.visibility, revision: before.published_revision },
+ { state: page.state, visibility: page.visibility, revision: page.published_revision });
+ if (!action) return null;
+ const decision = rev ? decide(page, rev) : null;
+ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0, actor, document: document(page, rev, decision), decision, now: now() });
+ db.prepare('INSERT INTO wiki_outbox (envelope) VALUES (?)').run(JSON.stringify(env));
+ return env;
+ }
+
+ const api = {
+ db, revisions, citations, redirects, reviews, policy: POLICY,
+
+ createPage({ title, body, author, sources = [], authorship: rec = authorship.record({ mode: 'human', authors: [author] }) }) {
+ return db.transaction(() => {
+ const id = `pg_${slugify(title)}`;
+ db.prepare('INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (?, ?, ?, ?)').run(id, slugify(title), author, now());
+ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title }, meta: { authorship: rec }, author });
+ citations.attachMany(id, revision.number, sources);
+ return { id, revision: revision.number, initial: authorship.initialState(rec) };
+ })();
+ },
+
+ /** Edit: new revision; `keepSources` carries earlier citations forward, `sources` adds new ones. */
+ edit(id, { expectedRevision, body, title, author, sources = [], keepSources = [] }) {
+ return db.transaction(() => {
+ const head = revisions.head(id);
+ const { revision } = revisions.create({
+ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title },
+ meta: { authorship: authorship.record({ mode: 'human', authors: [author] }) }, author,
+ });
+ if (keepSources.length) citations.carryForward({ entityId: id, fromRevision: head.number, toRevision: revision.number, ids: keepSources });
+ citations.attachMany(id, revision.number, sources);
+ return revision.number;
+ })();
+ },
+
+ revert(id, { toRevision, expectedRevision, author }) {
+ return db.transaction(() => {
+ const { revision } = revisions.revert({ entityId: id, toRevision, expectedRevision, author });
+ citations.carryForward({ entityId: id, fromRevision: toRevision, toRevision: revision.number });
+ return revision.number;
+ })();
+ },
+
+ publish(id, { revision, actor }) {
+ return db.transaction(() => {
+ const before = getPage(id);
+ const rev = revisions.get(id, revision);
+ const rec = rev.meta.authorship;
+ const ok = authorship.canPublish(rec, reviews.latest(id, revision));
+ if (!ok.ok) throw Object.assign(new Error(`cannot publish: ${ok.reason}`), { status: 409, code: ok.reason });
+ db.prepare("UPDATE wiki_pages SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?").run(revision, now(), now(), id);
+ return emit(before, getPage(id), actor);
+ })();
+ },
+
+ setVisibility(id, visibility, { actor }) {
+ return db.transaction(() => {
+ const before = getPage(id);
+ db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id);
+ return emit(before, getPage(id), actor);
+ })();
+ },
+
+ rename(id, newTitle) {
+ return db.transaction(() => {
+ const page = getPage(id);
+ const slug = slugify(newTitle);
+ redirects.recordMove(id, pathOf(page.slug), pathOf(slug));
+ db.prepare('UPDATE wiki_pages SET slug = ?, updated_at = ? WHERE id = ?').run(slug, now(), id);
+ return slug;
+ })();
+ },
+
+ remove(id, { actor }) {
+ return db.transaction(() => {
+ const before = getPage(id);
+ db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), id);
+ return emit(before, getPage(id), actor);
+ })();
+ },
+
+ outbox() { return db.prepare('SELECT envelope FROM wiki_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)); },
+
+ /** Every page with its live revision and gate decision. */
+ published() {
+ return db.prepare("SELECT * FROM wiki_pages WHERE state = 'published' ORDER BY published_at DESC, id").all().map((page) => {
+ const rev = revisions.get(page.id, page.published_revision);
+ return { page, rev, decision: decide(page, rev) };
+ });
+ },
+
+ sitemapXml() {
+ return seo.sitemap(api.published().map(({ page, rev, decision }) => ({ loc: urlOf(page.slug), lastmod: rev.createdAt, decision }))).files[0];
+ },
+
+ feedItems() {
+ return api.published().map(({ page, rev, decision }) => ({
+ id: `tag:openvibe.wiki,2026:page/${page.id}`, url: urlOf(page.slug), title: rev.fields.title,
+ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content, { rel: 'noopener' }),
+ published: page.published_at, updated: rev.createdAt, decision,
+ }));
+ },
+
+ atom() {
+ // Atom needs a feed-level <updated>: the last real change to any page (never "now").
+ const last = db.prepare('SELECT MAX(updated_at) AS t FROM wiki_pages').get().t;
+ return seo.atomFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.atom`, updated: last }, api.feedItems());
+ },
+ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.json` }, api.feedItems()); },
+
+ renderPage(page) {
+ const rev = revisions.get(page.id, page.published_revision);
+ const decision = decide(page, rev);
+ const cites = citations.forRevision(page.id, rev.number);
+ const disclosure = authorship.disclosure(rev.meta.authorship, reviews.latest(page.id, rev.number));
+ const crumbs = [{ name: 'Wiki', url: `${origin}/` }, { name: rev.fields.title, url: urlOf(page.slug) }];
+ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
+ jsonLd: [seo.structuredData.article({ headline: rev.fields.title, url: urlOf(page.slug), dateModified: rev.createdAt,
+ datePublished: page.published_at ? new Date(page.published_at).toISOString() : null, citations: cites }), seo.structuredData.breadcrumbs(crumbs)].filter(Boolean) });
+ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}</head><body>
+${ssr.raw(ssr.breadcrumbsHtml(crumbs))}
+<article><h1>${rev.fields.title}</h1>${disclosure ? ssr.html`<p class="disclosure">${disclosure.long}</p>` : ''}
+${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}
+<p>Revision ${rev.number}, ${ssr.raw(ssr.timeTag(rev.createdAt))} · <a href="${pathOf(page.slug)}/history">history</a></p>
+<section><h2>Sources</h2><ol>${cites.map((c) => ssr.html`<li>${c.url ? ssr.html`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : c.sourceItemId}${c.retrievedAt ? ssr.html`, retrieved ${ssr.raw(ssr.timeTag(c.retrievedAt))}` : ''}${c.licenseNote ? ` (${c.licenseNote})` : ''}</li>`)}</ol></section>
+</article></body></html>`);
+ },
+
+ /** Node http handler: pages, 301/410 history, sitemap, feeds. No JavaScript anywhere. */
+ handler(req, res) {
+ const url = new URL(req.url, origin);
+ const send = (status, type, body, headers = {}) => { res.writeHead(status, { 'Content-Type': type, ...headers }); res.end(body); };
+ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
+ if (url.pathname === '/feed.atom') return send(200, 'application/atom+xml', api.atom());
+ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
+ if (url.pathname === '/robots.txt') return send(200, 'text/plain', seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] }));
+ const m = url.pathname.match(/^\/p\/([^/]+)(\/history)?$/);
+ if (!m) return send(404, 'text/plain', 'Not found');
+ const page = bySlug(decodeURIComponent(m[1]));
+ if (!page) {
+ const r = redirects.resolve(url.pathname.replace(/\/history$/, ''), { currentPath: (id) => { const p = getPage(id); return p && p.state !== 'deleted' ? pathOf(p.slug) : null; } });
+ if (r && r.status === 301) return send(301, 'text/plain', 'Moved', { Location: r.location + (m[2] || '') });
+ if (r && r.status === 410) return send(410, 'text/plain', 'Gone');
+ return send(404, 'text/plain', 'Not found');
+ }
+ if (page.state === 'deleted') return send(410, 'text/plain', 'Gone');
+ if (page.state !== 'published' || page.visibility === 'private' || page.visibility === 'gated') return send(404, 'text/plain', 'Not found');
+ if (m[2]) {
+ const list = revisions.list(page.id).map((r) => ssr.html`<li>r${r.number} ${r.kind} ${ssr.raw(ssr.timeTag(r.createdAt))}</li>`);
+ return send(200, 'text/html; charset=utf-8', String(ssr.html`<!doctype html><title>History</title><meta name="robots" content="noindex"><ol>${list}</ol>`));
+ }
+ return send(200, 'text/html; charset=utf-8', api.renderPage(page));
+ },
+
+ close() { db.close(); },
+ };
+ return api;
+}
+
+module.exports = { createWiki };
+
+if (require.main === module) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-wiki-'));
+ const wiki = createWiki({ dbPath: path.join(dir, 'wiki.db'), origin: 'http://127.0.0.1:4801' });
+ const { id } = wiki.createPage({
+ title: 'Rye', author: 'usr_01J8Z6Q3KX0000000000000000',
+ body: 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.',
+ sources: [{ url: 'https://en.wikipedia.org/wiki/Rye', title: 'Rye — Wikipedia', licenseNote: 'CC BY-SA 4.0' }],
+ });
+ wiki.publish(id, { revision: 1, actor: 'usr_01J8Z6Q3KX0000000000000000' });
+ http.createServer(wiki.handler).listen(4801, '127.0.0.1', () => console.log(`mini wiki on http://127.0.0.1:4801/p/rye (db ${dir}/wiki.db)`));
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+/**
+ * openvibe-publishing — every module is also its own entry point and can be used alone:
+ * require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
+ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr
+ * This root export loads each one lazily, on first access.
+ */
+const MODULES = {
+ revisions: './lib/revisions',
+ schedule: './lib/schedule',
+ taxonomy: './lib/taxonomy',
+ citations: './lib/citations',
+ media: './lib/media',
+ discussion: './lib/discussion',
+ seo: './lib/seo',
+ authorship: './lib/authorship',
+ indexHooks: './lib/index-hooks',
+ ssr: './lib/ssr',
+};
+
+const api = {};
+for (const [name, path] of Object.entries(MODULES)) {
+ Object.defineProperty(api, name, { enumerable: true, get: () => require(path) });
+}
+Object.defineProperty(api, 'PublishingError', { enumerable: true, get: () => require('./lib/internal').PublishingError });
+Object.defineProperty(api, 'version', { enumerable: true, value: require('./package.json').version });
+
+module.exports = api;
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * openvibe-publishing/authorship — who made a revision (human | ai | hybrid | imported), which
+ * OpenVibe.AI workflow run produced it, the disclosure label, and the human review that lets
+ * AI-generated content leave draft/noindex.
+ *
+ * const authorship = require('openvibe-publishing/authorship');
+ * const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 3, runId: 'run_…' }, source: { label: 'Sources item src_…' } });
+ * authorship.initialState(rec) // → { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' }
+ * authorship.disclosure(rec).short // → 'AI-generated'
+ * const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page' }); // wiki_page_reviews
+ * reviews.record({ entityId, revision: 4, reviewer: 'usr_…', decision: 'approved' });
+ * authorship.gateFacts(rec, reviews.latest(entityId, 4)) // → { authorship: { mode: 'ai', reviewed: true } }
+ *
+ * Modes (§15.12 names in brackets): human; ai [AI-generated]; hybrid [AI-assisted: a person wrote or
+ * cut it from an AI suggestion]; imported [legacy/imported, keeps the original author].
+ * Rules:
+ * - ai and hybrid records must name the AI workflow and run (workflow.id + workflow.runId).
+ * - human records must not carry a workflow (that would hide AI involvement the other way round).
+ * - AI-generated content starts as draft + noindex; only a review by a person (a usr_ subject,
+ * never a service or the AI itself) marks it reviewed.
+ * - The record is metadata the product stores with the revision (revisions `meta.authorship`);
+ * reviews are append-only rows, so the revision itself stays immutable.
+ * Authorship is data, not a sentence: surfaces render the disclosure from this record.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber } = require('./internal');
+
+const MODES = ['human', 'ai', 'hybrid', 'imported'];
+const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
+
+function record({ mode, authors = [], workflow = null, stubProvider = false, source = null, importedFrom = null } = {}) {
+ if (!MODES.includes(mode)) throw new TypeError(`mode must be one of ${MODES.join(', ')}`);
+ for (const a of authors) if (!SUBJECT_RE.test(String(a))) throw new TypeError('authors must be usr_/gst_ subject ids');
+ let wf = null;
+ if (workflow != null) {
+ if (!workflow.id || !WORKFLOW_RE.test(workflow.id)) throw new TypeError('workflow.id must be an OpenVibe.AI workflow key like "wiki.generate_page"');
+ if (!workflow.runId || typeof workflow.runId !== 'string') throw new TypeError('workflow.runId is required');
+ wf = { id: workflow.id, runId: workflow.runId };
+ if (workflow.version != null) wf.version = workflow.version;
+ if (workflow.model != null) wf.model = String(workflow.model);
+ }
+ if ((mode === 'ai' || mode === 'hybrid') && !wf) throw new PublishingError(400, 'authorship.workflow_required', `${mode} authorship must reference the AI workflow and run`);
+ if (mode === 'human' && wf) throw new PublishingError(400, 'authorship.workflow_on_human', 'human authorship cannot carry an AI workflow; use hybrid');
+ if ((mode === 'human' || mode === 'hybrid') && !authors.length) throw new PublishingError(400, 'authorship.author_required', `${mode} authorship names the person accountable`);
+ if (mode === 'imported' && !importedFrom) throw new PublishingError(400, 'authorship.import_source_required', 'imported authorship names where it came from');
+ const out = { mode, authors: [...authors] };
+ if (wf) out.workflow = wf;
+ if (stubProvider) out.stubProvider = true;
+ if (source) out.source = { label: source.label == null ? null : String(source.label), ref: source.ref || null };
+ if (importedFrom) out.importedFrom = { label: String(importedFrom.label || ''), originalAuthor: importedFrom.originalAuthor == null ? null : String(importedFrom.originalAuthor) };
+ return out;
+}
+
+/** Only AI-generated content needs a review before it may be published and indexed. */
+function needsReview(rec) { return rec.mode === 'ai' || Boolean(rec.stubProvider); }
+
+function isReviewed(rec, review) {
+ return Boolean(review && review.decision === 'approved' && USER_RE.test(String(review.reviewer)));
+}
+
+/** Where a new revision starts. AI-generated → draft + noindex until reviewed. */
+function initialState(rec, review = null) {
+ if (needsReview(rec) && !isReviewed(rec, review)) return { state: 'draft', noindex: true, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
+ return { state: 'draft', noindex: false, reason: null };
+}
+
+/** { ok, reason } — whether this revision may be published now. */
+function canPublish(rec, review = null) {
+ if (needsReview(rec) && !isReviewed(rec, review)) return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
+ return { ok: true, reason: null };
+}
+
+/** Facts for seo.evaluate: { authorship: { mode, reviewed }, stubProvider }. */
+function gateFacts(rec, review = null) {
+ const reviewed = isReviewed(rec, review);
+ const out = { authorship: { mode: rec.mode, reviewed } };
+ if (rec.stubProvider) out.stubProvider = true;
+ return out;
+}
+
+/**
+ * Disclosure text, rendered at the item (not in a footer). Returns null for plain human content.
+ * { short, long, mode }
+ */
+function disclosure(rec, review = null, { sourceLabel } = {}) {
+ const src = sourceLabel || (rec.source && rec.source.label) || null;
+ const wf = rec.workflow ? `${rec.workflow.id}${rec.workflow.version != null ? ` v${rec.workflow.version}` : ''}` : null;
+ const reviewed = isReviewed(rec, review);
+ switch (rec.mode) {
+ case 'human': return null;
+ case 'ai': return {
+ mode: 'ai',
+ short: 'AI-generated',
+ long: `AI-generated${src ? ` from ${src}` : ''} by workflow ${wf}${reviewed ? ', reviewed by a person' : ', not yet reviewed by a person'}.`,
+ };
+ case 'hybrid': return { mode: 'hybrid', short: 'Written with AI assistance', long: `Written by a person with AI assistance (workflow ${wf})${src ? `, from ${src}` : ''}.` };
+ case 'imported': return {
+ mode: 'imported',
+ short: 'Imported',
+ long: `Imported from ${rec.importedFrom.label}${rec.importedFrom.originalAuthor ? `; originally by ${rec.importedFrom.originalAuthor}` : ''}.`,
+ };
+ default: return null;
+ }
+}
+
+/** Append-only review log: <prefix>_reviews. */
+function createReviewLog(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const R = `${prefix}_reviews`;
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${R} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ entity_id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ reviewer TEXT NOT NULL,
+ decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
+ note TEXT,
+ reviewed_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS ${R}_rev ON ${R} (entity_id, revision, id);
+ CREATE TRIGGER IF NOT EXISTS ${R}_no_update BEFORE UPDATE ON ${R} BEGIN SELECT RAISE(ABORT, '${R} rows are immutable'); END;
+ `);
+ const shape = (r) => (r ? { id: r.id, entityId: r.entity_id, revision: r.revision, reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: new Date(r.reviewed_at).toISOString() } : null);
+ const q = {
+ insert: db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, decision, note, reviewed_at) VALUES (?, ?, ?, ?, ?, ?)`),
+ latest: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`),
+ all: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id`),
+ byId: db.prepare(`SELECT * FROM ${R} WHERE id = ?`),
+ };
+ return {
+ table: R,
+ /** A person's review of one revision. The reviewer must be a usr_ subject. */
+ record({ entityId, revision, reviewer, decision, note = null } = {}) {
+ assertEntityId(entityId);
+ assertRevisionNumber(revision, 'revision');
+ if (!USER_RE.test(String(reviewer))) throw new PublishingError(400, 'review.reviewer_not_person', 'Only a person (usr_ subject) can review');
+ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
+ const info = q.insert.run(entityId, revision, reviewer, decision, note == null ? null : String(note).slice(0, 2000), clock());
+ return shape(q.byId.get(info.lastInsertRowid));
+ },
+ latest(entityId, revision) { return shape(q.latest.get(assertEntityId(entityId), revision)); },
+ history(entityId) { return q.all.all(assertEntityId(entityId)).map(shape); },
+ };
+}
+
+module.exports = { MODES, record, needsReview, isReviewed, initialState, canPublish, gateFacts, disclosure, createReviewLog };
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+'use strict';
+/**
+ * openvibe-publishing/citations — source references attached to one specific revision.
+ *
+ * const { createCitationStore } = require('openvibe-publishing/citations');
+ * const cites = createCitationStore(db, { prefix: 'wiki', revisions }); // wiki_citations
+ * cites.attach({ entityId: 'pg_1', revision: 2, url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z',
+ * quote: { text: '…', start: 120, end: 180 }, licenseNote: 'CC BY 4.0', sourceItemId: 'src_…' });
+ * cites.carryForward({ entityId: 'pg_1', fromRevision: 2, toRevision: 3 }); // reuse in r3
+ * cites.forRevision('pg_1', 2); // still there after r3 dropped them
+ *
+ * Rules:
+ * - A citation belongs to (entity, revision). It is never dropped: the table is append-only
+ * (UPDATE and DELETE abort in SQLite triggers), so a later revision that does not reuse a
+ * source leaves the earlier revision's citations intact. Only purgeEntity (audited) removes rows.
+ * - Every citation names a source: a Sources item id, an http(s) URL, or both.
+ * - Nothing is filled in: a missing retrieved_at, title or license note stays null. This module
+ * never stamps "now" as a retrieval time.
+ * - If a revisions store is passed, attach() refuses revisions that do not exist.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers } = require('./internal');
+
+function shape(row) {
+ if (!row) return null;
+ const quote = row.quote_text == null && row.quote_start == null ? null
+ : { text: row.quote_text, start: row.quote_start, end: row.quote_end };
+ return {
+ id: row.id,
+ entityId: row.entity_id,
+ revision: row.revision,
+ anchor: row.anchor,
+ sourceItemId: row.source_item_id,
+ url: row.url,
+ title: row.title,
+ retrievedAt: row.retrieved_at,
+ quote,
+ licenseNote: row.license_note,
+ carriedFrom: row.carried_from,
+ attachedBy: row.attached_by,
+ attachedAt: new Date(row.attached_at).toISOString(),
+ };
+}
+
+function checkUrl(url) {
+ if (url == null || url === '') return null;
+ let u;
+ try { u = new URL(String(url)); } catch { throw new TypeError('url must be an absolute URL'); }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new TypeError('url must be http(s)');
+ return u.toString();
+}
+
+function checkQuote(quote) {
+ if (quote == null) return { text: null, start: null, end: null };
+ if (typeof quote !== 'object') throw new TypeError('quote must be { text, start, end }');
+ const text = quote.text == null ? null : String(quote.text).slice(0, 5000);
+ const start = quote.start == null ? null : Number(quote.start);
+ const end = quote.end == null ? null : Number(quote.end);
+ if ((start == null) !== (end == null)) throw new TypeError('quote.start and quote.end go together');
+ if (start != null && (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start)) throw new TypeError('quote span must be 0 <= start <= end');
+ if (text == null && start == null) return { text: null, start: null, end: null };
+ return { text, start, end };
+}
+
+function createCitationStore(db, { prefix, now, revisions = null } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const C = `${prefix}_citations`;
+ const P = `${prefix}_citation_purges`;
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${C} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ entity_id TEXT NOT NULL,
+ revision INTEGER NOT NULL CHECK (revision >= 1),
+ anchor TEXT,
+ source_item_id TEXT,
+ url TEXT,
+ title TEXT,
+ retrieved_at TEXT,
+ quote_text TEXT,
+ quote_start INTEGER,
+ quote_end INTEGER,
+ license_note TEXT,
+ carried_from INTEGER REFERENCES ${C}(id),
+ attached_by TEXT,
+ attached_at INTEGER NOT NULL,
+ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
+ );
+ CREATE INDEX IF NOT EXISTS ${C}_rev ON ${C} (entity_id, revision);
+ CREATE INDEX IF NOT EXISTS ${C}_source ON ${C} (source_item_id);
+ CREATE TABLE IF NOT EXISTS ${P} (
+ entity_id TEXT PRIMARY KEY,
+ reason TEXT NOT NULL,
+ purged_by TEXT,
+ purged_at INTEGER NOT NULL
+ );
+ ${immutableTriggers(C, P)}
+ `);
+
+ const q = {
+ insert: db.prepare(`INSERT INTO ${C} (entity_id, revision, anchor, source_item_id, url, title, retrieved_at, quote_text, quote_start, quote_end, license_note, carried_from, attached_by, attached_at)
+ VALUES (@entity_id, @revision, @anchor, @source_item_id, @url, @title, @retrieved_at, @quote_text, @quote_start, @quote_end, @license_note, @carried_from, @attached_by, @attached_at)`),
+ byId: db.prepare(`SELECT * FROM ${C} WHERE id = ?`),
+ forRevision: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? AND revision = ? ORDER BY id`),
+ history: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? ORDER BY revision, id`),
+ bySource: db.prepare(`SELECT * FROM ${C} WHERE source_item_id = ? ORDER BY entity_id, revision, id`),
+ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
+ };
+
+ function checkRevision(entityId, revision) {
+ assertRevisionNumber(revision, 'revision');
+ if (revision < 1) throw new TypeError('revision must be >= 1');
+ if (revisions && !revisions.get(entityId, revision)) throw new PublishingError(404, 'revision.not_found', `No revision ${revision} of ${entityId}`);
+ }
+
+ function insert(entityId, revision, c, carriedFrom = null) {
+ const sourceItemId = c.sourceItemId == null || c.sourceItemId === '' ? null : String(c.sourceItemId).slice(0, 128);
+ const url = checkUrl(c.url);
+ if (!sourceItemId && !url) throw new PublishingError(400, 'citation.no_source', 'A citation needs a Sources item id or an http(s) URL');
+ const quote = checkQuote(c.quote);
+ const row = {
+ entity_id: entityId,
+ revision,
+ anchor: c.anchor == null ? null : String(c.anchor).slice(0, 100),
+ source_item_id: sourceItemId,
+ url,
+ title: c.title == null ? null : String(c.title).slice(0, 500),
+ retrieved_at: isoOrNull(c.retrievedAt, 'retrievedAt'),
+ quote_text: quote.text,
+ quote_start: quote.start,
+ quote_end: quote.end,
+ license_note: c.licenseNote == null ? null : String(c.licenseNote).slice(0, 1000),
+ carried_from: carriedFrom,
+ attached_by: c.attachedBy == null ? null : String(c.attachedBy),
+ attached_at: clock(),
+ };
+ return shape(q.byId.get(q.insert.run(row).lastInsertRowid));
+ }
+
+ const api = {
+ table: C,
+
+ /** Attach one citation to (entityId, revision). */
+ attach({ entityId, revision, ...citation } = {}) {
+ assertEntityId(entityId);
+ checkRevision(entityId, revision);
+ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
+ return insert(entityId, revision, citation);
+ },
+
+ /** Attach several in one transaction. */
+ attachMany(entityId, revision, list = []) {
+ assertEntityId(entityId);
+ checkRevision(entityId, revision);
+ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
+ return db.transaction(() => list.map((c) => insert(entityId, revision, c)))();
+ },
+
+ /**
+ * Reuse citations of `fromRevision` in `toRevision` (all, or only `ids`). New rows point back
+ * with carried_from; the originals are untouched.
+ */
+ carryForward({ entityId, fromRevision, toRevision, ids = null, attachedBy = null } = {}) {
+ assertEntityId(entityId);
+ checkRevision(entityId, toRevision);
+ return db.transaction(() => {
+ const wanted = ids ? new Set(ids.map(Number)) : null;
+ return q.forRevision.all(entityId, fromRevision)
+ .filter((r) => !wanted || wanted.has(r.id))
+ .map((r) => {
+ const c = shape(r);
+ return insert(entityId, toRevision, { ...c, attachedBy: attachedBy || c.attachedBy }, r.carried_from || r.id);
+ });
+ })();
+ },
+
+ get(id) { return shape(q.byId.get(Number(id))); },
+ forRevision(entityId, revision) { return q.forRevision.all(assertEntityId(entityId), revision).map(shape); },
+ /** Every citation any revision of the entity ever had, oldest revision first. */
+ history(entityId) { return q.history.all(assertEntityId(entityId)).map(shape); },
+ /** Where a Sources item is cited — for correction/removal propagation (a source changed → revise). */
+ bySourceItem(sourceItemId) { return q.bySource.all(String(sourceItemId)).map(shape); },
+
+ /** Audited erasure of every citation of one entity. */
+ purgeEntity(entityId, { reason, purgedBy = null } = {}) {
+ if (!reason) throw new TypeError('a purge needs a recorded reason');
+ return db.transaction(() => {
+ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
+ return { deleted: db.prepare(`DELETE FROM ${C} WHERE entity_id = ?`).run(entityId).changes };
+ })();
+ },
+ };
+ return api;
+}
+
+/** Plain facts for the SEO gate: how many citations, and how many name a retrieval time. */
+function gateFacts(citations = []) {
+ return {
+ citationCount: citations.length,
+ datedCitationCount: citations.filter((c) => c && c.retrievedAt).length,
+ };
+}
+
+module.exports = { createCitationStore, gateFacts };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Line and word diffs (Myers' O(ND) algorithm over tokens), used by revisions.diff and ssr.diffHtml.
+ *
+ * diffText(a, b, { mode: 'line' | 'word' }) ->
+ * { mode, ops: [{ op: 'equal'|'insert'|'delete', text }], added, removed, approximate }
+ *
+ * `added` / `removed` count tokens (lines or words). Common prefix and suffix are trimmed before
+ * the search; if the two texts differ by more than `maxEdits` tokens the middle is reported as one
+ * delete plus one insert and `approximate` is true, so a pathological input cannot exhaust memory.
+ */
+
+const WORD_RE = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
+
+function tokenize(text, mode) {
+ const s = String(text == null ? '' : text);
+ if (!s) return [];
+ if (mode === 'word') return s.match(WORD_RE) || [];
+ return s.match(/[^\n]*\n|[^\n]+$/g) || [];
+}
+
+function myers(a, b, maxEdits) {
+ const n = a.length;
+ const m = b.length;
+ const max = n + m;
+ const offset = max + 1;
+ const v = new Int32Array(2 * max + 3);
+ const trace = [];
+ for (let d = 0; d <= max; d++) {
+ if (d > maxEdits) return null;
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
+ for (let k = -d; k <= d; k += 2) {
+ let x = (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1;
+ let y = x - k;
+ while (x < n && y < m && a[x] === b[y]) { x++; y++; }
+ v[offset + k] = x;
+ if (x >= n && y >= m) return backtrack(trace, a, b);
+ }
+ }
+ return backtrack(trace, a, b);
+}
+
+function backtrack(trace, a, b) {
+ const ops = [];
+ let x = a.length;
+ let y = b.length;
+ for (let d = trace.length - 1; d >= 0; d--) {
+ const row = trace[d];
+ const at = (k) => row[k + d + 1];
+ const k = x - y;
+ const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;
+ const prevX = at(prevK);
+ const prevY = prevX - prevK;
+ while (x > prevX && y > prevY) { ops.push({ op: 'equal', text: a[x - 1] }); x--; y--; }
+ if (d > 0) {
+ if (x === prevX) { ops.push({ op: 'insert', text: b[y - 1] }); y--; }
+ else { ops.push({ op: 'delete', text: a[x - 1] }); x--; }
+ }
+ x = prevX;
+ y = prevY;
+ }
+ return ops.reverse();
+}
+
+function merge(ops) {
+ const out = [];
+ for (const o of ops) {
+ const last = out[out.length - 1];
+ if (last && last.op === o.op) last.text += o.text;
+ else out.push({ op: o.op, text: o.text });
+ }
+ return out;
+}
+
+function diffTokens(a, b, { maxEdits = 4000 } = {}) {
+ let start = 0;
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
+ let endA = a.length;
+ let endB = b.length;
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
+ const midA = a.slice(start, endA);
+ const midB = b.slice(start, endB);
+ let middle = myers(midA, midB, maxEdits);
+ let approximate = false;
+ if (!middle) {
+ approximate = true;
+ middle = [...midA.map((t) => ({ op: 'delete', text: t })), ...midB.map((t) => ({ op: 'insert', text: t }))];
+ }
+ const ops = [
+ ...a.slice(0, start).map((t) => ({ op: 'equal', text: t })),
+ ...middle,
+ ...a.slice(endA).map((t) => ({ op: 'equal', text: t })),
+ ];
+ let added = 0;
+ let removed = 0;
+ for (const o of ops) {
+ if (o.op === 'insert' && o.text.trim()) added++;
+ if (o.op === 'delete' && o.text.trim()) removed++;
+ }
+ return { ops: merge(ops), added, removed, approximate };
+}
+
+function diffText(a, b, { mode = 'line', maxEdits } = {}) {
+ if (mode !== 'line' && mode !== 'word') throw new TypeError('mode must be "line" or "word"');
+ return { mode, ...diffTokens(tokenize(a, mode), tokenize(b, mode), { maxEdits }) };
+}
+
+/** Unified-style text for a line diff: "+ added", "- removed", " kept". */
+function formatLines(result) {
+ const out = [];
+ for (const o of result.ops) {
+ const sign = o.op === 'insert' ? '+ ' : o.op === 'delete' ? '- ' : ' ';
+ for (const line of tokenize(o.text, 'line')) out.push(sign + line.replace(/\n$/, ''));
+ }
+ return out.join('\n');
+}
+
+module.exports = { diffText, formatLines, tokenize };
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * openvibe-publishing/discussion — Community comment-thread references. Reference, never copy.
+ *
+ * const { createDiscussionClient, createDiscussionRefs } = require('openvibe-publishing/discussion');
+ * const community = createDiscussionClient({ communityUrl: 'https://openvibe.community', tokenClient });
+ * const refs = createDiscussionRefs(db, { prefix: 'wiki' }); // wiki_discussion_refs
+ * const { threadId } = await refs.threadFor('pg_1', { service: 'wiki', type: 'page', id: 'pg_1', label: 'Sourdough' }, { client: community });
+ *
+ * The thread and its comments live in OpenVibe.Community. This module calls
+ * `POST /api/v1/comments/threads/resolve { ref: EntityRef }` (get-or-create; 201 created, 200
+ * existing) with the product's Network service token (`community.comment.write`, audience
+ * `openvibe.community`), and stores only the thread id next to the entity. There is no column for
+ * comment text, counts or authors, on purpose: products render the thread by embedding or by
+ * reading Community, so moderation and deletion there are always what readers see.
+ *
+ * tokenClient is anything with `authHeaders()` → { Authorization } (openvibe-contracts'
+ * serviceAuth.createTokenClient). Failures are errors (code 'discussion.unavailable' or the
+ * problem code Community returned) — never a fabricated thread.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, parseJson } = require('./internal');
+
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
+const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TRACEPARENT_RE = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;
+
+/** Validates and normalises an EntityRef (common.entity-ref@1). */
+function entityRef({ service, type, id, label, revision } = {}) {
+ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('ref.service must be a service slug');
+ if (!TYPE_RE.test(String(type || ''))) throw new TypeError('ref.type must match ^[a-z][a-z0-9_]{1,39}$');
+ if (typeof id !== 'string' || !id || id.length > 128) throw new TypeError('ref.id must be a 1–128 character string');
+ const ref = { service, type, id };
+ if (Number.isInteger(revision) && revision >= 0) ref.revision = revision;
+ if (label != null && label !== '') ref.label = String(label).slice(0, 200);
+ return ref;
+}
+
+function createDiscussionClient({ communityUrl, tokenClient, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
+ if (!communityUrl) throw new TypeError('communityUrl is required');
+ if (!tokenClient || typeof tokenClient.authHeaders !== 'function') throw new TypeError('tokenClient with authHeaders() is required');
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');
+ const base = String(communityUrl).replace(/\/+$/, '');
+
+ return {
+ base,
+ /**
+ * Get-or-create the Community thread for an entity.
+ * opts: subject (usr_/gst_ id the product acts for; omit to act as the service),
+ * traceparent, requestId.
+ * → { threadId, created, visibility }
+ */
+ async resolveThread(ref, { subject, traceparent, requestId } = {}) {
+ const body = { ref: entityRef(ref) };
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient.authHeaders()) };
+ if (subject) {
+ if (!SUBJECT_RE.test(subject)) throw new TypeError('subject must be a usr_… or gst_… id');
+ headers['X-OV-Subject'] = subject;
+ }
+ if (traceparent && TRACEPARENT_RE.test(traceparent)) headers.traceparent = traceparent;
+ if (requestId) headers['X-OpenVibe-Request-Id'] = String(requestId).slice(0, 128);
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/comments/threads/resolve`, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });
+ } catch (err) {
+ throw new PublishingError(503, 'discussion.unavailable', `Community did not answer: ${err && err.name === 'AbortError' ? 'timeout' : String(err && err.message || err)}`);
+ } finally {
+ clearTimeout(timer);
+ }
+ const text = await res.text().catch(() => '');
+ const json = parseJson(text, null);
+ if (res.status === 401 && typeof tokenClient.invalidate === 'function') tokenClient.invalidate();
+ if (!res.ok) {
+ const code = json && typeof json.code === 'string' ? json.code : 'discussion.unavailable';
+ throw new PublishingError(res.status >= 500 ? 503 : res.status, code, (json && (json.detail || json.title)) || `Community answered ${res.status}`, { upstreamStatus: res.status });
+ }
+ const thread = json && json.thread;
+ if (!thread || typeof thread.id !== 'string' && typeof thread.id !== 'number') {
+ throw new PublishingError(502, 'discussion.bad_response', 'Community returned no thread id');
+ }
+ return { threadId: String(thread.id), created: res.status === 201 || Boolean(json.created), visibility: thread.visibility || null };
+ },
+ };
+}
+
+/** Local table of entity → Community thread id. Stores the reference only. */
+function createDiscussionRefs(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const R = `${prefix}_discussion_refs`;
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${R} (
+ entity_id TEXT PRIMARY KEY,
+ thread_id TEXT NOT NULL,
+ ref TEXT NOT NULL,
+ resolved_at INTEGER NOT NULL
+ );
+ `);
+ const q = {
+ get: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ?`),
+ put: db.prepare(`INSERT INTO ${R} (entity_id, thread_id, ref, resolved_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (entity_id) DO UPDATE SET thread_id = excluded.thread_id, ref = excluded.ref, resolved_at = excluded.resolved_at`),
+ del: db.prepare(`DELETE FROM ${R} WHERE entity_id = ?`),
+ };
+ const shape = (row) => (row ? { entityId: row.entity_id, threadId: row.thread_id, ref: parseJson(row.ref, null), resolvedAt: new Date(row.resolved_at).toISOString() } : null);
+
+ const api = {
+ table: R,
+ get(entityId) { return shape(q.get.get(assertEntityId(entityId))); },
+ /** Record a thread id obtained elsewhere (e.g. from a Community event). */
+ set(entityId, threadId, ref) {
+ q.put.run(assertEntityId(entityId), String(threadId), JSON.stringify(entityRef(ref)), clock());
+ return api.get(entityId);
+ },
+ forget(entityId) { return q.del.run(assertEntityId(entityId)).changes > 0; },
+ /** The stored thread id, or resolve it through Community once and store it. */
+ async threadFor(entityId, ref, { client, ...opts } = {}) {
+ const known = api.get(entityId);
+ if (known) return { threadId: known.threadId, created: false, cached: true };
+ if (!client) throw new TypeError('client (createDiscussionClient) is required to resolve a new thread');
+ const out = await client.resolveThread(ref, opts);
+ api.set(entityId, out.threadId, ref);
+ return { ...out, cached: false };
+ },
+ };
+ return api;
+}
+
+module.exports = { createDiscussionClient, createDiscussionRefs, entityRef };
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * openvibe-publishing/index-hooks — Search index documents and publication event envelopes.
+ *
+ * const hooks = require('openvibe-publishing/index-hooks');
+ * const doc = hooks.buildIndexDocument({ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published',
+ * visibility: 'public', canonicalUrl, title, summary, body, facets: { space: 'food' }, provenance, decision });
+ * const action = hooks.actionFor(before, after); // 'published' | 'updated' | 'unpublished' | 'deleted' | null
+ * const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: 'pg_1', revision: 4,
+ * actor: 'usr_…', document: doc, decision, now: Date.now() });
+ * outbox.enqueue(env); // OpenVibe.Events createOutbox, in the same transaction as the product's change
+ *
+ * The index document is the Search contract of [REF] §12.3 (owner service, resource type/id,
+ * revision, visibility + ACL, canonical URL, title, summary, body, facets, provenance, publication
+ * state, deletion marker) plus the gate's indexability decision. Proposed schema:
+ * docs/contracts-proposal/search.index-document.v1.json.
+ *
+ * Safety rules built in:
+ * - Anything not published, deleted, or unlisted becomes a tombstone (deletion marker, no
+ * title/summary/body), so a visibility change or deletion removes content from the index.
+ * - private content must carry an ACL naming subjects; gated content must name entitlements.
+ * There is no way to build a private document that reads as world-visible.
+ * - Events about non-public or non-listable content are 'internal', never 'public'.
+ * - The envelope carries no event_id; the outbox assigns evt_<ULID> (or pass eventId).
+ */
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const SEGMENT_RE = /^[a-z][a-z0-9_]{0,39}$/;
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const SUBJECT_ID_RE = new RegExp(`^(usr|gst|app|mod)_${ULID}$`);
+const ACTIONS = ['published', 'updated', 'unpublished', 'deleted'];
+const SCHEMA = 'search.index-document@1';
+const MAX_BODY = 100000;
+
+function assertDecision(d) {
+ if (!d || typeof d.indexable !== 'boolean' || typeof d.listable !== 'boolean') throw new TypeError('decision (seo.evaluate) is required');
+ return { indexable: d.indexable, listable: d.listable, reasons: Array.isArray(d.codes) ? [...d.codes] : [], gate: d.gate || null };
+}
+
+function iso(v, name) {
+ if (v == null || v === '') return null;
+ const d = new Date(v);
+ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);
+ return d.toISOString();
+}
+
+function checkFacets(facets) {
+ const out = {};
+ for (const [k, v] of Object.entries(facets || {})) {
+ if (!SEGMENT_RE.test(k)) throw new TypeError(`facet name "${k}" must match ${SEGMENT_RE}`);
+ if (v == null) continue;
+ const ok = (x) => typeof x === 'string' || typeof x === 'boolean' || (typeof x === 'number' && Number.isFinite(x));
+ if (Array.isArray(v) ? !v.every(ok) : !ok(v)) throw new TypeError(`facet "${k}" must be a string, number, boolean or an array of them`);
+ out[k] = Array.isArray(v) ? [...v] : v;
+ }
+ return out;
+}
+
+function checkProvenance(p = {}) {
+ const out = {};
+ if (p.authorship) {
+ out.authorship = { mode: p.authorship.mode };
+ if (p.authorship.workflow) out.authorship.workflow = { id: p.authorship.workflow.id, run_id: p.authorship.workflow.runId };
+ if (p.authorship.reviewed != null) out.authorship.reviewed = Boolean(p.authorship.reviewed);
+ }
+ if (Array.isArray(p.citations)) {
+ out.citations = p.citations.map((c) => {
+ const o = {};
+ if (c.sourceItemId) o.source_item_id = String(c.sourceItemId);
+ if (c.url) o.url = String(c.url);
+ if (c.retrievedAt) o.retrieved_at = iso(c.retrievedAt, 'citation.retrievedAt');
+ if (c.licenseNote) o.license_note = String(c.licenseNote);
+ return o;
+ }).filter((o) => o.source_item_id || o.url);
+ }
+ if (p.derivedFrom) out.derived_from = p.derivedFrom;
+ return out;
+}
+
+function checkIdentity({ service, type, id, revision }) {
+ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('service must be a service slug');
+ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');
+ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError('id must be a non-empty string');
+ if (!Number.isInteger(revision) || revision < 0) throw new TypeError('revision must be a non-negative integer');
+}
+
+/** A deletion marker: Search removes (resource, revision ≤ this) and caches purge it. */
+function tombstone({ service, type, id, revision, state = 'deleted', updatedAt = null }) {
+ checkIdentity({ service, type, id, revision });
+ return {
+ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,
+ publication_state: state, deleted: true, updated_at: iso(updatedAt, 'updatedAt'),
+ };
+}
+
+/**
+ * Build the Search document for a revision. Non-published, deleted and unlisted resources come
+ * back as tombstones.
+ */
+function buildIndexDocument({
+ service, type, id, revision, state, visibility, acl = {}, canonicalUrl, title, summary = null, body = '',
+ facets = {}, provenance = {}, decision, publishedAt = null, updatedAt = null, locale = null, deleted = false,
+} = {}) {
+ checkIdentity({ service, type, id, revision });
+ if (deleted || state !== 'published' || visibility === 'unlisted') {
+ return tombstone({ service, type, id, revision, state: deleted ? 'deleted' : state || 'deleted', updatedAt });
+ }
+ const indexability = assertDecision(decision);
+ let docAcl;
+ if (visibility === 'public') docAcl = { public: true };
+ else if (visibility === 'private') {
+ const subjects = (acl.subjects || []).map(String);
+ if (!subjects.length || !subjects.every((s) => SUBJECT_ID_RE.test(s))) throw new TypeError('private documents need acl.subjects (usr_/gst_/app_/mod_ ids)');
+ docAcl = { public: false, subjects };
+ } else if (visibility === 'gated') {
+ const entitlements = (acl.entitlements || []).map(String).filter(Boolean);
+ if (!entitlements.length) throw new TypeError('gated documents need acl.entitlements');
+ docAcl = { public: false, entitlements, ...(acl.subjects && acl.subjects.length ? { subjects: acl.subjects.map(String) } : {}) };
+ } else {
+ throw new TypeError('visibility must be public, private, gated or unlisted');
+ }
+ if (!canonicalUrl || !/^https?:\/\//.test(canonicalUrl)) throw new TypeError('canonicalUrl must be absolute');
+ if (!title) throw new TypeError('title is required');
+ const text = String(body == null ? '' : body);
+ const doc = {
+ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,
+ visibility, acl: docAcl, canonical_url: canonicalUrl, title: String(title),
+ summary: summary == null ? null : String(summary), body: text.slice(0, MAX_BODY),
+ facets: checkFacets(facets), provenance: checkProvenance(provenance),
+ publication_state: 'published', deleted: false, indexability,
+ published_at: iso(publishedAt, 'publishedAt'), updated_at: iso(updatedAt, 'updatedAt'),
+ };
+ if (text.length > MAX_BODY) doc.body_truncated = true;
+ if (locale) doc.locale = String(locale);
+ return doc;
+}
+
+/**
+ * Which publication event a transition produces. before/after: { state, visibility, revision }
+ * (before may be null for a new resource). → 'published' | 'updated' | 'unpublished' | 'deleted' | null
+ */
+function actionFor(before, after) {
+ const was = Boolean(before && before.state === 'published');
+ if (after.state === 'deleted') return before && before.state === 'deleted' ? null : (before ? 'deleted' : null);
+ const is = after.state === 'published';
+ if (!was && is) return 'published';
+ if (was && !is) return 'unpublished';
+ if (was && is && (before.revision !== after.revision || before.visibility !== after.visibility)) return 'updated';
+ return null;
+}
+
+/** 'usr_…' | 'svc:wiki' | 'service:wiki' | { type, id } → SubjectRef. */
+function subjectRef(actor) {
+ if (actor && typeof actor === 'object') return { type: actor.type, id: actor.id };
+ const s = String(actor || '');
+ if (/^svc:/.test(s)) return { type: 'service', id: s.slice(4) };
+ const m = s.match(/^(user|guest|service|system|app|mod):(.+)$/);
+ if (m) return { type: m[1], id: m[2] };
+ if (/^usr_/.test(s)) return { type: 'user', id: s };
+ if (/^gst_/.test(s)) return { type: 'guest', id: s };
+ throw new TypeError('actor must be a subject (usr_…, gst_…, svc:<service>) or a SubjectRef');
+}
+
+/**
+ * `<product>.<type>.<action>` envelope (contracts events/event-envelope@1) for the product's outbox.
+ * document: the index document (or tombstone) for this revision — it becomes the payload.
+ */
+function publicationEvent({ product, type, action, id, revision, actor, document, decision = null, now = Date.now(), traceId = null, eventId = null, extra = {} } = {}) {
+ if (!SERVICE_RE.test(String(product || '')) || !SEGMENT_RE.test(String(product))) throw new TypeError('product must be a lowercase service slug without dashes, e.g. "wiki"');
+ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');
+ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);
+ if (!document || document.resource_id !== id) throw new TypeError('document (buildIndexDocument/tombstone) for this resource is required');
+ if ((action === 'deleted' || action === 'unpublished') && !document.deleted) throw new TypeError(`${action} events carry a tombstone`);
+ // A published-but-unlisted resource is a tombstone with publication_state 'published': it may be updated, not listed.
+ if ((action === 'published' || action === 'updated') && document.publication_state !== 'published') throw new TypeError(`${action} events carry a published document`);
+ const isPublic = !document.deleted && document.visibility === 'public' && document.indexability && document.indexability.listable;
+ const env = {
+ event_type: `${product}.${type}.${action}`,
+ version: 1,
+ source: product,
+ actor: subjectRef(actor),
+ timestamp: new Date(now).toISOString(),
+ visibility: isPublic ? 'public' : 'internal',
+ subject: { type, id, revision },
+ payload: {
+ canonical_url: document.canonical_url || null,
+ publication_state: document.publication_state,
+ indexability: decision ? assertDecision(decision) : document.indexability || null,
+ document,
+ ...extra,
+ },
+ };
+ if (eventId) env.event_id = eventId;
+ if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;
+ return env;
+}
+
+module.exports = { SCHEMA, ACTIONS, buildIndexDocument, tombstone, actionFor, publicationEvent, subjectRef };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * Helpers shared by the modules in this package. Not a public entry point.
+ *
+ * Every storage module works on a better-sqlite3 handle the consumer owns and names its tables
+ * with the consumer's prefix, so a product's publication state stays in the product's database.
+ */
+const crypto = require('crypto');
+
+const PREFIX_RE = /^[a-z][a-z0-9_]{0,39}$/;
+const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+/** An error with an HTTP status and a stable code, ready for contracts.http.sendProblem(res, status, code, …). */
+class PublishingError extends Error {
+ constructor(status, code, message, extra = {}) {
+ super(message);
+ this.name = 'PublishingError';
+ this.status = status;
+ this.code = code;
+ Object.assign(this, extra);
+ }
+}
+
+function assertDb(db) {
+ if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') {
+ throw new TypeError('a better-sqlite3 database handle is required');
+ }
+}
+
+function assertPrefix(prefix) {
+ if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
+ throw new TypeError(`prefix must match ${PREFIX_RE} (got ${JSON.stringify(prefix)})`);
+ }
+ return prefix;
+}
+
+function clockOf(now) {
+ if (now == null) return () => Date.now();
+ if (typeof now !== 'function') throw new TypeError('now must be a function returning epoch milliseconds');
+ return () => {
+ const t = Number(now());
+ if (!Number.isFinite(t)) throw new TypeError('now() must return epoch milliseconds');
+ return t;
+ };
+}
+
+/** Time-sortable id with a type prefix: rev_01J…, job_01J…. */
+function newId(prefix, nowMs = Date.now()) {
+ let time = '';
+ for (let t = Math.floor(nowMs), i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
+ const bytes = crypto.randomBytes(16);
+ let rand = '';
+ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];
+ return `${prefix}_${time}${rand}`;
+}
+
+function sha256(text) {
+ return crypto.createHash('sha256').update(String(text)).digest('hex');
+}
+
+/** Stable JSON: object keys sorted, so the same fields always hash the same. */
+function stableStringify(value) {
+ if (value === undefined) return 'null';
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
+}
+
+function parseJson(text, fallback = null) {
+ if (text == null) return fallback;
+ try { return JSON.parse(text); } catch { return fallback; }
+}
+
+function assertEntityId(id, name = 'entityId') {
+ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError(`${name} must be a non-empty string of at most 200 characters`);
+ return id;
+}
+
+function assertRevisionNumber(n, name = 'revision') {
+ if (!Number.isInteger(n) || n < 0) throw new TypeError(`${name} must be a non-negative integer`);
+ return n;
+}
+
+/** ISO-8601 instant or null. Never invents a time: missing stays null. */
+function isoOrNull(value, name) {
+ if (value == null || value === '') return null;
+ const d = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);
+ return d.toISOString();
+}
+
+/**
+ * Append-only protection for a table: UPDATE always aborts, DELETE aborts unless the entity was
+ * first registered in `<purgeTable>` (an audited, explicit erasure path).
+ */
+function immutableTriggers(table, purgeTable, keyColumn = 'entity_id') {
+ return `
+ CREATE TRIGGER IF NOT EXISTS ${table}_no_update BEFORE UPDATE ON ${table}
+ BEGIN SELECT RAISE(ABORT, '${table} rows are immutable'); END;
+ CREATE TRIGGER IF NOT EXISTS ${table}_no_delete BEFORE DELETE ON ${table}
+ WHEN NOT EXISTS (SELECT 1 FROM ${purgeTable} WHERE entity_id = OLD.${keyColumn})
+ BEGIN SELECT RAISE(ABORT, '${table} rows are never deleted outside a recorded purge'); END;
+ `;
+}
+
+module.exports = {
+ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
+ assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers, PREFIX_RE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+'use strict';
+/**
+ * openvibe-publishing/media — attachments by OpenVibe.Media object id, with an explicit
+ * broken-asset state.
+ *
+ * const { createAttachmentStore } = require('openvibe-publishing/media');
+ * const media = createAttachmentStore(db, { prefix: 'blog_post' }); // blog_post_attachments
+ * media.attach({ entityId: 'post_1', mediaId: 'med_01J…', role: 'cover', alt: 'A loaf' });
+ * // Media says the object is gone (media.object.deleted event, or a 404 on fetch):
+ * media.markBroken('med_01J…', 'deleted');
+ * media.list('post_1') // → [{ …, state: 'broken', brokenReason: 'deleted' }]
+ *
+ * Bytes never live here: a row is a reference (MediaRef: med_<ULID> or the transitional
+ * legacy:<app>:<kind>:<id>). States:
+ * unverified attached, never checked
+ * available the last check found the object
+ * broken the object is gone (not_found | deleted | forbidden) — rendered as an explicit
+ * "media unavailable" placeholder, never silently dropped or replaced
+ * A check that fails for another reason (timeout, 5xx) changes nothing and is reported as
+ * check_failed: an outage is not evidence the object is gone, nor that it exists.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
+
+const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;
+const ROLE_RE = /^[a-z][a-z0-9_]{1,39}$/;
+const VARIANT_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;
+const STATES = ['unverified', 'available', 'broken'];
+const BROKEN_REASONS = ['not_found', 'deleted', 'forbidden'];
+
+function isMediaId(id) { return typeof id === 'string' && MEDIA_ID_RE.test(id); }
+
+function shape(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ entityId: row.entity_id,
+ revision: row.revision,
+ mediaId: row.media_id,
+ role: row.role,
+ variant: row.variant,
+ alt: row.alt,
+ caption: row.caption,
+ position: row.position,
+ state: row.state,
+ broken: row.state === 'broken',
+ brokenReason: row.broken_reason,
+ checkedAt: row.checked_at == null ? null : new Date(row.checked_at).toISOString(),
+ };
+}
+
+function createAttachmentStore(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const A = `${prefix}_attachments`;
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${A} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ entity_id TEXT NOT NULL,
+ revision INTEGER,
+ media_id TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'inline',
+ variant TEXT,
+ alt TEXT,
+ caption TEXT,
+ position INTEGER NOT NULL DEFAULT 0,
+ state TEXT NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
+ broken_reason TEXT CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
+ checked_at INTEGER,
+ created_at INTEGER NOT NULL,
+ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
+ );
+ CREATE INDEX IF NOT EXISTS ${A}_entity ON ${A} (entity_id, position);
+ CREATE INDEX IF NOT EXISTS ${A}_media ON ${A} (media_id);
+ `);
+
+ const q = {
+ insert: db.prepare(`INSERT INTO ${A} (entity_id, revision, media_id, role, variant, alt, caption, position, created_at)
+ VALUES (@entity_id, @revision, @media_id, @role, @variant, @alt, @caption, @position, @now)`),
+ byId: db.prepare(`SELECT * FROM ${A} WHERE id = ?`),
+ list: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? ORDER BY position, id`),
+ listRev: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND (revision IS NULL OR revision = ?) ORDER BY position, id`),
+ detach: db.prepare(`DELETE FROM ${A} WHERE id = ? AND entity_id = ?`),
+ broken: db.prepare(`UPDATE ${A} SET state = 'broken', broken_reason = ?, checked_at = ? WHERE media_id = ?`),
+ available: db.prepare(`UPDATE ${A} SET state = 'available', broken_reason = NULL, checked_at = ? WHERE media_id = ?`),
+ brokenFor: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND state = 'broken' ORDER BY position, id`),
+ entitiesWith: db.prepare(`SELECT DISTINCT entity_id FROM ${A} WHERE media_id = ? ORDER BY entity_id`),
+ };
+
+ const api = {
+ table: A,
+
+ attach({ entityId, mediaId, revision = null, role = 'inline', variant = null, alt = null, caption = null, position = 0 } = {}) {
+ assertEntityId(entityId);
+ if (!isMediaId(mediaId)) throw new PublishingError(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');
+ if (!ROLE_RE.test(role)) throw new TypeError(`role must match ${ROLE_RE}`);
+ if (variant != null && !VARIANT_RE.test(variant)) throw new TypeError(`variant must match ${VARIANT_RE}`);
+ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
+ const info = q.insert.run({
+ entity_id: entityId, revision, media_id: mediaId, role, variant,
+ alt: alt == null ? null : String(alt).slice(0, 1000),
+ caption: caption == null ? null : String(caption).slice(0, 2000),
+ position: Number.isInteger(position) ? position : 0, now: clock(),
+ });
+ return shape(q.byId.get(info.lastInsertRowid));
+ },
+
+ detach(entityId, attachmentId) { return q.detach.run(Number(attachmentId), assertEntityId(entityId)).changes > 0; },
+
+ get(id) { return shape(q.byId.get(Number(id))); },
+
+ /** All attachments of an entity; with `revision`, entity-wide ones plus that revision's. */
+ list(entityId, { revision } = {}) {
+ assertEntityId(entityId);
+ return (revision == null ? q.list.all(entityId) : q.listRev.all(entityId, revision)).map(shape);
+ },
+
+ broken(entityId) { return q.brokenFor.all(assertEntityId(entityId)).map(shape); },
+
+ /** Entities that reference a Media object — what to re-render/re-index when it changes. */
+ entitiesUsing(mediaId) { return q.entitiesWith.all(String(mediaId)).map((r) => r.entity_id); },
+
+ /** Mark every attachment of this object broken. Returns the affected entity ids. */
+ markBroken(mediaId, reason = 'not_found') {
+ if (!BROKEN_REASONS.includes(reason)) throw new TypeError(`reason must be one of ${BROKEN_REASONS.join(', ')}`);
+ return db.transaction(() => {
+ const ids = api.entitiesUsing(mediaId);
+ q.broken.run(reason, clock(), String(mediaId));
+ return ids;
+ })();
+ },
+
+ markAvailable(mediaId) {
+ return db.transaction(() => {
+ const ids = api.entitiesUsing(mediaId);
+ q.available.run(clock(), String(mediaId));
+ return ids;
+ })();
+ },
+
+ /**
+ * Check an entity's attachments against Media. resolve(mediaId) must return
+ * { exists: true } | { exists: false, reason: 'not_found'|'deleted'|'forbidden' } or throw.
+ * Returns [{ mediaId, outcome: 'available'|'broken'|'check_failed', reason? }].
+ */
+ async verify(entityId, { resolve } = {}) {
+ if (typeof resolve !== 'function') throw new TypeError('resolve(mediaId) is required');
+ const ids = [...new Set(api.list(entityId).map((a) => a.mediaId))];
+ const out = [];
+ for (const mediaId of ids) {
+ let r;
+ try { r = await resolve(mediaId); } catch (err) {
+ out.push({ mediaId, outcome: 'check_failed', error: String(err && err.message || err) });
+ continue;
+ }
+ if (r && r.exists === true) { api.markAvailable(mediaId); out.push({ mediaId, outcome: 'available' }); }
+ else if (r && r.exists === false && BROKEN_REASONS.includes(r.reason || 'not_found')) {
+ api.markBroken(mediaId, r.reason || 'not_found');
+ out.push({ mediaId, outcome: 'broken', reason: r.reason || 'not_found' });
+ } else out.push({ mediaId, outcome: 'check_failed', error: 'resolver returned no verdict' });
+ }
+ return out;
+ },
+ };
+ return api;
+}
+
+/** The Contracts MediaRef ({ media_id, role?, variant? }) for an attachment. */
+function mediaRef(att) {
+ const ref = { media_id: att.mediaId };
+ if (att.role) ref.role = att.role;
+ if (att.variant) ref.variant = att.variant;
+ return ref;
+}
+
+/**
+ * Server-rendered <figure> for an attachment. urlFor(mediaId, att) returns the delivery URL.
+ * A broken attachment renders an explicit placeholder — no <img>, no guessed URL.
+ */
+function figureHtml(att, { urlFor, unavailableText = 'This media is no longer available.' } = {}) {
+ const { escapeHtml: esc } = require('./ssr');
+ const caption = att.caption ? `<figcaption>${esc(att.caption)}</figcaption>` : '';
+ if (att.state === 'broken') {
+ return `<figure class="ov-media ov-media-broken" data-media-id="${esc(att.mediaId)}" data-state="broken"><div class="ov-media-missing" role="img" aria-label="${esc(unavailableText)}">${esc(unavailableText)}</div>${caption}</figure>`;
+ }
+ if (typeof urlFor !== 'function') throw new TypeError('urlFor(mediaId) is required to render an available attachment');
+ const src = urlFor(att.mediaId, att);
+ return `<figure class="ov-media" data-media-id="${esc(att.mediaId)}" data-state="${esc(att.state)}"><img src="${esc(src)}" alt="${esc(att.alt || '')}" loading="lazy" decoding="async">${caption}</figure>`;
+}
+
+module.exports = { createAttachmentStore, mediaRef, figureHtml, isMediaId, STATES, BROKEN_REASONS, MEDIA_ID_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,302 @@
@@ -0,0 +1,302 @@
+'use strict';
+/**
+ * openvibe-publishing/revisions — drafts and immutable revisions in the consumer's own database.
+ *
+ * const { createRevisionStore } = require('openvibe-publishing/revisions');
+ * const revs = createRevisionStore(db, { prefix: 'wiki_page' }); // wiki_page_revisions, …
+ * const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Hi', fields: { title: 'Hi' }, author: 'user:usr_…' });
+ * revs.create({ entityId: 'pg_1', expectedRevision: 1, content: '# Hello', author: … }); // r2, parent r1
+ * revs.diff('pg_1', 1, 2, { mode: 'word' });
+ * revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: … }); // r3 = copy of r1
+ *
+ * Tables (created idempotently):
+ * <prefix>_revisions immutable: UPDATE aborts; DELETE aborts unless the entity is purged
+ * <prefix>_drafts one mutable working copy per (entity, owner), based on a revision
+ * <prefix>_revision_purges audit rows for explicit erasure (purgeEntity)
+ *
+ * Optimistic concurrency: every write names the revision number it was based on
+ * (`expectedRevision`, 0 for a new entity). If the head moved, the write throws a PublishingError
+ * with status 412 and code 'revision.conflict' carrying `expected` and `current`. A UNIQUE
+ * (entity_id, number) index is the last line of defence against two processes racing.
+ *
+ * This module stores content, not publication state: which revision is live is the product's
+ * decision, kept in the product's own tables.
+ */
+const {
+ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
+ assertEntityId, assertRevisionNumber, immutableTriggers,
+} = require('./internal');
+const { diffText } = require('./diff');
+
+const KINDS = ['edit', 'revert', 'import'];
+
+function conflict(entityId, expected, current) {
+ return new PublishingError(412, 'revision.conflict',
+ `Revision conflict on ${entityId}: expected ${expected}, current is ${current}`,
+ { entityId, expected, current });
+}
+
+function shape(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ entityId: row.entity_id,
+ number: row.number,
+ parentId: row.parent_id,
+ parentNumber: row.parent_number,
+ kind: row.kind,
+ revertedTo: row.reverted_to,
+ content: row.content,
+ fields: parseJson(row.fields, {}),
+ meta: parseJson(row.meta, {}),
+ contentHash: row.content_hash,
+ author: row.author,
+ message: row.message,
+ createdAt: new Date(row.created_at).toISOString(),
+ };
+}
+
+function createRevisionStore(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const T = `${prefix}_revisions`;
+ const D = `${prefix}_drafts`;
+ const P = `${prefix}_revision_purges`;
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${T} (
+ id TEXT PRIMARY KEY,
+ entity_id TEXT NOT NULL,
+ number INTEGER NOT NULL CHECK (number >= 1),
+ parent_id TEXT,
+ parent_number INTEGER,
+ kind TEXT NOT NULL CHECK (kind IN ('edit','revert','import')),
+ reverted_to INTEGER,
+ content TEXT NOT NULL,
+ fields TEXT NOT NULL DEFAULT '{}',
+ meta TEXT NOT NULL DEFAULT '{}',
+ content_hash TEXT NOT NULL,
+ author TEXT,
+ message TEXT,
+ created_at INTEGER NOT NULL
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS ${T}_entity_number ON ${T} (entity_id, number);
+ CREATE TABLE IF NOT EXISTS ${D} (
+ entity_id TEXT NOT NULL,
+ owner TEXT NOT NULL,
+ base_revision INTEGER NOT NULL,
+ content TEXT NOT NULL,
+ fields TEXT NOT NULL DEFAULT '{}',
+ meta TEXT NOT NULL DEFAULT '{}',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (entity_id, owner)
+ );
+ CREATE TABLE IF NOT EXISTS ${P} (
+ entity_id TEXT PRIMARY KEY,
+ reason TEXT NOT NULL,
+ purged_by TEXT,
+ purged_at INTEGER NOT NULL
+ );
+ ${immutableTriggers(T, P)}
+ `);
+
+ const q = {
+ head: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? ORDER BY number DESC LIMIT 1`),
+ byNumber: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number = ?`),
+ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
+ list: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number < ? ORDER BY number DESC LIMIT ?`),
+ insert: db.prepare(`INSERT INTO ${T} (id, entity_id, number, parent_id, parent_number, kind, reverted_to, content, fields, meta, content_hash, author, message, created_at)
+ VALUES (@id, @entity_id, @number, @parent_id, @parent_number, @kind, @reverted_to, @content, @fields, @meta, @content_hash, @author, @message, @created_at)`),
+ draft: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? AND owner = ?`),
+ draftsFor: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? ORDER BY updated_at DESC`),
+ upsertDraft: db.prepare(`INSERT INTO ${D} (entity_id, owner, base_revision, content, fields, meta, created_at, updated_at)
+ VALUES (@entity_id, @owner, @base_revision, @content, @fields, @meta, @now, @now)
+ ON CONFLICT (entity_id, owner) DO UPDATE SET base_revision = excluded.base_revision, content = excluded.content,
+ fields = excluded.fields, meta = excluded.meta, updated_at = excluded.updated_at`),
+ deleteDraft: db.prepare(`DELETE FROM ${D} WHERE entity_id = ? AND owner = ?`),
+ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
+ };
+
+ function headNumber(entityId) {
+ const h = q.head.get(entityId);
+ return h ? h.number : 0;
+ }
+
+ function contentHash(content, fields) {
+ return sha256(`${content}\u0000${stableStringify(fields || {})}`);
+ }
+
+ function insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, revertedTo, allowUnchanged }) {
+ assertEntityId(entityId);
+ assertRevisionNumber(expectedRevision, 'expectedRevision');
+ if (typeof content !== 'string') throw new TypeError('content must be a string');
+ if (fields != null && (typeof fields !== 'object' || Array.isArray(fields))) throw new TypeError('fields must be an object');
+ if (meta != null && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');
+ if (!KINDS.includes(kind)) throw new TypeError(`kind must be one of ${KINDS.join(', ')}`);
+ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged; its id cannot be reused`);
+ const head = q.head.get(entityId);
+ const current = head ? head.number : 0;
+ if (current !== expectedRevision) throw conflict(entityId, expectedRevision, current);
+ const hash = contentHash(content, fields);
+ if (head && head.content_hash === hash && !allowUnchanged) return { revision: shape(head), created: false };
+ const t = clock();
+ const row = {
+ id: newId('rev', t),
+ entity_id: entityId,
+ number: current + 1,
+ parent_id: head ? head.id : null,
+ parent_number: head ? head.number : null,
+ kind,
+ reverted_to: revertedTo == null ? null : revertedTo,
+ content,
+ fields: JSON.stringify(fields || {}),
+ meta: JSON.stringify(meta || {}),
+ content_hash: hash,
+ author: author == null ? null : String(author),
+ message: message == null ? null : String(message).slice(0, 2000),
+ created_at: t,
+ };
+ try {
+ q.insert.run(row);
+ } catch (err) {
+ if (/UNIQUE constraint failed/.test(String(err && err.message))) throw conflict(entityId, expectedRevision, headNumber(entityId));
+ throw err;
+ }
+ return { revision: shape(q.byId.get(row.id)), created: true };
+ }
+
+ const tx = (fn) => db.transaction(fn);
+
+ const store = {
+ prefix,
+ tables: { revisions: T, drafts: D, purges: P },
+
+ /** Latest revision of an entity, or null. */
+ head(entityId) { return shape(q.head.get(assertEntityId(entityId))); },
+
+ /** Current head number (0 when the entity has no revision yet). */
+ headNumber(entityId) { return headNumber(assertEntityId(entityId)); },
+
+ get(entityId, number) { return shape(q.byNumber.get(assertEntityId(entityId), number)); },
+
+ getById(id) { return shape(q.byId.get(String(id))); },
+
+ /** Newest first. `before` is an exclusive revision number cursor. */
+ list(entityId, { limit = 50, before } = {}) {
+ const lim = Math.max(1, Math.min(500, Number(limit) || 50));
+ const cursor = before == null ? Number.MAX_SAFE_INTEGER : Number(before);
+ return q.list.all(assertEntityId(entityId), cursor, lim).map(shape);
+ },
+
+ /** Walks parent pointers from `number` (default: head) back to revision 1. */
+ lineage(entityId, number) {
+ let row = number == null ? q.head.get(assertEntityId(entityId)) : q.byNumber.get(assertEntityId(entityId), number);
+ const out = [];
+ while (row) {
+ out.push(shape(row));
+ row = row.parent_id ? q.byId.get(row.parent_id) : null;
+ }
+ return out;
+ },
+
+ /**
+ * New immutable revision. Returns { revision, created }. An edit identical to the head
+ * (same content and fields) returns the head with created: false unless allowUnchanged.
+ */
+ create({ entityId, expectedRevision, content, fields = {}, meta = {}, author = null, message = null, kind = 'edit', allowUnchanged = false } = {}) {
+ if (kind === 'revert') throw new TypeError('use revert() to create a revert revision');
+ return tx(() => insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, allowUnchanged }))();
+ },
+
+ /** Revert as a new revision: copies revision `toRevision`'s content and fields; history is never rewritten. */
+ revert({ entityId, toRevision, expectedRevision, author = null, message = null, meta = {} } = {}) {
+ return tx(() => {
+ const target = q.byNumber.get(assertEntityId(entityId), toRevision);
+ if (!target) throw new PublishingError(404, 'revision.not_found', `No revision ${toRevision} of ${entityId}`);
+ return insertRevision({
+ entityId, expectedRevision, content: target.content, fields: parseJson(target.fields, {}),
+ meta: { ...meta, revertedFrom: headNumber(entityId) }, author,
+ message: message == null ? `Revert to revision ${toRevision}` : message,
+ kind: 'revert', revertedTo: toRevision, allowUnchanged: true,
+ });
+ })();
+ },
+
+ /** Line or word diff of the content plus a field-by-field comparison. */
+ diff(entityId, fromNumber, toNumber, { mode = 'line' } = {}) {
+ const a = q.byNumber.get(assertEntityId(entityId), fromNumber);
+ const b = q.byNumber.get(entityId, toNumber);
+ if (!a || !b) throw new PublishingError(404, 'revision.not_found', `No revision ${!a ? fromNumber : toNumber} of ${entityId}`);
+ const fa = parseJson(a.fields, {});
+ const fb = parseJson(b.fields, {});
+ const fields = [];
+ for (const key of [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort()) {
+ if (stableStringify(fa[key]) !== stableStringify(fb[key])) fields.push({ field: key, from: fa[key] === undefined ? null : fa[key], to: fb[key] === undefined ? null : fb[key] });
+ }
+ return { entityId, from: fromNumber, to: toNumber, content: diffText(a.content, b.content, { mode }), fields };
+ },
+
+ /** Save (create or replace) the owner's working copy. baseRevision defaults to the current head. */
+ saveDraft({ entityId, owner, content, fields = {}, meta = {}, baseRevision } = {}) {
+ assertEntityId(entityId);
+ if (typeof owner !== 'string' || !owner) throw new TypeError('owner must be a subject string');
+ if (typeof content !== 'string') throw new TypeError('content must be a string');
+ const base = baseRevision == null ? headNumber(entityId) : assertRevisionNumber(baseRevision, 'baseRevision');
+ q.upsertDraft.run({ entity_id: entityId, owner, base_revision: base, content, fields: JSON.stringify(fields || {}), meta: JSON.stringify(meta || {}), now: clock() });
+ return store.getDraft(entityId, owner);
+ },
+
+ getDraft(entityId, owner) {
+ const row = q.draft.get(assertEntityId(entityId), String(owner));
+ if (!row) return null;
+ return {
+ entityId: row.entity_id, owner: row.owner, baseRevision: row.base_revision, content: row.content,
+ fields: parseJson(row.fields, {}), meta: parseJson(row.meta, {}),
+ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
+ stale: row.base_revision !== headNumber(row.entity_id),
+ };
+ },
+
+ drafts(entityId) {
+ return q.draftsFor.all(assertEntityId(entityId)).map((row) => store.getDraft(row.entity_id, row.owner));
+ },
+
+ discardDraft(entityId, owner) { return q.deleteDraft.run(assertEntityId(entityId), String(owner)).changes > 0; },
+
+ /**
+ * Turn a draft into a revision, based on the revision the draft started from. If someone
+ * published in between, this is a 412 conflict and the draft is kept.
+ */
+ commitDraft({ entityId, owner, message = null, author } = {}) {
+ return tx(() => {
+ const d = q.draft.get(assertEntityId(entityId), String(owner));
+ if (!d) throw new PublishingError(404, 'draft.not_found', `No draft of ${entityId} for ${owner}`);
+ const out = insertRevision({
+ entityId, expectedRevision: d.base_revision, content: d.content, fields: parseJson(d.fields, {}),
+ meta: parseJson(d.meta, {}), author: author || owner, message, kind: 'edit',
+ });
+ q.deleteDraft.run(entityId, owner);
+ return out;
+ })();
+ },
+
+ /**
+ * Explicit, audited erasure of every revision and draft of one entity (legal removal).
+ * The purge row stays as the record that it happened.
+ */
+ purgeEntity(entityId, { reason, purgedBy = null } = {}) {
+ if (!reason) throw new TypeError('a purge needs a recorded reason');
+ return tx(() => {
+ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
+ const n = db.prepare(`DELETE FROM ${T} WHERE entity_id = ?`).run(entityId).changes;
+ db.prepare(`DELETE FROM ${D} WHERE entity_id = ?`).run(entityId);
+ return { deleted: n };
+ })();
+ },
+ };
+ return store;
+}
+
+module.exports = { createRevisionStore, diffText, PublishingError, KINDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+'use strict';
+/**
+ * openvibe-publishing/schedule — scheduled publish/unpublish jobs that survive worker restarts.
+ *
+ * const { createScheduler } = require('openvibe-publishing/schedule');
+ * const sched = createScheduler(db, { prefix: 'blog', leaseMs: 60000, now: () => Date.now() });
+ * sched.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.parse('2026-10-01T09:00:00Z'), revision: 3 });
+ * // in the product's worker loop:
+ * await sched.runDue({ worker: 'blog-1', handler: async (job) => { publishPost(job.entityId, job.revision); } });
+ *
+ * Table <prefix>_schedule_jobs. Guarantees:
+ * - schedule() is idempotent: the same (entity, action, runAt, revision) — or the same explicit
+ * `key` — returns the existing job instead of adding a second one.
+ * - claim() takes a lease (lease_owner, lease_until). A worker that dies mid-job leaves the lease
+ * to expire, and the next claim re-runs the job: at-least-once. The handler must therefore be
+ * re-run safe ("make revision N the published one" is; "append a publish" is not). Each job
+ * carries a stable `key` the handler can use as its own idempotency key.
+ * - complete()/fail() only count when the caller still holds the lease, so a worker whose lease
+ * was taken over cannot overwrite the new owner's result.
+ * - failures retry with backoff up to maxAttempts, then the job is 'failed' (the product emits
+ * its `<product>.schedule.failed` event from runDue's summary).
+ * The clock is injectable (`now`), so tests and replays are deterministic.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, newId, parseJson, assertEntityId } = require('./internal');
+
+const ACTIONS = ['publish', 'unpublish'];
+const STATUSES = ['pending', 'running', 'done', 'failed', 'cancelled'];
+
+function shape(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ key: row.idem_key,
+ entityId: row.entity_id,
+ action: row.action,
+ revision: row.revision,
+ runAt: new Date(row.run_at).toISOString(),
+ status: row.status,
+ attempts: row.attempts,
+ leaseOwner: row.lease_owner,
+ leaseUntil: row.lease_until == null ? null : new Date(row.lease_until).toISOString(),
+ lastError: row.last_error,
+ result: parseJson(row.result, null),
+ createdAt: new Date(row.created_at).toISOString(),
+ updatedAt: new Date(row.updated_at).toISOString(),
+ };
+}
+
+function toMs(v, name) {
+ const t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : Date.parse(v);
+ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a date, ISO string or epoch milliseconds`);
+ return t;
+}
+
+function createScheduler(db, { prefix, now, leaseMs = 60000, maxAttempts = 5, backoffMs = [5000, 30000, 120000, 600000] } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ if (!(leaseMs > 0)) throw new TypeError('leaseMs must be positive');
+ const J = `${prefix}_schedule_jobs`;
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${J} (
+ id TEXT PRIMARY KEY,
+ idem_key TEXT NOT NULL UNIQUE,
+ entity_id TEXT NOT NULL,
+ action TEXT NOT NULL CHECK (action IN ('publish','unpublish')),
+ revision INTEGER,
+ run_at INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ lease_owner TEXT,
+ lease_until INTEGER,
+ last_error TEXT,
+ result TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS ${J}_due ON ${J} (status, run_at);
+ CREATE INDEX IF NOT EXISTS ${J}_entity ON ${J} (entity_id, run_at);
+ `);
+
+ const q = {
+ byId: db.prepare(`SELECT * FROM ${J} WHERE id = ?`),
+ byKey: db.prepare(`SELECT * FROM ${J} WHERE idem_key = ?`),
+ insert: db.prepare(`INSERT OR IGNORE INTO ${J} (id, idem_key, entity_id, action, revision, run_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+ due: db.prepare(`SELECT * FROM ${J} WHERE (status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now)
+ ORDER BY run_at, id LIMIT @limit`),
+ take: db.prepare(`UPDATE ${J} SET status = 'running', lease_owner = @worker, lease_until = @until, attempts = attempts + 1, updated_at = @now
+ WHERE id = @id AND ((status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now))`),
+ complete: db.prepare(`UPDATE ${J} SET status = 'done', result = @result, lease_owner = NULL, lease_until = NULL, last_error = NULL, updated_at = @now
+ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
+ retry: db.prepare(`UPDATE ${J} SET status = 'pending', run_at = @runAt, last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
+ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
+ dead: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
+ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
+ abandon: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = 'lease expired on every attempt', lease_owner = NULL, lease_until = NULL, updated_at = @now
+ WHERE id = @id AND status = 'running' AND lease_until <= @now`),
+ cancel: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending'`),
+ cancelEntity: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE entity_id = ? AND status = 'pending' AND (? IS NULL OR action = ?)`),
+ forEntity: db.prepare(`SELECT * FROM ${J} WHERE entity_id = ? ORDER BY run_at, id`),
+ };
+
+ function backoff(attempts) {
+ return backoffMs[Math.min(attempts - 1, backoffMs.length - 1)] || 0;
+ }
+
+ function claimDue(worker, limit) {
+ if (typeof worker !== 'string' || !worker) throw new TypeError('worker must be a non-empty string');
+ return db.transaction(() => {
+ const t = clock();
+ const rows = q.due.all({ now: t, limit: Math.max(1, Math.min(100, limit)) });
+ const jobs = [];
+ const abandoned = [];
+ for (const r of rows) {
+ // A job whose workers keep dying stops after maxAttempts instead of looping forever.
+ if (r.status === 'running' && r.attempts >= maxAttempts) {
+ if (q.abandon.run({ id: r.id, now: t }).changes === 1) abandoned.push(shape(q.byId.get(r.id)));
+ continue;
+ }
+ if (q.take.run({ id: r.id, worker, until: t + leaseMs, now: t }).changes === 1) jobs.push(shape(q.byId.get(r.id)));
+ }
+ return { jobs, abandoned };
+ }).immediate();
+ }
+
+ const api = {
+ table: J,
+ ACTIONS,
+ STATUSES,
+
+ /** Idempotent. Returns { job, created }. */
+ schedule({ entityId, action, runAt, revision = null, key } = {}) {
+ assertEntityId(entityId);
+ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);
+ const at = toMs(runAt, 'runAt');
+ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
+ const idem = key ? String(key) : `${entityId}:${action}:${at}:${revision == null ? '-' : revision}`;
+ const t = clock();
+ const info = q.insert.run(newId('job', t), idem, entityId, action, revision, at, t, t);
+ return { job: shape(q.byKey.get(idem)), created: info.changes === 1 };
+ },
+
+ get(id) { return shape(q.byId.get(String(id))); },
+
+ jobs(entityId) { return q.forEntity.all(assertEntityId(entityId)).map(shape); },
+
+ /** Cancel one pending job (a running job finishes or its lease expires; it cannot be cancelled mid-flight). */
+ cancel(id) { return q.cancel.run(clock(), String(id)).changes > 0; },
+
+ /** Cancel every pending job of an entity, optionally only one action. Returns the count. */
+ cancelPending(entityId, action = null) {
+ return q.cancelEntity.run(clock(), assertEntityId(entityId), action, action).changes;
+ },
+
+ /** Lease up to `limit` due jobs (pending and due, or running with an expired lease). */
+ claim({ worker, limit = 10 } = {}) { return claimDue(worker, limit).jobs; },
+
+ /** true when recorded; false when this worker no longer holds the lease. */
+ complete(id, worker, result = null) {
+ return q.complete.run({ id: String(id), worker, result: result == null ? null : JSON.stringify(result), now: clock() }).changes === 1;
+ },
+
+ /** Returns { status: 'retry' | 'failed' | 'lost' }. */
+ fail(id, worker, error) {
+ const row = q.byId.get(String(id));
+ if (!row || row.status !== 'running' || row.lease_owner !== worker) return { status: 'lost' };
+ const t = clock();
+ const msg = String(error && error.message ? error.message : error || 'failed').slice(0, 1000);
+ if (row.attempts >= maxAttempts) {
+ q.dead.run({ id: row.id, worker, error: msg, now: t });
+ return { status: 'failed', job: shape(q.byId.get(row.id)) };
+ }
+ q.retry.run({ id: row.id, worker, error: msg, runAt: t + backoff(row.attempts), now: t });
+ return { status: 'retry', job: shape(q.byId.get(row.id)) };
+ },
+
+ /**
+ * Claim and run due jobs one by one. handler(job) may be async; a throw counts as a failure.
+ * Returns { done: [job], retried: [job], failed: [job], lost: [job] }.
+ */
+ async runDue({ worker, handler, limit = 10 } = {}) {
+ if (typeof handler !== 'function') throw new TypeError('handler must be a function');
+ const { jobs, abandoned } = claimDue(worker, limit);
+ const summary = { done: [], retried: [], failed: [...abandoned], lost: [] };
+ for (const job of jobs) {
+ try {
+ const result = await handler(job);
+ if (api.complete(job.id, worker, result === undefined ? null : result)) summary.done.push(api.get(job.id));
+ else summary.lost.push(api.get(job.id));
+ } catch (err) {
+ const f = api.fail(job.id, worker, err);
+ if (f.status === 'failed') summary.failed.push(f.job);
+ else if (f.status === 'retry') summary.retried.push(f.job);
+ else summary.lost.push(api.get(job.id));
+ }
+ }
+ return summary;
+ },
+ };
+ return api;
+}
+
+module.exports = { createScheduler, ACTIONS, STATUSES, PublishingError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,570 @@
@@ -0,0 +1,570 @@
+'use strict';
+/**
+ * openvibe-publishing/seo — the deterministic indexability gate and the discovery artifacts that
+ * obey it: canonical URLs, history-aware redirects, meta/robots tags, sitemaps, RSS/Atom/JSON
+ * Feed, and JSON-LD built only from fields the product actually has.
+ *
+ * const seo = require('openvibe-publishing/seo');
+ * const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 3 },
+ * { policy: { minWords: 150, requireSources: true } });
+ * // → { indexable: true, listable: true, robots: 'index, follow', reasons: [], codes: [], canonical, gate }
+ * seo.metaTags({ title, description, decision }) // robots come from the decision, never a default
+ * seo.sitemap([{ loc, lastmod, decision }]) // only indexable entries
+ * seo.atomFeed(channel, [{ id, url, title, published, decision, … }]) // only listable items
+ * seo.structuredData.article({ headline, url, datePublished, authors }) // missing → omitted
+ *
+ * Generic primitives (head tag layout, sitemap XML, robots.txt, JSON-LD script escaping) are the
+ * network's single implementation in openvibe-shared/seo; this module adds the publication rules.
+ *
+ * THE GATE. evaluate(facts, { policy, now }) is a pure function: the same facts, policy and `now`
+ * always give the same decision, and every "no" names a stable reason code (REASONS, in evaluation
+ * order). Each reason has an effect:
+ * hidden — not indexable and not listable: never in search, sitemaps or feeds
+ * noindex — may be served and listed in feeds, but never indexed or put in a sitemap
+ * Unknown fact names throw (a typo must not silently pass the gate), and so do missing facts a
+ * rule needs — there is no default that makes content indexable.
+ */
+const sharedSeo = require('openvibe-shared/seo');
+const { assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
+const { wordCount } = require('./ssr');
+
+const GATE = 'openvibe-publishing/seo@1';
+
+const REASONS = Object.freeze([
+ { code: 'deleted', effect: 'hidden', description: 'The resource is deleted.' },
+ { code: 'takedown', effect: 'hidden', description: 'Removed by a moderation, legal or owner takedown.' },
+ { code: 'private', effect: 'hidden', description: 'Visibility is private.' },
+ { code: 'gated', effect: 'hidden', description: 'Only entitled members (e.g. VIP) may read it.' },
+ { code: 'unlisted', effect: 'hidden', description: 'Reachable by link only; never listed or indexed.' },
+ { code: 'draft', effect: 'hidden', description: 'Not published yet (draft or scheduled).' },
+ { code: 'unpublished', effect: 'hidden', description: 'Was published, has been unpublished.' },
+ { code: 'ai_generated_unreviewed', effect: 'hidden', description: 'AI-generated and no human review is recorded.' },
+ { code: 'stub_provider', effect: 'hidden', description: 'Produced while only the AI stub provider was available, and not reviewed.' },
+ { code: 'unreviewed_sensitive', effect: 'hidden', description: 'Sensitive or regulated category without a recorded review.' },
+ { code: 'retracted', effect: 'noindex', description: 'Retracted; kept visible with its correction, not indexed.' },
+ { code: 'expired', effect: 'noindex', description: 'Past its expiry time.' },
+ { code: 'stale_price', effect: 'noindex', description: 'A price is shown without a recent enough observation time.' },
+ { code: 'duplicate_of', effect: 'noindex', description: 'Duplicates another canonical page.' },
+ { code: 'thin', effect: 'noindex', description: 'Below the product\'s minimum amount of text.' },
+ { code: 'unsourced', effect: 'noindex', description: 'Fewer citations than the product requires.' },
+ { code: 'unsupported_claims', effect: 'noindex', description: 'Contains claims flagged as lacking a source.' },
+ { code: 'missing_canonical', effect: 'noindex', description: 'No canonical URL was provided.' },
+ { code: 'noindex_requested', effect: 'noindex', description: 'The author or an editor asked for noindex.' },
+]);
+const REASON_BY_CODE = Object.freeze(Object.fromEntries(REASONS.map((r) => [r.code, r])));
+
+const STATES = ['draft', 'scheduled', 'published', 'unpublished', 'deleted'];
+const VISIBILITIES = ['public', 'unlisted', 'private', 'gated'];
+const FACT_KEYS = new Set([
+ 'state', 'visibility', 'takedown', 'canonicalUrl', 'duplicateOf', 'text', 'wordCount', 'citationCount',
+ 'unsupportedClaims', 'authorship', 'stubProvider', 'sensitive', 'category', 'sensitiveReviewed',
+ 'expiresAt', 'price', 'retracted', 'noindex',
+]);
+const DEFAULT_POLICY = Object.freeze({
+ minWords: 150,
+ requireSources: false,
+ minSources: 1,
+ priceMaxAgeMs: 24 * 60 * 60 * 1000,
+ sensitiveCategories: [],
+});
+
+function timeOf(value, name) {
+ const t = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);
+ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a valid date`);
+ return t;
+}
+
+/**
+ * The indexability decision. facts (see FACT_KEYS):
+ * state (required) draft | scheduled | published | unpublished | deleted
+ * visibility (required) public | unlisted | private | gated
+ * canonicalUrl, duplicateOf (URL of the original this duplicates)
+ * text or wordCount (required when policy.minWords > 0)
+ * citationCount (required when policy.requireSources)
+ * unsupportedClaims number of claims flagged unsourced
+ * authorship { mode: human|ai|hybrid|imported, reviewed: bool } (authorship.gateFacts)
+ * stubProvider, sensitive, category, sensitiveReviewed, takedown, retracted, noindex (booleans/strings)
+ * expiresAt instant; price { observedAt } — both need options.now
+ */
+function evaluate(facts, { policy = {}, now } = {}) {
+ if (!facts || typeof facts !== 'object') throw new TypeError('facts must be an object');
+ for (const k of Object.keys(facts)) if (!FACT_KEYS.has(k)) throw new TypeError(`unknown fact "${k}"`);
+ const p = { ...DEFAULT_POLICY, ...policy };
+ if (!STATES.includes(facts.state)) throw new TypeError(`facts.state must be one of ${STATES.join(', ')}`);
+ if (!VISIBILITIES.includes(facts.visibility)) throw new TypeError(`facts.visibility must be one of ${VISIBILITIES.join(', ')}`);
+ const nowMs = now == null ? null : timeOf(now, 'now');
+ const needNow = (what) => { if (nowMs == null) throw new TypeError(`${what} needs options.now (the gate has no hidden clock)`); return nowMs; };
+
+ const found = new Map();
+ const hit = (code, detail = null) => { if (!found.has(code)) found.set(code, detail); };
+
+ if (facts.state === 'deleted') hit('deleted');
+ if (facts.takedown) hit('takedown');
+ if (facts.visibility === 'private') hit('private');
+ if (facts.visibility === 'gated') hit('gated');
+ if (facts.visibility === 'unlisted') hit('unlisted');
+ if (facts.state === 'draft' || facts.state === 'scheduled') hit('draft', facts.state);
+ if (facts.state === 'unpublished') hit('unpublished');
+
+ const a = facts.authorship;
+ if (a != null) {
+ if (typeof a !== 'object' || !a.mode) throw new TypeError('facts.authorship must be { mode, reviewed }');
+ if (a.mode === 'ai' && !a.reviewed) hit('ai_generated_unreviewed');
+ }
+ const reviewed = Boolean(a && a.reviewed);
+ if (facts.stubProvider && !reviewed) hit('stub_provider');
+
+ const sensitive = Boolean(facts.sensitive) || (facts.category != null && p.sensitiveCategories.includes(facts.category));
+ if (sensitive && !facts.sensitiveReviewed) hit('unreviewed_sensitive', facts.category != null ? String(facts.category) : null);
+
+ if (facts.retracted) hit('retracted');
+ if (facts.expiresAt != null && timeOf(facts.expiresAt, 'expiresAt') <= needNow('expiresAt')) hit('expired', new Date(timeOf(facts.expiresAt, 'expiresAt')).toISOString());
+ if (facts.price != null) {
+ const observed = facts.price && facts.price.observedAt;
+ if (observed == null || observed === '') hit('stale_price', 'price has no observation time');
+ else if (needNow('price.observedAt') - timeOf(observed, 'price.observedAt') > p.priceMaxAgeMs) hit('stale_price', `observed ${new Date(timeOf(observed, 'price.observedAt')).toISOString()}`);
+ }
+ if (facts.duplicateOf && facts.duplicateOf !== facts.canonicalUrl) hit('duplicate_of', String(facts.duplicateOf));
+
+ if (p.minWords > 0) {
+ let words;
+ if (Number.isInteger(facts.wordCount)) words = facts.wordCount;
+ else if (typeof facts.text === 'string') words = wordCount(facts.text);
+ else throw new TypeError('facts.text or facts.wordCount is required when policy.minWords > 0');
+ if (words < p.minWords) hit('thin', `${words} of ${p.minWords} words`);
+ }
+ if (p.requireSources) {
+ if (!Number.isInteger(facts.citationCount)) throw new TypeError('facts.citationCount is required when policy.requireSources');
+ if (facts.citationCount < p.minSources) hit('unsourced', `${facts.citationCount} of ${p.minSources} sources`);
+ }
+ if (Number(facts.unsupportedClaims) > 0) hit('unsupported_claims', `${Number(facts.unsupportedClaims)} claims`);
+ if (!facts.canonicalUrl) hit('missing_canonical');
+ if (facts.noindex) hit('noindex_requested');
+
+ const reasons = REASONS.filter((r) => found.has(r.code)).map((r) => ({ code: r.code, effect: r.effect, detail: found.get(r.code) }));
+ const hidden = reasons.some((r) => r.effect === 'hidden');
+ const indexable = reasons.length === 0;
+ return {
+ gate: GATE,
+ indexable,
+ listable: !hidden,
+ robots: indexable ? 'index, follow' : hidden ? 'noindex, nofollow' : 'noindex, follow',
+ canonical: found.has('duplicate_of') ? String(facts.duplicateOf) : (facts.canonicalUrl || null),
+ reasons,
+ codes: reasons.map((r) => r.code),
+ };
+}
+
+function requireDecision(decision, where) {
+ if (!decision || typeof decision.indexable !== 'boolean' || typeof decision.robots !== 'string') {
+ throw new TypeError(`${where} needs the gate's decision (seo.evaluate): publication state, not rendering, decides indexability`);
+ }
+ return decision;
+}
+
+// ---- Canonical URLs and redirects --------------------------------------------------------------
+
+/**
+ * Absolute canonical URL: https/http origin + path segments (each encoded), no fragment, no
+ * trailing slash (except the root), and only whitelisted query parameters, sorted.
+ * canonicalUrl('https://openvibe.wiki', ['spaces', 'food', 'pages', 'Crème brûlée'])
+ * canonicalUrl('https://openvibe.blog', '/alex/posts/bread?page=2&utm_source=x', { query: ['page'] })
+ */
+function canonicalUrl(origin, path = '/', { query = [] } = {}) {
+ let base;
+ try { base = new URL(String(origin)); } catch { throw new TypeError('origin must be an absolute URL'); }
+ if (base.protocol !== 'https:' && base.protocol !== 'http:') throw new TypeError('origin must be http(s)');
+ let pathname;
+ let search = '';
+ 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);
+ 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));
+ if (keep.length) search = '?' + new URLSearchParams(keep).toString();
+ }
+ pathname = pathname.replace(/\/{2,}/g, '/');
+ if (pathname.length > 1) pathname = pathname.replace(/\/+$/, '');
+ return `${base.protocol}//${base.host.toLowerCase()}${pathname}${search}`;
+}
+
+function normalizePath(p) {
+ const s = String(p == null ? '' : p).split('#')[0].split('?')[0];
+ if (!s.startsWith('/') || s.startsWith('//')) throw new TypeError('paths must be site-relative and start with one "/"');
+ return s.length > 1 ? s.replace(/\/+$/, '') : s;
+}
+
+/**
+ * History-aware redirects in <prefix>_redirects: every path an entity ever had points at the
+ * entity, and resolve() answers with the entity's *current* path, so chains collapse into one 301.
+ */
+function createRedirectStore(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const R = `${prefix}_redirects`;
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${R} (
+ from_path TEXT PRIMARY KEY,
+ entity_id TEXT NOT NULL,
+ reason TEXT,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id);
+ `);
+ const q = {
+ put: db.prepare(`INSERT INTO ${R} (from_path, entity_id, reason, created_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (from_path) DO UPDATE SET entity_id = excluded.entity_id, reason = excluded.reason, created_at = excluded.created_at`),
+ del: db.prepare(`DELETE FROM ${R} WHERE from_path = ?`),
+ get: db.prepare(`SELECT * FROM ${R} WHERE from_path = ?`),
+ history: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY created_at, from_path`),
+ };
+ return {
+ table: R,
+ /** An entity moved from oldPath to newPath (slug rename, space move). */
+ recordMove(entityId, oldPath, newPath, { reason = 'renamed' } = {}) {
+ assertEntityId(entityId);
+ const from = normalizePath(oldPath);
+ const to = normalizePath(newPath);
+ db.transaction(() => {
+ q.del.run(to); // the new path is live: it must not redirect anywhere
+ if (from !== to) q.put.run(from, entityId, reason, clock());
+ })();
+ },
+ /** Claim a path for a (new) entity: removes any redirect that used it. */
+ release(path) { return q.del.run(normalizePath(path)).changes > 0; },
+ /**
+ * currentPath(entityId) → the entity's live path, or null when it is gone.
+ * → null (not a historical path) | { status: 301, location } | { status: 410 }
+ */
+ resolve(path, { currentPath } = {}) {
+ if (typeof currentPath !== 'function') throw new TypeError('currentPath(entityId) is required');
+ let from;
+ try { from = normalizePath(path); } catch { return null; }
+ const row = q.get.get(from);
+ if (!row) return null;
+ const target = currentPath(row.entity_id);
+ if (target == null) return { status: 410, entityId: row.entity_id };
+ const location = normalizePath(target);
+ if (location === from) return null;
+ return { status: 301, location, entityId: row.entity_id };
+ },
+ history(entityId) { return q.history.all(assertEntityId(entityId)).map((r) => ({ path: r.from_path, reason: r.reason, at: new Date(r.created_at).toISOString() })); },
+ };
+}
+
+// ---- Meta tags ---------------------------------------------------------------------------------
+
+/** <meta name="robots"> from the decision. */
+function robotsMeta(decision) {
+ return `<meta name="robots" content="${sharedSeo.esc(requireDecision(decision, 'robotsMeta').robots)}">`;
+}
+
+/** X-Robots-Tag header value for non-HTML representations (JSON, feeds of private collections). */
+function xRobotsTag(decision) { return requireDecision(decision, 'xRobotsTag').robots; }
+
+/**
+ * The page's <head> SEO block (openvibe-shared/seo headTags) with robots and canonical taken from
+ * the decision. Options are headTags' (title, description, image, type, siteName, jsonLd, …);
+ * the canonical defaults to decision.canonical (the original, for a duplicate).
+ */
+function metaTags({ decision, canonical, ...rest } = {}) {
+ requireDecision(decision, 'metaTags');
+ return sharedSeo.headTags({ ...rest, canonical: decision.canonical || canonical, robots: decision.robots });
+}
+
+// ---- Sitemaps ----------------------------------------------------------------------------------
+
+/**
+ * entries: [{ loc (absolute), lastmod? (a real revision time), decision, images? }]
+ * → { files: [xml], count, skipped: [{ loc, codes }] }. Only indexable entries are written;
+ * a missing lastmod is left out, never filled with "now". Split every maxUrls (50,000).
+ */
+function sitemap(entries = [], { maxUrls = 50000 } = {}) {
+ const keep = [];
+ const skipped = [];
+ for (const e of entries) {
+ if (!e || !e.loc) throw new TypeError('every sitemap entry needs loc');
+ const d = requireDecision(e.decision, 'sitemap entries');
+ if (!d.indexable || !/^https?:\/\//i.test(e.loc)) { skipped.push({ loc: e.loc, codes: d.codes || [] }); continue; }
+ const u = { loc: d.canonical || e.loc };
+ if (e.lastmod != null && e.lastmod !== '') u.lastmod = new Date(timeOf(e.lastmod, 'lastmod')).toISOString();
+ if (Array.isArray(e.images) && e.images.length) u.images = e.images;
+ keep.push(u);
+ }
+ const size = Math.max(1, Math.min(50000, Math.floor(maxUrls) || 50000));
+ const files = [];
+ for (let i = 0; i < keep.length; i += size) files.push(sharedSeo.sitemapXml(keep.slice(i, i + size)));
+ if (!files.length) files.push(sharedSeo.sitemapXml([]));
+ return { files, count: keep.length, skipped };
+}
+
+/** Sitemap index over per-section sitemaps: [{ loc, lastmod? }]. */
+function sitemapIndex(maps = []) { return sharedSeo.sitemapIndexXml(maps); }
+
+/** robots.txt (openvibe-shared/seo), with the sitemap locations declared. */
+function robotsTxt(opts = {}) { return sharedSeo.robotsTxt(opts); }
+
+// ---- Feeds -------------------------------------------------------------------------------------
+
+const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
+ // Characters XML 1.0 forbids.
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
+const isoOr = (v, name) => (v == null || v === '' ? null : new Date(timeOf(v, name)).toISOString());
+
+function feedItems(items) {
+ const out = [];
+ const skipped = [];
+ for (const it of items) {
+ if (!it || it.id == null || !it.url) throw new TypeError('every feed item needs a stable id and a canonical url');
+ const d = requireDecision(it.decision, 'feed items');
+ if (!d.listable) { skipped.push({ id: String(it.id), codes: d.codes }); continue; }
+ out.push({
+ id: String(it.id), url: it.url, title: it.title == null ? null : String(it.title),
+ summary: it.summary == null ? null : String(it.summary), contentHtml: it.contentHtml == null ? null : String(it.contentHtml),
+ published: isoOr(it.published, 'published'), updated: isoOr(it.updated, 'updated'),
+ authors: (it.authors || []).filter((a) => a && a.name).map((a) => ({ name: String(a.name), url: a.url || null })),
+ tags: (it.tags || []).filter((t) => t != null && t !== '').map(String),
+ image: it.image || null,
+ });
+ }
+ return { items: out, skipped };
+}
+
+function requireChannel(c, keys) {
+ for (const k of keys) if (!c || !c[k]) throw new TypeError(`feed channel needs ${k}`);
+}
+
+/** RSS 2.0. channel: { title, link, description, feedUrl?, language?, updated? }. */
+function rssFeed(channel, items = []) {
+ requireChannel(channel, ['title', 'link', 'description']);
+ const { items: list } = feedItems(items);
+ const out = ['<?xml version="1.0" encoding="UTF-8"?>',
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
+ '<channel>', `<title>${xml(channel.title)}</title>`, `<link>${xml(channel.link)}</link>`, `<description>${xml(channel.description)}</description>`];
+ if (channel.feedUrl) out.push(`<atom:link href="${xml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`);
+ if (channel.language) out.push(`<language>${xml(channel.language)}</language>`);
+ if (channel.updated) out.push(`<lastBuildDate>${new Date(timeOf(channel.updated, 'channel.updated')).toUTCString()}</lastBuildDate>`);
+ for (const it of list) {
+ const parts = [];
+ if (it.title) parts.push(`<title>${xml(it.title)}</title>`);
+ parts.push(`<link>${xml(it.url)}</link>`, `<guid isPermaLink="false">${xml(it.id)}</guid>`);
+ if (it.summary) parts.push(`<description>${xml(it.summary)}</description>`);
+ if (it.contentHtml) parts.push(`<content:encoded>${xml(it.contentHtml)}</content:encoded>`);
+ if (it.published) parts.push(`<pubDate>${new Date(it.published).toUTCString()}</pubDate>`);
+ for (const a of it.authors) parts.push(`<dc:creator>${xml(a.name)}</dc:creator>`);
+ for (const t of it.tags) parts.push(`<category>${xml(t)}</category>`);
+ out.push(`<item>${parts.join('')}</item>`);
+ }
+ out.push('</channel>', '</rss>', '');
+ return out.join('\n');
+}
+
+const atomId = (id) => (/^[a-z][a-z0-9+.-]*:/i.test(id) ? id : `urn:openvibe:${id}`);
+
+/**
+ * Atom 1.0. channel: { title, link, feedUrl, id?, updated?, author? { name, url }, subtitle? }.
+ * Entries with neither `updated` nor `published` are left out (Atom requires a date and this
+ * module will not invent one). The feed's <updated> is channel.updated or the newest entry.
+ */
+function atomFeed(channel, items = []) {
+ requireChannel(channel, ['title', 'link', 'feedUrl']);
+ const { items: list } = feedItems(items);
+ const dated = list.filter((it) => it.updated || it.published);
+ const newest = dated.map((it) => it.updated || it.published).sort().pop();
+ const updated = channel.updated ? new Date(timeOf(channel.updated, 'channel.updated')).toISOString() : newest;
+ if (!updated) throw new TypeError('an Atom feed needs channel.updated or at least one dated entry');
+ const out = ['<?xml version="1.0" encoding="UTF-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">',
+ `<id>${xml(atomId(channel.id || channel.feedUrl))}</id>`, `<title>${xml(channel.title)}</title>`,
+ `<link rel="alternate" type="text/html" href="${xml(channel.link)}"/>`, `<link rel="self" type="application/atom+xml" href="${xml(channel.feedUrl)}"/>`,
+ `<updated>${updated}</updated>`];
+ if (channel.subtitle) out.push(`<subtitle>${xml(channel.subtitle)}</subtitle>`);
+ if (channel.author && channel.author.name) out.push(`<author><name>${xml(channel.author.name)}</name>${channel.author.url ? `<uri>${xml(channel.author.url)}</uri>` : ''}</author>`);
+ for (const it of dated) {
+ const parts = [`<id>${xml(atomId(it.id))}</id>`, `<title>${xml(it.title || '')}</title>`, `<link rel="alternate" type="text/html" href="${xml(it.url)}"/>`,
+ `<updated>${it.updated || it.published}</updated>`];
+ if (it.published) parts.push(`<published>${it.published}</published>`);
+ for (const a of it.authors) parts.push(`<author><name>${xml(a.name)}</name>${a.url ? `<uri>${xml(a.url)}</uri>` : ''}</author>`);
+ for (const t of it.tags) parts.push(`<category term="${xml(t)}"/>`);
+ if (it.summary) parts.push(`<summary>${xml(it.summary)}</summary>`);
+ if (it.contentHtml) parts.push(`<content type="html">${xml(it.contentHtml)}</content>`);
+ out.push(`<entry>${parts.join('')}</entry>`);
+ }
+ out.push('</feed>', '');
+ return out.join('\n');
+}
+
+/** JSON Feed 1.1 (as an object; JSON.stringify it). channel: { title, link, feedUrl, description?, language?, authors? }. */
+function jsonFeed(channel, items = []) {
+ requireChannel(channel, ['title', 'link', 'feedUrl']);
+ const { items: list } = feedItems(items);
+ const feed = { version: 'https://jsonfeed.org/version/1.1', title: String(channel.title), home_page_url: channel.link, feed_url: channel.feedUrl };
+ if (channel.description) feed.description = String(channel.description);
+ if (channel.language) feed.language = String(channel.language);
+ const chAuthors = (channel.authors || []).filter((a) => a && a.name).map((a) => compact({ name: a.name, url: a.url }));
+ if (chAuthors.length) feed.authors = chAuthors;
+ feed.items = list.map((it) => compact({
+ id: it.id, url: it.url, title: it.title,
+ content_html: it.contentHtml || undefined,
+ content_text: it.contentHtml ? undefined : (it.summary || it.title || ''),
+ summary: it.summary, image: it.image,
+ date_published: it.published, date_modified: it.updated,
+ authors: it.authors.length ? it.authors.map((a) => compact({ name: a.name, url: a.url })) : undefined,
+ tags: it.tags.length ? it.tags : undefined,
+ }, { keepEmptyString: ['content_text'] }));
+ return feed;
+}
+
+/** <link rel="alternate"> tags that advertise feeds: [{ type: 'rss'|'atom'|'json', href, title }]. */
+function feedLinks(feeds = []) {
+ const types = { rss: 'application/rss+xml', atom: 'application/atom+xml', json: 'application/feed+json' };
+ return feeds.filter((f) => f && f.href && types[f.type])
+ .map((f) => `<link rel="alternate" type="${types[f.type]}" href="${sharedSeo.esc(f.href)}"${f.title ? ` title="${sharedSeo.esc(f.title)}"` : ''}>`).join('\n');
+}
+
+// ---- Structured data ---------------------------------------------------------------------------
+
+/**
+ * Recursively drops null / undefined / '' / NaN / empty arrays / empty objects, and objects whose
+ * only keys are '@type' / '@context' / '@id'. Nothing is ever added.
+ */
+function compact(value, { keepEmptyString = [] } = {}) {
+ const walk = (v, key) => {
+ if (v == null) return undefined;
+ if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;
+ if (typeof v === 'string') return v === '' && !keepEmptyString.includes(key) ? undefined : v;
+ if (Array.isArray(v)) { const a = v.map((x) => walk(x, key)).filter((x) => x !== undefined); return a.length ? a : undefined; }
+ if (typeof v === 'object') {
+ const o = {};
+ for (const [k, x] of Object.entries(v)) { const w = walk(x, k); if (w !== undefined) o[k] = w; }
+ const meaningful = Object.keys(o).filter((k) => k !== '@type' && k !== '@context' && k !== '@id');
+ return meaningful.length ? o : undefined;
+ }
+ return v;
+ };
+ return walk(value, null);
+}
+
+const iso = (v, name) => (v == null || v === '' ? undefined : new Date(timeOf(v, name)).toISOString());
+const num = (v) => (v == null || v === '' ? undefined : (Number.isFinite(Number(v)) ? Number(v) : undefined));
+const CTX = 'https://schema.org';
+
+function person(a) {
+ if (!a) return undefined;
+ if (typeof a === 'string') return { '@type': 'Person', name: a };
+ return { '@type': a.type === 'Organization' ? 'Organization' : 'Person', name: a.name, url: a.url };
+}
+
+function org(o) {
+ if (!o) return undefined;
+ return { '@type': 'Organization', name: o.name, url: o.url, logo: o.logo ? { '@type': 'ImageObject', url: o.logo } : undefined };
+}
+
+/** schema.org Rating, or undefined unless a numeric value is provided. No default bestRating. */
+function rating(r) {
+ if (!r || num(r.value) === undefined) return undefined;
+ return { '@type': 'Rating', ratingValue: num(r.value), bestRating: num(r.best), worstRating: num(r.worst) };
+}
+
+/** AggregateRating only with a numeric value AND a positive count of ratings or reviews. */
+function aggregateRating(r) {
+ if (!r || num(r.value) === undefined) return undefined;
+ const ratingCount = Number.isInteger(r.count) && r.count > 0 ? r.count : undefined;
+ const reviewCount = Number.isInteger(r.reviewCount) && r.reviewCount > 0 ? r.reviewCount : undefined;
+ if (ratingCount === undefined && reviewCount === undefined) return undefined;
+ return { '@type': 'AggregateRating', ratingValue: num(r.value), ratingCount, reviewCount, bestRating: num(r.best), worstRating: num(r.worst) };
+}
+
+const AVAILABILITY = {
+ in_stock: 'https://schema.org/InStock', out_of_stock: 'https://schema.org/OutOfStock', preorder: 'https://schema.org/PreOrder',
+ discontinued: 'https://schema.org/Discontinued', limited: 'https://schema.org/LimitedAvailability', sold_out: 'https://schema.org/SoldOut',
+ online_only: 'https://schema.org/OnlineOnly', in_store_only: 'https://schema.org/InStoreOnly',
+};
+const CONDITION = { new: 'https://schema.org/NewCondition', used: 'https://schema.org/UsedCondition', refurbished: 'https://schema.org/RefurbishedCondition', damaged: 'https://schema.org/DamagedCondition' };
+
+/** Offer only with a numeric price and a 3-letter currency. Availability only from a known value. */
+function offer(o) {
+ if (!o || num(o.price) === undefined || !/^[A-Z]{3}$/.test(String(o.priceCurrency || ''))) return undefined;
+ return {
+ '@type': 'Offer', price: num(o.price), priceCurrency: o.priceCurrency, url: o.url,
+ availability: AVAILABILITY[o.availability], itemCondition: CONDITION[o.condition],
+ priceValidUntil: o.priceValidUntil ? iso(o.priceValidUntil, 'priceValidUntil').slice(0, 10) : undefined,
+ validFrom: iso(o.validFrom, 'validFrom'), validThrough: iso(o.validThrough, 'validThrough'),
+ seller: o.seller ? org(o.seller) : undefined,
+ };
+}
+
+function citationList(list) {
+ return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));
+}
+
+const structuredData = {
+ /**
+ * Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.
+ * { type, headline, url, description, datePublished, dateModified, authors: [ {name,url,type} | 'Name' ],
+ * publisher: { name, url, logo }, image, section, keywords[], inLanguage, citations: [{ url, title }], wordCount }
+ */
+ article(a = {}) {
+ if (!a.headline || !a.url) return null;
+ const type = ['Article', 'BlogPosting', 'NewsArticle'].includes(a.type) ? a.type : 'Article';
+ return compact({
+ '@context': CTX, '@type': type, '@id': `${a.url}#article`, headline: sharedSeo.clip(a.headline, 110), url: a.url,
+ mainEntityOfPage: a.url, description: a.description, datePublished: iso(a.datePublished, 'datePublished'),
+ dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),
+ image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,
+ inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,
+ });
+ },
+
+ /** A single review. Needs the reviewed item's name; the rating appears only if provided. */
+ review(r = {}) {
+ if (!r.itemReviewed || !r.itemReviewed.name) return null;
+ return compact({
+ '@context': CTX, '@type': 'Review', url: r.url,
+ itemReviewed: { '@type': r.itemReviewed.type || 'Thing', name: r.itemReviewed.name, url: r.itemReviewed.url },
+ author: person(r.author), datePublished: iso(r.datePublished, 'datePublished'), reviewBody: r.body,
+ reviewRating: rating(r.rating), publisher: org(r.publisher),
+ });
+ },
+
+ /** Product with offers / aggregateRating only where the data exists. Missing name → null. */
+ product(p = {}) {
+ if (!p.name) return null;
+ return compact({
+ '@context': CTX, '@type': 'Product', name: p.name, url: p.url, description: p.description, image: p.image,
+ brand: p.brand ? { '@type': 'Brand', name: p.brand } : undefined, sku: p.sku, gtin: p.gtin, mpn: p.mpn,
+ offers: (p.offers || []).map(offer), aggregateRating: aggregateRating(p.aggregateRating),
+ });
+ },
+
+ /** An entity with an aggregate rating (Reviews). Missing name → null; missing counts → no rating. */
+ ratedThing(t = {}) {
+ if (!t.name) return null;
+ return compact({ '@context': CTX, '@type': t.type || 'Thing', name: t.name, url: t.url, description: t.description, aggregateRating: aggregateRating(t.aggregateRating) });
+ },
+
+ breadcrumbs(items = []) {
+ const list = items.filter((it) => it && it.name);
+ if (!list.length) return null;
+ return compact({ '@context': CTX, '@type': 'BreadcrumbList', itemListElement: list.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) });
+ },
+
+ webPage(w = {}) {
+ if (!w.url) return null;
+ return compact({ '@context': CTX, '@type': w.type || 'WebPage', '@id': `${w.url}#page`, url: w.url, name: w.name, description: w.description, dateModified: iso(w.dateModified, 'dateModified'), inLanguage: w.inLanguage });
+ },
+};
+
+/** <script type="application/ld+json"> (safe against </script> break-out); '' for null. */
+function jsonLdScript(obj) { return obj ? sharedSeo.jsonLdTag(obj) : ''; }
+
+module.exports = {
+ GATE, REASONS, REASON_BY_CODE, STATES, VISIBILITIES, DEFAULT_POLICY,
+ evaluate, canonicalUrl, createRedirectStore, normalizePath,
+ metaTags, robotsMeta, xRobotsTag,
+ sitemap, sitemapIndex, robotsTxt,
+ rssFeed, atomFeed, jsonFeed, feedLinks,
+ structuredData, jsonLdScript, compact,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,253 @@
@@ -0,0 +1,253 @@
+'use strict';
+/**
+ * openvibe-publishing/ssr — small helpers for server-rendered pages that are useful without JS.
+ *
+ * const ssr = require('openvibe-publishing/ssr');
+ * ssr.html`<h1>${title}</h1>${ssr.raw(ssr.renderMarkdown(body))}` // values escaped unless raw()
+ * ssr.paginate({ page: 2, perPage: 20, total: 95, href: (p) => `/tags/bread?page=${p}` })
+ * ssr.paginationHtml(pager) ssr.breadcrumbsHtml([{ name, url }, …])
+ * ssr.diffHtml(revs.diff(id, 1, 2, { mode: 'word' }).content)
+ * ssr.timeTag('2026-09-20T10:00:00Z') // '' when the date is unknown — never a guessed date
+ *
+ * Markdown safety model: the source is never trusted as HTML. Text is escaped before any tag is
+ * written, and the only tags produced are this file's own (p, br, h2–h6, blockquote, ul/ol/li, hr,
+ * pre/code, strong, em, del, a). Links keep http(s), mailto, same-site paths and #fragments only.
+ * No images (media goes through openvibe-publishing/media by object id), tables or raw HTML.
+ * The Markdown renderer follows the approach of OpenVibe.Community's server/render/markdown.js (MIT),
+ * without the syntax highlighter, with configurable link rel and heading level.
+ */
+
+const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+function escapeHtml(value) {
+ return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ESC[c]);
+}
+const escapeAttr = escapeHtml;
+
+class Raw {
+ constructor(s) { this.html = String(s == null ? '' : s); }
+ toString() { return this.html; }
+}
+/** Mark trusted HTML (output of this module, or your own template) so html`` does not escape it. */
+function raw(s) { return s instanceof Raw ? s : new Raw(s); }
+
+function interp(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.html;
+ if (Array.isArray(v)) return v.map(interp).join('');
+ return escapeHtml(v);
+}
+
+/** Tagged template: every interpolated value is escaped unless wrapped in raw(). Returns a Raw. */
+function html(strings, ...values) {
+ let out = strings[0];
+ for (let i = 0; i < values.length; i++) out += interp(values[i]) + strings[i + 1];
+ return new Raw(out);
+}
+
+// ---- Markdown ----------------------------------------------------------------------------------
+
+const MAX_SOURCE = 200000;
+const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})\s*$/;
+const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
+const QUOTE = /^ {0,3}>\s?(.*)$/;
+const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
+const UL = /^ {0,3}[-*+]\s+(.*)$/;
+const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;
+const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;
+
+function decodeBasic(s) {
+ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
+}
+
+function emphasis(s) {
+ return s
+ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
+ .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')
+ .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
+ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
+}
+
+function inline(src, opts) {
+ const held = [];
+ const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;
+ const relAttr = opts.rel ? ` rel="${escapeHtml(opts.rel)}"` : '';
+ let s = String(src).replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, (_m, _t, code) => hold(`<code>${escapeHtml(code)}</code>`));
+ s = escapeHtml(s);
+ s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
+ const href = decodeBasic(url);
+ if (!SAFE_URL.test(href)) return m;
+ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${emphasis(text)}</a>`);
+ });
+ s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {
+ let url = m;
+ let tail = '';
+ for (;;) {
+ const ent = url.match(/&(quot|#39|gt|lt);$/);
+ if (ent) { tail = ent[0] + tail; url = url.slice(0, -ent[0].length); continue; }
+ if (/[.,;:!?)\]'"]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); continue; }
+ break;
+ }
+ if (url.length <= 8) return m;
+ const href = decodeBasic(url);
+ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${escapeHtml(href)}</a>`) + tail;
+ });
+ s = emphasis(s).replace(/\n/g, '<br>\n');
+ for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => held[Number(n)] || '');
+ return s;
+}
+
+function startsBlock(line) {
+ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
+}
+
+function blocks(lines, depth, opts) {
+ const out = [];
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ if (!line.trim()) { i++; continue; }
+ let m = line.match(FENCE);
+ if (m) {
+ const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);
+ const body = [];
+ i++;
+ while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
+ i++;
+ const lang = m[2] ? ` class="language-${escapeHtml(m[2].toLowerCase())}"` : '';
+ out.push(`<pre><code${lang}>${escapeHtml(body.join('\n'))}</code></pre>`);
+ continue;
+ }
+ if (HR.test(line)) { out.push('<hr>'); i++; continue; }
+ m = line.match(HEADING);
+ if (m) {
+ const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);
+ out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);
+ i++;
+ continue;
+ }
+ if (QUOTE.test(line)) {
+ const inner = [];
+ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
+ out.push(depth < 3 ? `<blockquote>${blocks(inner, depth + 1, opts)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'), opts)}</p></blockquote>`);
+ continue;
+ }
+ if (UL.test(line) || OL.test(line)) {
+ const ordered = !UL.test(line);
+ const re = ordered ? OL : UL;
+ const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
+ const items = [];
+ while (i < lines.length && lines[i].trim()) {
+ const mm = lines[i].match(re);
+ if (mm) items.push(ordered ? mm[2] : mm[1]);
+ else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
+ else break;
+ i++;
+ }
+ const tag = ordered ? 'ol' : 'ul';
+ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it, opts)}</li>`).join('')}</${tag}>`);
+ continue;
+ }
+ const para = [];
+ while (i < lines.length && lines[i].trim() && (!para.length || !startsBlock(lines[i]))) para.push(lines[i++]);
+ out.push(`<p>${inline(para.join('\n'), opts)}</p>`);
+ }
+ return out.join('\n');
+}
+
+/**
+ * Markdown → safe HTML. Options: rel (link rel, default 'nofollow ugc noopener' — pass 'noopener'
+ * for editorial text you vouch for), headingShift (default 1: '#' becomes <h2>, the page owns <h1>).
+ */
+function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift = 1 } = {}) {
+ const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
+ return blocks(text.split('\n'), 0, { rel, headingShift });
+}
+
+/** Markdown → plain text for descriptions, feeds and index documents (the caller escapes it). */
+function markdownToText(source, max = Infinity) {
+ const t = String(source == null ? '' : source).replace(/\u0000/g, '')
+ .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
+ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1')
+ .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
+ .replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')
+ .replace(/(\*\*|__|~~|`)/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return t.length > max ? `${t.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : t;
+}
+
+/** Words in a text (Unicode letters/numbers runs) — the SEO gate's thin-content measure. */
+function wordCount(text) {
+ const m = String(text == null ? '' : text).match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu);
+ return m ? m.length : 0;
+}
+
+// ---- Pagination, breadcrumbs, diff, time -------------------------------------------------------
+
+/**
+ * Server pagination. href(page) builds each page's URL. Out-of-range pages are clamped and
+ * reported (`outOfRange: true`) so the caller can answer 404 instead of an empty page.
+ */
+function paginate({ page = 1, perPage = 20, total = 0, href } = {}) {
+ if (typeof href !== 'function') throw new TypeError('href(page) is required');
+ const per = Math.max(1, Math.min(500, Math.floor(Number(perPage)) || 20));
+ const count = Math.max(0, Math.floor(Number(total)) || 0);
+ const pages = Math.max(1, Math.ceil(count / per));
+ const asked = Math.floor(Number(page)) || 1;
+ const current = Math.min(Math.max(1, asked), pages);
+ const window = [];
+ for (let p = Math.max(1, current - 2); p <= Math.min(pages, current + 2); p++) window.push({ page: p, href: href(p), current: p === current });
+ return {
+ page: current, perPage: per, total: count, pages, offset: (current - 1) * per, limit: per,
+ outOfRange: asked !== current,
+ prev: current > 1 ? { page: current - 1, href: href(current - 1) } : null,
+ next: current < pages ? { page: current + 1, href: href(current + 1) } : null,
+ first: { page: 1, href: href(1) }, last: { page: pages, href: href(pages) },
+ window,
+ };
+}
+
+/** Plain links (rel=prev/next) that work without JavaScript. */
+function paginationHtml(p, { label = 'Pagination' } = {}) {
+ if (!p || p.pages <= 1) return '';
+ const parts = [];
+ if (p.prev) parts.push(`<a href="${escapeHtml(p.prev.href)}" rel="prev">← Newer</a>`);
+ if (p.window[0] && p.window[0].page > 1) parts.push(`<a href="${escapeHtml(p.first.href)}">1</a>`, p.window[0].page > 2 ? '<span aria-hidden="true">…</span>' : '');
+ for (const w of p.window) parts.push(w.current ? `<span aria-current="page">${w.page}</span>` : `<a href="${escapeHtml(w.href)}">${w.page}</a>`);
+ const lastShown = p.window[p.window.length - 1];
+ if (lastShown && lastShown.page < p.pages) parts.push(lastShown.page < p.pages - 1 ? '<span aria-hidden="true">…</span>' : '', `<a href="${escapeHtml(p.last.href)}">${p.pages}</a>`);
+ if (p.next) parts.push(`<a href="${escapeHtml(p.next.href)}" rel="next">Older →</a>`);
+ return `<nav class="ov-pagination" aria-label="${escapeHtml(label)}">${parts.filter(Boolean).join(' ')}</nav>`;
+}
+
+/** Breadcrumb trail: items [{ name, url }], the last one is the current page. */
+function breadcrumbsHtml(items = [], { label = 'Breadcrumb' } = {}) {
+ const list = items.filter((it) => it && it.name);
+ if (!list.length) return '';
+ const li = list.map((it, i) => (i === list.length - 1 || !it.url
+ ? `<li${i === list.length - 1 ? ' aria-current="page"' : ''}>${escapeHtml(it.name)}</li>`
+ : `<li><a href="${escapeHtml(it.url)}">${escapeHtml(it.name)}</a></li>`));
+ return `<nav class="ov-breadcrumbs" aria-label="${escapeHtml(label)}"><ol>${li.join('')}</ol></nav>`;
+}
+
+/** A diff (from revisions.diff(...).content or diffText) as <ins>/<del> markup inside <pre>. */
+function diffHtml(result) {
+ const ops = result && result.ops ? result.ops : [];
+ const body = ops.map((o) => (o.op === 'insert' ? `<ins>${escapeHtml(o.text)}</ins>` : o.op === 'delete' ? `<del>${escapeHtml(o.text)}</del>` : escapeHtml(o.text))).join('');
+ return `<pre class="ov-diff ov-diff-${result && result.mode === 'word' ? 'word' : 'line'}">${body}</pre>`;
+}
+
+/** <time> for a known instant; '' for an unknown one (the page must not guess a date). */
+function timeTag(value, { label } = {}) {
+ if (value == null || value === '') return '';
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return '';
+ const iso = d.toISOString();
+ return `<time datetime="${iso}">${escapeHtml(label || iso.slice(0, 10))}</time>`;
+}
+
+module.exports = {
+ escapeHtml, escapeAttr, html, raw, renderMarkdown, markdownToText, wordCount,
+ paginate, paginationHtml, breadcrumbsHtml, diffHtml, timeTag,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+'use strict';
+/**
+ * openvibe-publishing/taxonomy — tags, terms with slugs and hierarchical categories.
+ *
+ * const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
+ * const tax = createTaxonomy(db, { prefix: 'blog' }); // blog_terms, blog_term_links
+ * const cooking = tax.ensureTerm({ vocabulary: 'category', name: 'Cooking' });
+ * const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: cooking.id });
+ * tax.setTerms('post_1', 'tag', ['sourdough', 'Rye Bread']); // names or term ids
+ * tax.ancestors(bread.id); // [Cooking, Bread] — breadcrumbs
+ * tax.entitiesFor(cooking.id, { includeDescendants: true });
+ *
+ * Vocabularies are free-form names ('tag', 'category', 'series', …) chosen by the product. Slugs
+ * are unique per vocabulary. Hierarchy is a parent pointer; cycles are refused.
+ */
+const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
+
+const VOCAB_RE = /^[a-z][a-z0-9_]{0,39}$/;
+
+/** "Crème Brûlée & Co." → "creme-brulee-co". Throws when nothing sluggable remains. */
+function slugify(text, { max = 80 } = {}) {
+ const s = String(text == null ? '' : text)
+ .normalize('NFKD')
+ .replace(/[̀-ͯ]/g, '')
+ .toLowerCase()
+ .replace(/['’]/g, '')
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, max)
+ .replace(/-+$/g, '');
+ if (!s) throw new TypeError(`cannot make a slug from ${JSON.stringify(text)}`);
+ return s;
+}
+
+function shape(row) {
+ if (!row) return null;
+ return { id: row.id, vocabulary: row.vocabulary, slug: row.slug, name: row.name, parentId: row.parent_id, description: row.description };
+}
+
+function createTaxonomy(db, { prefix, now } = {}) {
+ assertDb(db);
+ assertPrefix(prefix);
+ const clock = clockOf(now);
+ const T = `${prefix}_terms`;
+ const L = `${prefix}_term_links`;
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ${T} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ vocabulary TEXT NOT NULL,
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ parent_id INTEGER REFERENCES ${T}(id),
+ description TEXT,
+ created_at INTEGER NOT NULL,
+ UNIQUE (vocabulary, slug)
+ );
+ CREATE INDEX IF NOT EXISTS ${T}_parent ON ${T} (parent_id);
+ CREATE TABLE IF NOT EXISTS ${L} (
+ entity_id TEXT NOT NULL,
+ term_id INTEGER NOT NULL REFERENCES ${T}(id),
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (entity_id, term_id)
+ );
+ CREATE INDEX IF NOT EXISTS ${L}_term ON ${L} (term_id);
+ `);
+
+ const q = {
+ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
+ bySlug: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? AND slug = ?`),
+ insert: db.prepare(`INSERT INTO ${T} (vocabulary, slug, name, parent_id, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`),
+ setParent: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE id = ?`),
+ rename: db.prepare(`UPDATE ${T} SET name = ? WHERE id = ?`),
+ children: db.prepare(`SELECT * FROM ${T} WHERE parent_id = ? ORDER BY name`),
+ vocab: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? ORDER BY name`),
+ ancestors: db.prepare(`WITH RECURSIVE up(id, parent_id, depth) AS (
+ SELECT id, parent_id, 0 FROM ${T} WHERE id = ?
+ UNION ALL SELECT t.id, t.parent_id, up.depth + 1 FROM ${T} t JOIN up ON t.id = up.parent_id WHERE up.depth < 64)
+ SELECT t.* FROM up JOIN ${T} t ON t.id = up.id ORDER BY up.depth DESC`),
+ descendants: db.prepare(`WITH RECURSIVE down(id, depth) AS (
+ SELECT id, 0 FROM ${T} WHERE id = ?
+ UNION ALL SELECT t.id, down.depth + 1 FROM ${T} t JOIN down ON t.parent_id = down.id WHERE down.depth < 64)
+ SELECT t.* FROM down JOIN ${T} t ON t.id = down.id WHERE down.depth > 0 ORDER BY t.name`),
+ linksFor: db.prepare(`SELECT t.*, l.position FROM ${L} l JOIN ${T} t ON t.id = l.term_id WHERE l.entity_id = ? ORDER BY t.vocabulary, l.position, t.name`),
+ unlinkVocab: db.prepare(`DELETE FROM ${L} WHERE entity_id = ? AND term_id IN (SELECT id FROM ${T} WHERE vocabulary = ?)`),
+ link: db.prepare(`INSERT OR IGNORE INTO ${L} (entity_id, term_id, position, created_at) VALUES (?, ?, ?, ?)`),
+ unlinkTerm: db.prepare(`DELETE FROM ${L} WHERE term_id = ?`),
+ reparentChildren: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE parent_id = ?`),
+ deleteTerm: db.prepare(`DELETE FROM ${T} WHERE id = ?`),
+ };
+
+ function vocabOf(v) {
+ if (typeof v !== 'string' || !VOCAB_RE.test(v)) throw new TypeError(`vocabulary must match ${VOCAB_RE}`);
+ return v;
+ }
+
+ function mustGet(id) {
+ const row = q.byId.get(Number(id));
+ if (!row) throw new PublishingError(404, 'term.not_found', `No term ${id}`);
+ return row;
+ }
+
+ function checkParent(termId, parentId, vocabulary) {
+ if (parentId == null) return null;
+ const parent = mustGet(parentId);
+ if (parent.vocabulary !== vocabulary) throw new PublishingError(400, 'term.parent_vocabulary', 'A parent must be in the same vocabulary');
+ if (termId != null) {
+ const chain = q.ancestors.all(parent.id).map((r) => r.id);
+ if (chain.includes(Number(termId))) throw new PublishingError(400, 'term.cycle', 'That parent would create a cycle');
+ }
+ return parent.id;
+ }
+
+ const api = {
+ tables: { terms: T, links: L },
+
+ get(id) { return shape(q.byId.get(Number(id))); },
+ bySlug(vocabulary, slug) { return shape(q.bySlug.get(vocabOf(vocabulary), String(slug))); },
+
+ /** Get-or-create by slug (derived from name unless given). The existing term is returned unchanged. */
+ ensureTerm({ vocabulary, name, slug, parentId = null, description = null } = {}) {
+ vocabOf(vocabulary);
+ const label = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 200);
+ if (!label) throw new TypeError('name is required');
+ const s = slug ? slugify(slug) : slugify(label);
+ return db.transaction(() => {
+ const existing = q.bySlug.get(vocabulary, s);
+ if (existing) return shape(existing);
+ const pid = checkParent(null, parentId, vocabulary);
+ const info = q.insert.run(vocabulary, s, label, pid, description, clock());
+ return shape(q.byId.get(info.lastInsertRowid));
+ })();
+ },
+
+ rename(id, name) {
+ mustGet(id);
+ const label = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 200);
+ if (!label) throw new TypeError('name is required');
+ q.rename.run(label, Number(id));
+ return api.get(id);
+ },
+
+ setParent(id, parentId) {
+ const term = mustGet(id);
+ if (parentId != null && Number(parentId) === term.id) throw new PublishingError(400, 'term.cycle', 'A term cannot be its own parent');
+ q.setParent.run(checkParent(term.id, parentId, term.vocabulary), term.id);
+ return api.get(id);
+ },
+
+ /** Root first, the term itself last: a breadcrumb trail. */
+ ancestors(id) { return q.ancestors.all(Number(id)).map(shape); },
+ descendants(id) { return q.descendants.all(Number(id)).map(shape); },
+ children(id) { return q.children.all(Number(id)).map(shape); },
+
+ /** Nested tree of a vocabulary: [{ ...term, children: [...] }]. */
+ tree(vocabulary) {
+ const rows = q.vocab.all(vocabOf(vocabulary)).map((r) => ({ ...shape(r), children: [] }));
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ const roots = [];
+ for (const r of rows) (r.parentId != null && byId.has(r.parentId) ? byId.get(r.parentId).children : roots).push(r);
+ return roots;
+ },
+
+ terms(vocabulary) { return q.vocab.all(vocabOf(vocabulary)).map(shape); },
+
+ /** Replace the entity's terms in one vocabulary. Items are term ids or names (created on demand). */
+ setTerms(entityId, vocabulary, items = []) {
+ assertEntityId(entityId);
+ vocabOf(vocabulary);
+ return db.transaction(() => {
+ q.unlinkVocab.run(entityId, vocabulary);
+ const t = clock();
+ const seen = new Set();
+ items.forEach((item, i) => {
+ const term = typeof item === 'number' ? mustGet(item) : q.bySlug.get(vocabulary, slugify(item)) || q.byId.get(api.ensureTerm({ vocabulary, name: item }).id);
+ if (term.vocabulary !== vocabulary) throw new PublishingError(400, 'term.vocabulary_mismatch', `Term ${term.id} is not a ${vocabulary}`);
+ if (seen.has(term.id)) return;
+ seen.add(term.id);
+ q.link.run(entityId, term.id, i, t);
+ });
+ return api.termsFor(entityId, vocabulary);
+ })();
+ },
+
+ termsFor(entityId, vocabulary) {
+ const rows = q.linksFor.all(assertEntityId(entityId));
+ return rows.filter((r) => !vocabulary || r.vocabulary === vocabulary).map(shape);
+ },
+
+ /** Entity ids tagged with the term (and, optionally, any of its descendants). */
+ entitiesFor(termId, { includeDescendants = false, limit = 1000 } = {}) {
+ const ids = [Number(termId), ...(includeDescendants ? q.descendants.all(Number(termId)).map((r) => r.id) : [])];
+ const marks = ids.map(() => '?').join(',');
+ return db.prepare(`SELECT DISTINCT entity_id FROM ${L} WHERE term_id IN (${marks}) ORDER BY entity_id LIMIT ?`)
+ .all(...ids, Math.max(1, Math.min(10000, limit))).map((r) => r.entity_id);
+ },
+
+ /** Remove a term: its children move up to its parent, its links are dropped. */
+ remove(id) {
+ const term = mustGet(id);
+ db.transaction(() => {
+ q.reparentChildren.run(term.parent_id, term.id);
+ q.unlinkTerm.run(term.id);
+ q.deleteTerm.run(term.id);
+ })();
+ return true;
+ },
+ };
+ return api;
+}
+
+module.exports = { createTaxonomy, slugify };
new file mode 100644
new file mode 100644
@@ -0,0 +1,726 @@
@@ -0,0 +1,726 @@
+{
+ "name": "openvibe-publishing",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-publishing",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ },
+ "devDependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "better-sqlite3": "^11.10.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=11"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "dev": true,
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.7.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",
+ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",
+ "dev": true,
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+{
+ "name": "openvibe-publishing",
+ "version": "0.1.0",
+ "description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
+ "main": "index.js",
+ "exports": {
+ ".": "./index.js",
+ "./package.json": "./package.json",
+ "./revisions": "./lib/revisions.js",
+ "./schedule": "./lib/schedule.js",
+ "./taxonomy": "./lib/taxonomy.js",
+ "./citations": "./lib/citations.js",
+ "./media": "./lib/media.js",
+ "./discussion": "./lib/discussion.js",
+ "./seo": "./lib/seo.js",
+ "./authorship": "./lib/authorship.js",
+ "./index-hooks": "./lib/index-hooks.js",
+ "./ssr": "./lib/ssr.js"
+ },
+ "files": [
+ "index.js",
+ "lib/",
+ "docs/",
+ "CHANGELOG.md"
+ ],
+ "scripts": {
+ "test": "node test/run.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/OpenVibers/OpenVibe.Publishing.git"
+ },
+ "homepage": "https://github.com/OpenVibers/OpenVibe.Publishing#readme",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=11"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "better-sqlite3": "^11.10.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+const assert = require('assert');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const authorship = require('../lib/authorship');
+const seo = require('../lib/seo');
+
+const { test, run } = suite();
+const USER = 'usr_01J8Z6Q3KX0000000000000000';
+const WF = { id: 'wiki.generate_page', version: 3, runId: 'run_01J8Z6Q3KX0000000000000000', model: 'stub' };
+const facts = (extra) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/x', wordCount: 500, ...extra });
+
+test('records: ai/hybrid need a workflow run, human cannot carry one', () => {
+ assert.deepStrictEqual(authorship.record({ mode: 'human', authors: [USER] }), { mode: 'human', authors: [USER] });
+ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });
+ assert.deepStrictEqual(ai.workflow, WF);
+ assert.throws(() => authorship.record({ mode: 'ai' }), (e) => e.code === 'authorship.workflow_required');
+ assert.throws(() => authorship.record({ mode: 'hybrid', authors: [USER] }), (e) => e.code === 'authorship.workflow_required');
+ assert.throws(() => authorship.record({ mode: 'human', authors: [USER], workflow: WF }), (e) => e.code === 'authorship.workflow_on_human');
+ assert.throws(() => authorship.record({ mode: 'human' }), (e) => e.code === 'authorship.author_required');
+ assert.throws(() => authorship.record({ mode: 'imported' }), (e) => e.code === 'authorship.import_source_required');
+ assert.throws(() => authorship.record({ mode: 'robot' }), /mode/);
+ assert.throws(() => authorship.record({ mode: 'ai', workflow: { id: 'Bad Id', runId: 'r' } }), /workflow.id/);
+ assert.throws(() => authorship.record({ mode: 'human', authors: ['42'] }), /subject/);
+});
+
+test('AI-generated defaults to draft + noindex until a person reviews it', () => {
+ const db = openDb('auth');
+ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now: fakeClock() });
+ const rec = authorship.record({ mode: 'ai', workflow: WF });
+ assert.deepStrictEqual(authorship.initialState(rec), { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
+ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'ai_generated_unreviewed' });
+ const before = seo.evaluate(facts(authorship.gateFacts(rec, reviews.latest('pg', 1))));
+ assert.deepStrictEqual(before.codes, ['ai_generated_unreviewed']);
+
+ assert.throws(() => reviews.record({ entityId: 'pg', revision: 1, reviewer: 'svc:wiki', decision: 'approved' }), (e) => e.code === 'review.reviewer_not_person');
+ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'rejected', note: 'wrong facts' });
+ assert.strictEqual(authorship.canPublish(rec, reviews.latest('pg', 1)).ok, false);
+ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'approved' });
+ const review = reviews.latest('pg', 1);
+ assert.strictEqual(authorship.canPublish(rec, review).ok, true);
+ assert.strictEqual(seo.evaluate(facts(authorship.gateFacts(rec, review))).indexable, true);
+ assert.strictEqual(reviews.latest('pg', 2), null, 'a review covers one revision only');
+ assert.strictEqual(reviews.history('pg').length, 2);
+ assert.throws(() => db.prepare("UPDATE wiki_page_reviews SET decision = 'approved'").run(), /immutable/);
+});
+
+test('stub-provider output is held like AI output', () => {
+ const rec = authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF, stubProvider: true });
+ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'stub_provider' });
+ assert.deepStrictEqual(seo.evaluate(facts(authorship.gateFacts(rec))).codes, ['stub_provider']);
+});
+
+test('human and hybrid content publishes without an AI review', () => {
+ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'human', authors: [USER] })).ok, true);
+ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).ok, true);
+});
+
+test('disclosure labels name the workflow and the source; human content has none', () => {
+ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'human', authors: [USER] })), null);
+ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });
+ assert.deepStrictEqual(authorship.disclosure(ai), {
+ mode: 'ai', short: 'AI-generated',
+ long: 'AI-generated from goosely\'s stream by workflow wiki.generate_page v3, not yet reviewed by a person.',
+ });
+ assert.match(authorship.disclosure(ai, { decision: 'approved', reviewer: USER }).long, /reviewed by a person\.$/);
+ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).short, 'Written with AI assistance');
+ const imp = authorship.record({ mode: 'imported', importedFrom: { label: 'the legacy Live pastes', originalAuthor: 'goosely' } });
+ assert.strictEqual(authorship.disclosure(imp).long, 'Imported from the legacy Live pastes; originally by goosely.');
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+const assert = require('assert');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const { createRevisionStore } = require('../lib/revisions');
+const { createCitationStore, gateFacts } = require('../lib/citations');
+
+const { test, run } = suite();
+
+function setup() {
+ const db = openDb('cite');
+ const now = fakeClock();
+ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
+ const cites = createCitationStore(db, { prefix: 'wiki', now, revisions });
+ revisions.create({ entityId: 'pg', expectedRevision: 0, content: 'Rye is a grass.' });
+ revisions.create({ entityId: 'pg', expectedRevision: 1, content: 'Rye is a grass grown as a grain.' });
+ revisions.create({ entityId: 'pg', expectedRevision: 2, content: 'Rye is a cereal.' });
+ return { db, now, revisions, cites };
+}
+
+test('a citation records source, retrieval time, quote span and license on one revision', () => {
+ const { cites } = setup();
+ const c = cites.attach({
+ entityId: 'pg', revision: 1, sourceItemId: 'src_01J8Z6Q3KX0000000000000001', url: 'https://example.org/rye',
+ title: 'Rye', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass', start: 0, end: 14 },
+ licenseNote: 'CC BY-SA 4.0', anchor: '1', attachedBy: 'usr_01J8Z6Q3KX0000000000000000',
+ });
+ assert.strictEqual(c.revision, 1);
+ assert.strictEqual(c.retrievedAt, '2026-09-20T10:00:00.000Z');
+ assert.deepStrictEqual(c.quote, { text: 'Rye is a grass', start: 0, end: 14 });
+ assert.strictEqual(c.licenseNote, 'CC BY-SA 4.0');
+ assert.strictEqual(cites.table, 'wiki_citations');
+});
+
+test('nothing is filled in: unknown retrieval time and title stay null', () => {
+ const { cites } = setup();
+ const c = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/x' });
+ assert.strictEqual(c.retrievedAt, null);
+ assert.strictEqual(c.title, null);
+ assert.strictEqual(c.licenseNote, null);
+ assert.strictEqual(c.quote, null);
+ assert.deepStrictEqual(gateFacts([c]), { citationCount: 1, datedCitationCount: 0 });
+});
+
+test('citations stay attached to the revision that used them after later revisions drop them', () => {
+ const { cites } = setup();
+ const a = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
+ const b = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/b' });
+ const carried = cites.carryForward({ entityId: 'pg', fromRevision: 1, toRevision: 2, ids: [a.id] });
+ assert.strictEqual(carried.length, 1);
+ assert.strictEqual(carried[0].carriedFrom, a.id);
+ // revision 3 reuses nothing
+ assert.deepStrictEqual(cites.forRevision('pg', 3), []);
+ assert.deepStrictEqual(cites.forRevision('pg', 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
+ assert.deepStrictEqual(cites.forRevision('pg', 2).map((c) => c.url), ['https://example.org/a']);
+ assert.strictEqual(cites.history('pg').length, 3);
+ assert.ok(cites.get(b.id));
+});
+
+test('citation rows cannot be updated or deleted outside an audited purge', () => {
+ const { db, cites } = setup();
+ cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
+ assert.throws(() => db.prepare("UPDATE wiki_citations SET url = 'https://evil.test'").run(), /immutable/);
+ assert.throws(() => db.prepare('DELETE FROM wiki_citations').run(), /never deleted/);
+ assert.deepStrictEqual(cites.purgeEntity('pg', { reason: 'legal' }), { deleted: 1 });
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' }), (e) => e.status === 410);
+});
+
+test('validation: a source is required, URLs are http(s), revisions must exist, spans are ordered', () => {
+ const { cites } = setup();
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1 }), (e) => e.code === 'citation.no_source');
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'javascript:alert(1)' }), /http/);
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 9, url: 'https://example.org' }), (e) => e.code === 'revision.not_found');
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', quote: { start: 5, end: 2 } }), /span/);
+ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', retrievedAt: 'yesterday' }), /retrievedAt/);
+});
+
+test('bySourceItem finds every revision citing a source (correction propagation)', () => {
+ const { cites } = setup();
+ cites.attach({ entityId: 'pg', revision: 1, sourceItemId: 'src_A' });
+ cites.attach({ entityId: 'pg', revision: 3, sourceItemId: 'src_A' });
+ assert.deepStrictEqual(cites.bySourceItem('src_A').map((c) => c.revision), [1, 3]);
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+const assert = require('assert');
+const http = require('http');
+const contracts = require('openvibe-contracts');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const { createDiscussionClient, createDiscussionRefs, entityRef } = require('../lib/discussion');
+
+const { test, run } = suite();
+const USER = 'usr_01J8Z6Q3KX0000000000000000';
+
+/** A stand-in for OpenVibe.Community's POST /api/v1/comments/threads/resolve (get-or-create). */
+function fakeCommunity() {
+ const threads = new Map();
+ const requests = [];
+ let mode = 'ok';
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ requests.push({ method: req.method, url: req.url, headers: req.headers, body });
+ const send = (status, obj, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(JSON.stringify(obj)); };
+ if (mode === 'down') return send(503, { type: 'x', title: 'Service Unavailable', status: 503, code: 'service.unavailable', error: 'down' }, 'application/problem+json');
+ if (req.method !== 'POST' || req.url !== '/api/v1/comments/threads/resolve') return send(404, { code: 'route.not_found' });
+ if (req.headers.authorization !== 'Bearer svc-token') return send(401, { code: 'auth.invalid_token', error: 'no' });
+ const { ref } = JSON.parse(body);
+ if (!contracts.validate('common.entity-ref@1', ref).valid) return send(400, { code: 'ref.invalid' });
+ const key = `${ref.service}/${ref.type}/${ref.id}`;
+ if (threads.has(key)) return send(200, { thread: threads.get(key), created: false });
+ const thread = { id: `thr_${threads.size + 1}`, ref, visibility: 'public', comment_count: 0 };
+ threads.set(key, thread);
+ return send(201, { thread, created: true });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`, requests, setMode: (m) => { mode = m; }, close: () => server.close(),
+ })));
+}
+
+const tokenClient = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }), invalidated: 0, invalidate() { this.invalidated++; } };
+
+test('resolveThread: get-or-create over the Community contract, with service auth and trace headers', async () => {
+ const community = await fakeCommunity();
+ try {
+ const client = createDiscussionClient({ communityUrl: community.url + '/', tokenClient });
+ const ref = { service: 'wiki', type: 'page', id: 'pg_1', label: 'Rye' };
+ const first = await client.resolveThread(ref, { subject: USER, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', requestId: 'req-1' });
+ const second = await client.resolveThread(ref);
+ assert.deepStrictEqual(first, { threadId: 'thr_1', created: true, visibility: 'public' });
+ assert.strictEqual(second.threadId, 'thr_1');
+ assert.strictEqual(second.created, false);
+ const h = community.requests[0].headers;
+ assert.strictEqual(h['x-ov-subject'], USER);
+ assert.strictEqual(h.traceparent, '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01');
+ assert.strictEqual(h['x-openvibe-request-id'], 'req-1');
+ assert.strictEqual(community.requests[1].headers['x-ov-subject'], undefined, 'acts as the service when no subject');
+ } finally { community.close(); }
+});
+
+test('failures are errors, never a fabricated thread', async () => {
+ const community = await fakeCommunity();
+ try {
+ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });
+ community.setMode('down');
+ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 503 && e.code === 'service.unavailable');
+ const bad = createDiscussionClient({ communityUrl: community.url, tokenClient: { authHeaders: async () => ({ Authorization: 'Bearer wrong' }), invalidate() { this.hit = true; } } });
+ community.setMode('ok');
+ await assert.rejects(bad.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 401 && e.code === 'auth.invalid_token');
+ const nowhere = createDiscussionClient({ communityUrl: 'http://127.0.0.1:1', tokenClient, timeoutMs: 500 });
+ await assert.rejects(nowhere.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.code === 'discussion.unavailable');
+ await assert.rejects(client.resolveThread({ service: 'Blog!', type: 'post', id: 'p' }), /service/);
+ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }, { subject: '42' }), /subject/);
+ } finally { community.close(); }
+});
+
+test('the local table stores the reference only — no comment content columns — and resolves once', async () => {
+ const community = await fakeCommunity();
+ try {
+ const db = openDb('disc');
+ const refs = createDiscussionRefs(db, { prefix: 'wiki', now: fakeClock() });
+ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });
+ const ref = { service: 'wiki', type: 'page', id: 'pg_9' };
+ const a = await refs.threadFor('pg_9', ref, { client });
+ const b = await refs.threadFor('pg_9', ref, { client });
+ assert.strictEqual(a.cached, false);
+ assert.strictEqual(b.cached, true);
+ assert.strictEqual(a.threadId, b.threadId);
+ assert.strictEqual(community.requests.length, 1);
+ const cols = db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort();
+ assert.deepStrictEqual(cols, ['entity_id', 'ref', 'resolved_at', 'thread_id']);
+ assert.ok(contracts.validate('common.entity-ref@1', refs.get('pg_9').ref).valid);
+ } finally { community.close(); }
+});
+
+test('entityRef validation matches common.entity-ref@1', () => {
+ const r = entityRef({ service: 'news', type: 'story', id: 'st_1', label: 'x'.repeat(500), revision: 3 });
+ assert.strictEqual(r.label.length, 200);
+ assert.ok(contracts.validate('common.entity-ref@1', r).valid);
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/** A fresh on-disk SQLite database in a temp directory, removed at exit. */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const dirs = [];
+process.on('exit', () => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
+
+function tempDir(tag = 'pub') {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ovpub-${tag}-`));
+ dirs.push(dir);
+ return dir;
+}
+
+function openDb(tag) {
+ const db = new Database(path.join(tempDir(tag), 'test.db'));
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ return db;
+}
+
+/** A controllable clock. */
+function fakeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
+ let t = start;
+ const now = () => t;
+ now.advance = (ms) => { t += ms; return t; };
+ now.set = (ms) => { t = ms; return t; };
+ return now;
+}
+
+/** Tiny async-aware test runner: test(name, fn) then run(). */
+function suite() {
+ const tests = [];
+ return {
+ test(name, fn) { tests.push({ name, fn }); },
+ async run() {
+ let failed = 0;
+ for (const t of tests) {
+ try { await t.fn(); console.log(` ok ${t.name}`); } catch (err) { failed++; console.log(` FAIL ${t.name}\n${err && err.stack || err}`); }
+ }
+ console.log(`${tests.length - failed}/${tests.length} passed`);
+ if (failed) process.exit(1);
+ },
+ };
+}
+
+module.exports = { openDb, tempDir, fakeClock, suite };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const Ajv = require('ajv/dist/2020');
+const addFormats = require('ajv-formats');
+const contracts = require('openvibe-contracts');
+const hooks = require('../lib/index-hooks');
+const seo = require('../lib/seo');
+const { suite } = require('./helpers/db');
+
+const { test, run } = suite();
+const USER = 'usr_01J8Z6Q3KX0000000000000000';
+const ajv = new Ajv({ allErrors: true, strict: false });
+addFormats(ajv);
+const validateDoc = ajv.compile(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'contracts-proposal', 'search.index-document.v1.json'), 'utf8')));
+
+const facts = { state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', wordCount: 400, citationCount: 2 };
+const decision = seo.evaluate(facts, { policy: { requireSources: true } });
+const input = (extra = {}) => ({
+ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published', visibility: 'public',
+ canonicalUrl: 'https://openvibe.wiki/p/rye', title: 'Rye', summary: 'A cereal grass.', body: 'Rye is a grass grown as a grain.',
+ facets: { space: 'food', tags: ['grain', 'bread'] },
+ provenance: { authorship: { mode: 'human' }, citations: [{ sourceItemId: 'src_1', url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] },
+ decision, publishedAt: '2026-09-20T12:00:00Z', updatedAt: '2026-09-21T12:00:00Z', ...extra,
+});
+
+function withEventId(env) { return { ...env, event_id: contracts.ids.newId('event') }; }
+
+test('a published revision becomes a full index document with every §12.3 field', () => {
+ const doc = hooks.buildIndexDocument(input());
+ for (const k of ['owner_service', 'resource_type', 'resource_id', 'revision', 'visibility', 'acl', 'canonical_url', 'title', 'summary', 'body', 'facets', 'provenance', 'publication_state', 'deleted', 'indexability']) {
+ assert.ok(k in doc, `missing ${k}`);
+ }
+ assert.deepStrictEqual(doc.acl, { public: true });
+ assert.deepStrictEqual(doc.indexability, { indexable: true, listable: true, reasons: [], gate: 'openvibe-publishing/seo@1' });
+ assert.deepStrictEqual(doc.provenance.citations, [{ source_item_id: 'src_1', url: 'https://example.org/rye', retrieved_at: '2026-09-20T10:00:00.000Z' }]);
+ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));
+});
+
+test('deleted, unpublished, draft and unlisted resources become tombstones with no content', () => {
+ for (const extra of [{ deleted: true }, { state: 'unpublished' }, { state: 'draft' }, { visibility: 'unlisted' }]) {
+ const doc = hooks.buildIndexDocument(input(extra));
+ assert.strictEqual(doc.deleted, true, JSON.stringify(extra));
+ for (const k of ['title', 'summary', 'body', 'canonical_url', 'acl']) assert.ok(!(k in doc), `${k} leaked in a tombstone`);
+ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));
+ }
+});
+
+test('private content needs an ACL of subjects; gated content needs entitlements', () => {
+ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });
+ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv })), /acl.subjects/);
+ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: ['everyone'] } })), /acl.subjects/);
+ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));
+ assert.deepStrictEqual(doc.acl, { public: false, subjects: [USER] });
+ assert.strictEqual(doc.indexability.listable, false);
+ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv })), /entitlements/);
+ const vip = hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv, acl: { entitlements: ['vip.tier.gold'] } }));
+ assert.deepStrictEqual(vip.acl, { public: false, entitlements: ['vip.tier.gold'] });
+ assert.ok(validateDoc(vip));
+ assert.throws(() => hooks.buildIndexDocument(input({ decision: undefined })), /decision/);
+});
+
+test('actionFor maps state transitions to event actions', () => {
+ const pub = { state: 'published', visibility: 'public', revision: 1 };
+ assert.strictEqual(hooks.actionFor(null, pub), 'published');
+ assert.strictEqual(hooks.actionFor({ state: 'draft', revision: 1 }, pub), 'published');
+ assert.strictEqual(hooks.actionFor(pub, { ...pub, revision: 2 }), 'updated');
+ assert.strictEqual(hooks.actionFor(pub, { ...pub, visibility: 'private' }), 'updated');
+ assert.strictEqual(hooks.actionFor(pub, pub), null);
+ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'unpublished' }), 'unpublished');
+ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'deleted' }), 'deleted');
+ assert.strictEqual(hooks.actionFor({ state: 'deleted' }, { state: 'deleted' }), null);
+ assert.strictEqual(hooks.actionFor({ state: 'draft' }, { state: 'draft' }), null);
+});
+
+test('publication events are valid contracts envelopes: <product>.<type>.<action>', () => {
+ const doc = hooks.buildIndexDocument(input());
+ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: doc, decision, now: Date.parse('2026-09-22T12:00:00Z') });
+ assert.strictEqual(env.event_type, 'wiki.page.published');
+ assert.strictEqual(env.visibility, 'public');
+ assert.deepStrictEqual(env.actor, { type: 'user', id: USER });
+ assert.deepStrictEqual(env.subject, { type: 'page', id: 'pg_1', revision: 4 });
+ assert.strictEqual(env.payload.document, doc);
+ assert.ok(!('event_id' in env), 'the outbox assigns event_id');
+ const check = contracts.validate('events.event-envelope@1', withEventId(env));
+ assert.ok(check.valid, JSON.stringify(check.errors));
+
+ const tomb = hooks.buildIndexDocument(input({ deleted: true }));
+ const del = hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: 'svc:blog', document: tomb });
+ assert.strictEqual(del.event_type, 'blog.post.deleted');
+ assert.strictEqual(del.visibility, 'internal');
+ assert.deepStrictEqual(del.actor, { type: 'service', id: 'blog' });
+ assert.ok(contracts.validate('events.event-envelope@1', withEventId(del)).valid);
+ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: USER, document: doc }), /tombstone/);
+ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: tomb }), /published document/);
+ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'moved', id: 'pg_1', revision: 4, actor: USER, document: doc }), /action/);
+});
+
+test('events about private or non-listable content are internal', () => {
+ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });
+ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));
+ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: doc });
+ assert.strictEqual(env.visibility, 'internal');
+ const unlisted = hooks.buildIndexDocument(input({ visibility: 'unlisted' }));
+ const env2 = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: unlisted });
+ assert.strictEqual(env2.visibility, 'internal');
+ assert.ok(contracts.validate('events.event-envelope@1', withEventId(env2)).valid);
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+const assert = require('assert');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const { createAttachmentStore, mediaRef, figureHtml, isMediaId } = require('../lib/media');
+const contracts = require('openvibe-contracts');
+
+const { test, run } = suite();
+const MED = 'med_01J8Z6Q3KX0000000000000000';
+const MED2 = 'med_01J8Z6Q3KX0000000000000001';
+
+test('attachments reference Media ids and validate as Contracts MediaRef', () => {
+ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
+ const a = media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', alt: 'A loaf' });
+ assert.strictEqual(a.state, 'unverified');
+ assert.strictEqual(a.broken, false);
+ assert.ok(contracts.validate('media.media-ref@1', mediaRef(a)).valid);
+ assert.ok(isMediaId('legacy:live:vod:123'));
+ assert.throws(() => media.attach({ entityId: 'post_1', mediaId: 'https://cdn.example/x.png' }), (e) => e.code === 'media.invalid_id');
+ assert.strictEqual(media.table, 'blog_post_attachments');
+});
+
+test('a deleted Media object surfaces an explicit broken-asset state', () => {
+ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
+ media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', caption: 'Crumb' });
+ media.attach({ entityId: 'post_2', mediaId: MED });
+ media.attach({ entityId: 'post_2', mediaId: MED2 });
+ assert.deepStrictEqual(media.markBroken(MED, 'deleted'), ['post_1', 'post_2']);
+ const [att] = media.list('post_1');
+ assert.strictEqual(att.state, 'broken');
+ assert.strictEqual(att.brokenReason, 'deleted');
+ assert.ok(att.checkedAt);
+ assert.strictEqual(media.broken('post_2').length, 1);
+ const html = figureHtml(att, { urlFor: () => 'https://openvibe.media/o/x' });
+ assert.match(html, /data-state="broken"/);
+ assert.match(html, /no longer available/);
+ assert.doesNotMatch(html, /<img/, 'no image and no guessed URL for a broken object');
+ assert.match(html, /<figcaption>Crumb<\/figcaption>/);
+});
+
+test('verify(): 404 → broken, found → available, outage → unchanged (check_failed)', async () => {
+ const media = createAttachmentStore(openDb('media'), { prefix: 'wiki_page', now: fakeClock() });
+ media.attach({ entityId: 'pg', mediaId: MED });
+ media.attach({ entityId: 'pg', mediaId: MED2 });
+ const out = await media.verify('pg', { resolve: async (id) => (id === MED ? { exists: true } : { exists: false, reason: 'not_found' }) });
+ assert.deepStrictEqual(out.map((o) => o.outcome), ['available', 'broken']);
+ const outage = await media.verify('pg', { resolve: async () => { throw new Error('ECONNREFUSED'); } });
+ assert.deepStrictEqual(outage.map((o) => o.outcome), ['check_failed', 'check_failed']);
+ assert.deepStrictEqual(media.list('pg').map((a) => a.state), ['available', 'broken'], 'an outage changes nothing');
+ media.markAvailable(MED2);
+ assert.strictEqual(media.list('pg')[1].state, 'available');
+});
+
+test('figureHtml escapes alt/caption and needs a URL builder for available media', () => {
+ const att = { mediaId: MED, state: 'available', alt: '"><script>x</script>', caption: '<b>c</b>' };
+ const html = figureHtml(att, { urlFor: (id) => `https://openvibe.media/o/${id}` });
+ assert.doesNotMatch(html, /<script>/);
+ assert.match(html, /<b>c<\/b>/);
+ assert.throws(() => figureHtml(att), /urlFor/);
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+/** Package shape: every subpath export loads alone, the root is lazy, no server code needs a browser. */
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+const path = require('path');
+const pkg = require('../package.json');
+const { suite } = require('./helpers/db');
+
+const { test, run } = suite();
+const ROOT = path.join(__dirname, '..');
+const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json');
+
+test('every subpath export resolves to an existing module', () => {
+ assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
+ ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
+ for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
+});
+
+test('each module loads alone in a fresh process, without better-sqlite3 being required', () => {
+ for (const s of SUBPATHS) {
+ const code = `const Module = require('module'); const orig = Module._load;
+ Module._load = function (req, ...rest) { if (req === 'better-sqlite3') throw new Error('loaded better-sqlite3'); return orig.call(this, req, ...rest); };
+ require('openvibe-publishing/${s.slice(2)}'); console.log('ok');`;
+ const r = spawnSync(process.execPath, ['-e', code], { cwd: ROOT, encoding: 'utf8' });
+ assert.strictEqual(r.status, 0, `${s}: ${r.stderr}`);
+ }
+});
+
+test('the root export is lazy and exposes every module', () => {
+ const r = spawnSync(process.execPath, ['-e', `const p = require('openvibe-publishing');
+ const before = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;
+ const names = Object.keys(p); p.seo.evaluate; const after = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;
+ console.log(JSON.stringify({ names, before, after, v: p.version }));`], { cwd: ROOT, encoding: 'utf8' });
+ assert.strictEqual(r.status, 0, r.stderr);
+ const out = JSON.parse(r.stdout);
+ assert.strictEqual(out.before, 0);
+ assert.ok(out.after > 0);
+ assert.strictEqual(out.v, pkg.version);
+ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
+});
+
+test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {
+ const fs = require('fs');
+ for (const f of fs.readdirSync(path.join(ROOT, 'lib'))) {
+ const src = fs.readFileSync(path.join(ROOT, 'lib', f), 'utf8');
+ assert.doesNotMatch(src, /\.listen\(|new Database\(|process\.env|require\('better-sqlite3'\)/, `${f} must take the consumer's handle, not open its own`);
+ }
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+const assert = require('assert');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const { createRevisionStore, diffText } = require('../lib/revisions');
+const { formatLines } = require('../lib/diff');
+
+const { test, run } = suite();
+const USER = 'user:usr_01J8Z6Q3KX0000000000000000';
+
+test('create → edit → revert keeps an immutable lineage with parent pointers', () => {
+ const db = openDb('rev');
+ const revs = createRevisionStore(db, { prefix: 'wiki_page', now: fakeClock() });
+ const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Bread is baked.\n', fields: { title: 'Bread' }, author: USER }).revision;
+ const r2 = revs.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Bread is baked dough.\nIt is old.\n', fields: { title: 'Bread' }, author: USER }).revision;
+ assert.strictEqual(r1.number, 1);
+ assert.strictEqual(r1.parentId, null);
+ assert.strictEqual(r2.parentId, r1.id);
+ assert.strictEqual(r2.parentNumber, 1);
+ const r3 = revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: USER }).revision;
+ assert.strictEqual(r3.number, 3);
+ assert.strictEqual(r3.kind, 'revert');
+ assert.strictEqual(r3.revertedTo, 1);
+ assert.strictEqual(r3.parentId, r2.id);
+ assert.strictEqual(r3.content, r1.content);
+ assert.deepStrictEqual(revs.lineage('pg_1').map((r) => r.number), [3, 2, 1]);
+ assert.strictEqual(revs.get('pg_1', 2).content, 'Bread is baked dough.\nIt is old.\n', 'revert did not rewrite history');
+ assert.deepStrictEqual(Object.keys(revs.tables), ['revisions', 'drafts', 'purges']);
+ assert.strictEqual(revs.tables.revisions, 'wiki_page_revisions');
+});
+
+test('rows are immutable in SQLite itself (UPDATE and DELETE abort)', () => {
+ const db = openDb('rev');
+ const revs = createRevisionStore(db, { prefix: 'blog_post' });
+ revs.create({ entityId: 'p', expectedRevision: 0, content: 'x' });
+ assert.throws(() => db.prepare('UPDATE blog_post_revisions SET content = ?').run('y'), /immutable/);
+ assert.throws(() => db.prepare('DELETE FROM blog_post_revisions').run(), /never deleted/);
+ assert.strictEqual(revs.head('p').content, 'x');
+});
+
+test('optimistic concurrency: a stale expectedRevision is a 412 conflict', () => {
+ const db = openDb('rev');
+ const revs = createRevisionStore(db, { prefix: 'wiki_page' });
+ revs.create({ entityId: 'pg', expectedRevision: 0, content: 'a' });
+ revs.create({ entityId: 'pg', expectedRevision: 1, content: 'b' });
+ let err;
+ try { revs.create({ entityId: 'pg', expectedRevision: 1, content: 'c' }); } catch (e) { err = e; }
+ assert.ok(err, 'conflict thrown');
+ assert.strictEqual(err.status, 412);
+ assert.strictEqual(err.code, 'revision.conflict');
+ assert.strictEqual(err.expected, 1);
+ assert.strictEqual(err.current, 2);
+ assert.strictEqual(revs.headNumber('pg'), 2);
+ assert.throws(() => revs.create({ entityId: 'new', content: 'x' }), /expectedRevision/, 'expectedRevision is required');
+});
+
+test('a second connection on the same file with a stale base revision gets 412', () => {
+ const db = openDb('race');
+ const other = new (require('better-sqlite3'))(db.name);
+ const a = createRevisionStore(db, { prefix: 'wiki_page' });
+ const b = createRevisionStore(other, { prefix: 'wiki_page' });
+ a.create({ entityId: 'pg', expectedRevision: 0, content: 'base' });
+ a.create({ entityId: 'pg', expectedRevision: 1, content: 'from a' });
+ assert.throws(() => b.create({ entityId: 'pg', expectedRevision: 1, content: 'from b' }), (e) => e.status === 412);
+ other.close();
+});
+
+test('unchanged edits do not create empty revisions', () => {
+ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
+ revs.create({ entityId: 'e', expectedRevision: 0, content: 'same', fields: { a: 1, b: 2 } });
+ const again = revs.create({ entityId: 'e', expectedRevision: 1, content: 'same', fields: { b: 2, a: 1 } });
+ assert.strictEqual(again.created, false);
+ assert.strictEqual(revs.headNumber('e'), 1);
+});
+
+test('line and word diffs', () => {
+ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
+ revs.create({ entityId: 'e', expectedRevision: 0, content: 'one\ntwo\nthree\n', fields: { title: 'A' } });
+ revs.create({ entityId: 'e', expectedRevision: 1, content: 'one\n2\nthree\nfour\n', fields: { title: 'B', tag: 'x' } });
+ const d = revs.diff('e', 1, 2);
+ assert.strictEqual(d.content.mode, 'line');
+ assert.deepStrictEqual(d.content.ops, [
+ { op: 'equal', text: 'one\n' }, { op: 'delete', text: 'two\n' }, { op: 'insert', text: '2\n' },
+ { op: 'equal', text: 'three\n' }, { op: 'insert', text: 'four\n' },
+ ]);
+ assert.strictEqual(d.content.added, 2);
+ assert.strictEqual(d.content.removed, 1);
+ assert.deepStrictEqual(d.fields, [{ field: 'tag', from: null, to: 'x' }, { field: 'title', from: 'A', to: 'B' }]);
+ assert.strictEqual(formatLines(d.content), ' one\n- two\n+ 2\n three\n+ four');
+
+ const w = diffText('The quick brown fox', 'The slow brown fox jumps', { mode: 'word' });
+ const rebuiltOld = w.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join('');
+ const rebuiltNew = w.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join('');
+ assert.strictEqual(rebuiltOld, 'The quick brown fox');
+ assert.strictEqual(rebuiltNew, 'The slow brown fox jumps');
+ assert.ok(w.ops.some((o) => o.op === 'delete' && o.text === 'quick'));
+ assert.ok(w.ops.some((o) => o.op === 'insert' && o.text === 'slow'));
+});
+
+test('diff reconstructs both sides on random inputs', () => {
+ let seed = 7;
+ const rnd = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; };
+ const words = ['a', 'b', 'c', 'd', 'e'];
+ for (let i = 0; i < 200; i++) {
+ const mk = () => Array.from({ length: Math.floor(rnd() * 12) }, () => words[Math.floor(rnd() * words.length)]).join('\n');
+ const a = mk();
+ const b = mk();
+ const r = diffText(a, b, { mode: 'line' });
+ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);
+ assert.strictEqual(r.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join(''), b);
+ }
+});
+
+test('a huge rewrite falls back to an approximate diff instead of exhausting memory', () => {
+ const a = Array.from({ length: 3000 }, (_, i) => `a${i}`).join('\n');
+ const b = Array.from({ length: 3000 }, (_, i) => `b${i}`).join('\n');
+ const r = diffText(a, b, { maxEdits: 100 });
+ assert.strictEqual(r.approximate, true);
+ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);
+});
+
+test('drafts: save, commit on the base revision, conflict keeps the draft', () => {
+ const revs = createRevisionStore(openDb('rev'), { prefix: 'blog_post', now: fakeClock() });
+ revs.create({ entityId: 'p1', expectedRevision: 0, content: 'v1' });
+ const d = revs.saveDraft({ entityId: 'p1', owner: USER, content: 'my edit' });
+ assert.strictEqual(d.baseRevision, 1);
+ assert.strictEqual(d.stale, false);
+ revs.create({ entityId: 'p1', expectedRevision: 1, content: 'someone else' });
+ assert.strictEqual(revs.getDraft('p1', USER).stale, true);
+ assert.throws(() => revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.status === 412);
+ assert.ok(revs.getDraft('p1', USER), 'draft survives a conflict');
+ revs.saveDraft({ entityId: 'p1', owner: USER, content: 'rebased edit', baseRevision: 2 });
+ const out = revs.commitDraft({ entityId: 'p1', owner: USER, message: 'rebased' });
+ assert.strictEqual(out.revision.number, 3);
+ assert.strictEqual(out.revision.author, USER);
+ assert.strictEqual(revs.getDraft('p1', USER), null);
+});
+
+test('purge is explicit, audited and final', () => {
+ const db = openDb('rev');
+ const revs = createRevisionStore(db, { prefix: 'w' });
+ revs.create({ entityId: 'gone', expectedRevision: 0, content: 'x' });
+ revs.create({ entityId: 'kept', expectedRevision: 0, content: 'y' });
+ assert.throws(() => revs.purgeEntity('gone'), /reason/);
+ assert.deepStrictEqual(revs.purgeEntity('gone', { reason: 'legal request #12', purgedBy: USER }), { deleted: 1 });
+ assert.strictEqual(revs.head('gone'), null);
+ assert.ok(revs.head('kept'));
+ assert.strictEqual(db.prepare('SELECT reason FROM w_revision_purges WHERE entity_id = ?').get('gone').reason, 'legal request #12');
+ assert.throws(() => revs.create({ entityId: 'gone', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);
+ assert.throws(() => db.prepare("DELETE FROM w_revisions WHERE entity_id = 'kept'").run(), /never deleted/);
+});
+
+test('prefix and handle are validated; schema creation is idempotent', () => {
+ const db = openDb('rev');
+ assert.throws(() => createRevisionStore(db, { prefix: 'Bad-Prefix' }), /prefix/);
+ assert.throws(() => createRevisionStore(db, { prefix: 'x; DROP TABLE y' }), /prefix/);
+ assert.throws(() => createRevisionStore({}, { prefix: 'ok' }), /better-sqlite3/);
+ createRevisionStore(db, { prefix: 'twice' });
+ createRevisionStore(db, { prefix: 'twice' });
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, a few at a
+ * time, and fails if any of them fails.
+ *
+ * npm test # everything
+ * npm test -- seo revisions # only files whose name contains one of the words
+ *
+ * Plain Node: temp SQLite files, in-process HTTP, no network.
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const queue = [...files];
+ const results = [];
+ await Promise.all(Array.from({ length: PARALLEL }, async () => {
+ while (queue.length) {
+ const r = await runOne(queue.shift());
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
+ }
+ }));
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-40).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+const assert = require('assert');
+const Database = require('better-sqlite3');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const { createScheduler } = require('../lib/schedule');
+
+const { test, run } = suite();
+const T0 = Date.parse('2026-10-01T09:00:00Z');
+
+test('schedule() is idempotent for the same job', () => {
+ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0 - 60000) });
+ const a = s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 3 });
+ const b = s.schedule({ entityId: 'post_1', action: 'publish', runAt: new Date(T0).toISOString(), revision: 3 });
+ assert.strictEqual(a.created, true);
+ assert.strictEqual(b.created, false);
+ assert.strictEqual(a.job.id, b.job.id);
+ assert.strictEqual(s.jobs('post_1').length, 1);
+ assert.strictEqual(s.table, 'blog_schedule_jobs');
+});
+
+test('jobs are not due before runAt, and run once when due', async () => {
+ const clock = fakeClock(T0 - 60000);
+ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock });
+ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 2 });
+ let calls = 0;
+ const handler = () => { calls++; return { published: 2 }; };
+ assert.deepStrictEqual((await s.runDue({ worker: 'w1', handler })).done, []);
+ clock.set(T0);
+ const out = await s.runDue({ worker: 'w1', handler });
+ assert.strictEqual(out.done.length, 1);
+ assert.deepStrictEqual(out.done[0].result, { published: 2 });
+ await s.runDue({ worker: 'w1', handler });
+ assert.strictEqual(calls, 1);
+});
+
+test('a worker that dies mid-job: the lease expires and a restarted worker re-runs it; the effect happens once', async () => {
+ const clock = fakeClock(T0);
+ const db = openDb('sch');
+ const s = createScheduler(db, { prefix: 'blog', now: clock, leaseMs: 30000 });
+ // the product's own state, updated idempotently by the handler
+ db.exec('CREATE TABLE posts (id TEXT PRIMARY KEY, published_revision INTEGER, publish_count INTEGER NOT NULL DEFAULT 0)');
+ db.prepare("INSERT INTO posts (id) VALUES ('post_1')").run();
+ const handler = (job) => {
+ const info = db.prepare('UPDATE posts SET published_revision = ?, publish_count = publish_count + 1 WHERE id = ? AND published_revision IS NOT ?').run(job.revision, job.entityId, job.revision);
+ return { changed: info.changes };
+ };
+ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 4 });
+
+ // worker A claims, applies the effect, then "crashes" before complete()
+ const [claimed] = s.claim({ worker: 'A' });
+ handler(claimed);
+ assert.strictEqual(s.get(claimed.id).status, 'running');
+
+ // restart: a new scheduler instance on a new connection (like a new process)
+ const s2 = createScheduler(new Database(db.name), { prefix: 'blog', now: clock, leaseMs: 30000 });
+ assert.strictEqual(s2.claim({ worker: 'B' }).length, 0, 'lease still held');
+ clock.advance(30001);
+ const out = await s2.runDue({ worker: 'B', handler });
+ assert.strictEqual(out.done.length, 1);
+ assert.strictEqual(out.done[0].attempts, 2);
+ assert.deepStrictEqual(out.done[0].result, { changed: 0 });
+ const post = db.prepare("SELECT * FROM posts WHERE id = 'post_1'").get();
+ assert.strictEqual(post.published_revision, 4);
+ assert.strictEqual(post.publish_count, 1, 'published exactly once');
+ // the crashed worker coming back cannot overwrite the result
+ assert.strictEqual(s.complete(claimed.id, 'A', { late: true }), false);
+ assert.deepStrictEqual(s.get(claimed.id).result, { changed: 0 });
+});
+
+test('failures retry with backoff, then fail for good', async () => {
+ const clock = fakeClock(T0);
+ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, backoffMs: [1000] });
+ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 });
+ const boom = () => { throw new Error('media service down'); };
+ let out = await s.runDue({ worker: 'w', handler: boom });
+ assert.strictEqual(out.retried.length, 1);
+ assert.strictEqual(out.retried[0].lastError, 'media service down');
+ assert.strictEqual((await s.runDue({ worker: 'w', handler: boom })).retried.length, 0, 'waits for backoff');
+ clock.advance(1000);
+ out = await s.runDue({ worker: 'w', handler: boom });
+ assert.strictEqual(out.failed.length, 1);
+ assert.strictEqual(out.failed[0].status, 'failed');
+});
+
+test('a job whose workers keep crashing is abandoned after maxAttempts and reported as failed', async () => {
+ const clock = fakeClock(T0);
+ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, leaseMs: 1000 });
+ s.schedule({ entityId: 'p', action: 'publish', runAt: T0, revision: 1 });
+ s.claim({ worker: 'a' }); clock.advance(1001);
+ s.claim({ worker: 'b' }); clock.advance(1001);
+ const out = await s.runDue({ worker: 'c', handler: () => assert.fail('must not run a third time') });
+ assert.strictEqual(out.failed.length, 1);
+ assert.match(out.failed[0].lastError, /lease expired/);
+});
+
+test('cancel and cancelPending', () => {
+ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0) });
+ const { job } = s.schedule({ entityId: 'p', action: 'publish', runAt: T0 + 1000, revision: 1 });
+ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 5000 });
+ assert.strictEqual(s.cancel(job.id), true);
+ assert.strictEqual(s.cancelPending('p', 'unpublish'), 1);
+ assert.deepStrictEqual(s.jobs('p').map((j) => j.status), ['cancelled', 'cancelled']);
+});
+
+test('validation', () => {
+ const s = createScheduler(openDb('sch'), { prefix: 'blog' });
+ assert.throws(() => s.schedule({ entityId: 'p', action: 'delete', runAt: T0 }), /action/);
+ assert.throws(() => s.schedule({ entityId: 'p', action: 'publish', runAt: 'soon' }), /runAt/);
+ assert.throws(() => s.claim({}), /worker/);
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,294 @@
@@ -0,0 +1,294 @@
+'use strict';
+const assert = require('assert');
+const { openDb, fakeClock, suite } = require('./helpers/db');
+const seo = require('../lib/seo');
+
+const { test, run } = suite();
+const NOW = Date.parse('2026-09-22T12:00:00Z');
+const LONG = 'word '.repeat(200);
+const base = (extra = {}) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', text: LONG, ...extra });
+
+// ---- the gate ----------------------------------------------------------------------------------
+
+test('published, sourced, non-duplicate content is indexable', () => {
+ const d = seo.evaluate(base({ citationCount: 2 }), { policy: { requireSources: true } });
+ assert.deepStrictEqual(d, {
+ gate: 'openvibe-publishing/seo@1', indexable: true, listable: true, robots: 'index, follow',
+ canonical: 'https://openvibe.wiki/p/rye', reasons: [], codes: [],
+ });
+});
+
+test('every reason code fires on its own fact, with a stable effect', () => {
+ const policy = { requireSources: true, sensitiveCategories: ['health'] };
+ const cases = [
+ ['deleted', { state: 'deleted' }, 'hidden'],
+ ['takedown', { takedown: true }, 'hidden'],
+ ['private', { visibility: 'private' }, 'hidden'],
+ ['gated', { visibility: 'gated' }, 'hidden'],
+ ['unlisted', { visibility: 'unlisted' }, 'hidden'],
+ ['draft', { state: 'draft' }, 'hidden'],
+ ['draft', { state: 'scheduled' }, 'hidden'],
+ ['unpublished', { state: 'unpublished' }, 'hidden'],
+ ['ai_generated_unreviewed', { authorship: { mode: 'ai', reviewed: false } }, 'hidden'],
+ ['stub_provider', { stubProvider: true }, 'hidden'],
+ ['unreviewed_sensitive', { category: 'health' }, 'hidden'],
+ ['unreviewed_sensitive', { sensitive: true }, 'hidden'],
+ ['retracted', { retracted: true }, 'noindex'],
+ ['expired', { expiresAt: '2026-09-01T00:00:00Z' }, 'noindex'],
+ ['stale_price', { price: { observedAt: '2026-09-01T00:00:00Z' } }, 'noindex'],
+ ['stale_price', { price: {} }, 'noindex'],
+ ['duplicate_of', { duplicateOf: 'https://openvibe.wiki/p/rye-grain' }, 'noindex'],
+ ['thin', { text: 'too short' }, 'noindex'],
+ ['unsourced', { citationCount: 0 }, 'noindex'],
+ ['unsupported_claims', { unsupportedClaims: 2 }, 'noindex'],
+ ['missing_canonical', { canonicalUrl: undefined }, 'noindex'],
+ ['noindex_requested', { noindex: true }, 'noindex'],
+ ];
+ for (const [code, facts, effect] of cases) {
+ const f = base({ citationCount: 1, ...facts });
+ if (f.canonicalUrl === undefined) delete f.canonicalUrl;
+ const d = seo.evaluate(f, { policy, now: NOW });
+ assert.deepStrictEqual(d.codes, [code], `${JSON.stringify(facts)} → ${d.codes}`);
+ assert.strictEqual(d.reasons[0].effect, effect);
+ assert.strictEqual(d.indexable, false);
+ assert.strictEqual(d.listable, effect !== 'hidden');
+ assert.strictEqual(d.robots, effect === 'hidden' ? 'noindex, nofollow' : 'noindex, follow');
+ assert.ok(seo.REASON_BY_CODE[code].description);
+ }
+});
+
+test('the gate is deterministic: same input → same output, reasons in REASONS order', () => {
+ const facts = base({ state: 'draft', visibility: 'private', text: 'short', noindex: true, citationCount: 0, authorship: { mode: 'ai', reviewed: false }, duplicateOf: 'https://x.test/o' });
+ const opts = { policy: { requireSources: true }, now: NOW };
+ const a = seo.evaluate(facts, opts);
+ const b = seo.evaluate(JSON.parse(JSON.stringify(facts)), opts);
+ assert.deepStrictEqual(a, b);
+ assert.deepStrictEqual(a.codes, ['private', 'draft', 'ai_generated_unreviewed', 'duplicate_of', 'thin', 'unsourced', 'noindex_requested']);
+ const order = seo.REASONS.map((r) => r.code);
+ assert.deepStrictEqual([...a.codes].sort((x, y) => order.indexOf(x) - order.indexOf(y)), a.codes);
+ assert.strictEqual(a.canonical, 'https://x.test/o', 'a duplicate canonicalises to its original');
+ assert.strictEqual(a.reasons.find((r) => r.code === 'thin').detail, '1 of 150 words');
+});
+
+test('reviews lift the AI/stub/sensitive holds; fresh prices and future expiry pass', () => {
+ const d = seo.evaluate(base({
+ authorship: { mode: 'ai', reviewed: true }, stubProvider: true, sensitive: true, sensitiveReviewed: true,
+ expiresAt: '2026-12-01T00:00:00Z', price: { observedAt: NOW - 3600e3 },
+ }), { now: NOW });
+ assert.strictEqual(d.indexable, true);
+ assert.strictEqual(seo.evaluate(base({ authorship: { mode: 'hybrid', reviewed: false } })).indexable, true);
+});
+
+test('no silent pass: unknown facts, missing facts and time facts without `now` throw', () => {
+ assert.throws(() => seo.evaluate(base({ visiblity: 'public' })), /unknown fact "visiblity"/);
+ assert.throws(() => seo.evaluate({ visibility: 'public', text: LONG }), /state/);
+ assert.throws(() => seo.evaluate({ state: 'published', text: LONG }), /visibility/);
+ const { text, ...noText } = base();
+ assert.throws(() => seo.evaluate(noText), /text or facts.wordCount/);
+ assert.throws(() => seo.evaluate(base(), { policy: { requireSources: true } }), /citationCount/);
+ assert.throws(() => seo.evaluate(base({ expiresAt: '2026-01-01' })), /needs options.now/);
+ assert.throws(() => seo.evaluate(base({ price: { observedAt: NOW } })), /needs options.now/);
+ assert.strictEqual(seo.evaluate(noText, { policy: { minWords: 0 } }).indexable, true);
+ assert.strictEqual(seo.evaluate(base({ text: undefined, wordCount: 151 })).indexable, true);
+});
+
+// ---- canonical URLs and redirects --------------------------------------------------------------
+
+test('canonicalUrl', () => {
+ assert.strictEqual(seo.canonicalUrl('https://OpenVibe.Wiki', ['spaces', 'food', 'Crème brûlée']), 'https://openvibe.wiki/spaces/food/Cr%C3%A8me%20br%C3%BBl%C3%A9e');
+ 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/);
+ assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);
+});
+
+test('history-aware redirects: every old slug 301s straight to the current path; gone → 410', () => {
+ const redirects = seo.createRedirectStore(openDb('seo'), { prefix: 'wiki_page', now: fakeClock() });
+ const current = { pg_1: '/p/rye' };
+ const currentPath = (id) => current[id] || null;
+ redirects.recordMove('pg_1', '/p/ryegrass', '/p/rye-grain');
+ redirects.recordMove('pg_1', '/p/rye-grain', '/p/rye');
+ assert.deepStrictEqual(redirects.resolve('/p/ryegrass', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
+ assert.deepStrictEqual(redirects.resolve('/p/rye-grain/?x=1', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
+ assert.strictEqual(redirects.resolve('/p/rye', { currentPath }), null);
+ assert.strictEqual(redirects.resolve('/p/unknown', { currentPath }), null);
+ // renamed back to an old slug: that slug is live again and must not redirect
+ redirects.recordMove('pg_1', '/p/rye', '/p/ryegrass');
+ current.pg_1 = '/p/ryegrass';
+ assert.strictEqual(redirects.resolve('/p/ryegrass', { currentPath }), null);
+ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }).location, '/p/ryegrass');
+ assert.deepStrictEqual(redirects.history('pg_1').map((h) => h.path).sort(), ['/p/rye', '/p/rye-grain']);
+ delete current.pg_1;
+ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }), { status: 410, entityId: 'pg_1' });
+ assert.strictEqual(redirects.table, 'wiki_page_redirects');
+ assert.throws(() => redirects.recordMove('pg_1', 'no-slash', '/x'), /start with/);
+});
+
+// ---- meta tags ---------------------------------------------------------------------------------
+
+test('meta tags take robots and canonical from the decision, and refuse to render without one', () => {
+ const ok = seo.evaluate(base());
+ const html = seo.metaTags({ title: 'Rye', description: 'A grass.', decision: ok });
+ assert.match(html, /<meta name="robots" content="index, follow">/);
+ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye">/);
+ const dup = seo.evaluate(base({ duplicateOf: 'https://openvibe.wiki/p/original' }));
+ assert.match(seo.metaTags({ title: 'Rye', decision: dup }), /rel="canonical" href="https:\/\/openvibe.wiki\/p\/original"/);
+ assert.match(seo.metaTags({ title: 'x', decision: seo.evaluate(base({ visibility: 'private' })) }), /content="noindex, nofollow"/);
+ assert.throws(() => seo.metaTags({ title: 'Rye' }), /decision/);
+ assert.strictEqual(seo.robotsMeta(dup), '<meta name="robots" content="noindex, follow">');
+ assert.strictEqual(seo.xRobotsTag(ok), 'index, follow');
+});
+
+// ---- sitemaps ----------------------------------------------------------------------------------
+
+test('sitemaps contain only indexable URLs and never invent lastmod', () => {
+ const entries = [
+ { loc: 'https://openvibe.wiki/p/a', lastmod: '2026-09-20T08:00:00Z', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/a' })) },
+ { loc: 'https://openvibe.wiki/p/b', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/b' })) },
+ { loc: 'https://openvibe.wiki/p/draft', lastmod: '2026-09-21', decision: seo.evaluate(base({ state: 'draft' })) },
+ { loc: 'https://openvibe.wiki/p/private', decision: seo.evaluate(base({ visibility: 'private' })) },
+ { loc: 'https://openvibe.wiki/p/thin', decision: seo.evaluate(base({ text: 'short' })) },
+ ];
+ const out = seo.sitemap(entries);
+ assert.strictEqual(out.count, 2);
+ assert.strictEqual(out.files.length, 1);
+ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/a<\/loc><lastmod>2026-09-20<\/lastmod>/);
+ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/b<\/loc><\/url>/, 'no lastmod for b');
+ assert.doesNotMatch(out.files[0], /draft|private|thin/);
+ assert.deepStrictEqual(out.skipped.map((s) => s.codes), [['draft'], ['private'], ['thin']]);
+ assert.throws(() => seo.sitemap([{ loc: 'https://x.test/' }]), /decision/);
+ const many = Array.from({ length: 5 }, (_, i) => ({ loc: `https://x.test/${i}`, decision: seo.evaluate(base({ canonicalUrl: `https://x.test/${i}` })) }));
+ assert.strictEqual(seo.sitemap(many, { maxUrls: 2 }).files.length, 3);
+ assert.match(seo.sitemapIndex([{ loc: 'https://x.test/sitemap-1.xml' }]), /<sitemapindex/);
+ assert.match(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }), /Sitemap: https:\/\/x.test\/sitemap.xml/);
+});
+
+// ---- feeds -------------------------------------------------------------------------------------
+
+function feedFixture() {
+ const ok = seo.evaluate(base());
+ const thin = seo.evaluate(base({ text: 'short' }));
+ return [
+ { id: 'tag:openvibe.blog,2026:post/1', url: 'https://openvibe.blog/a/1', title: 'Rye <bread> & co', summary: 'About rye', contentHtml: '<p>Rye</p>', published: '2026-09-20T08:00:00Z', updated: '2026-09-21T08:00:00Z', authors: [{ name: 'Alex', url: 'https://openvibe.blog/a' }], tags: ['bread'], decision: ok },
+ { id: 'post-2', url: 'https://openvibe.blog/a/2', title: 'Undated and authorless', decision: thin },
+ { id: 'post-3', url: 'https://openvibe.blog/a/3', title: 'Private', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'private' })) },
+ { id: 'post-4', url: 'https://openvibe.blog/a/4', title: 'VIP', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'gated' })) },
+ ];
+}
+const channel = { title: 'Alex bakes', link: 'https://openvibe.blog/a', feedUrl: 'https://openvibe.blog/a/feed.xml', description: 'Bread notes' };
+
+test('RSS: only listable items; no invented pubDate or author; XML escaped', () => {
+ const xml = seo.rssFeed(channel, feedFixture());
+ assert.match(xml, /<title>Rye <bread> & co<\/title>/);
+ assert.match(xml, /<pubDate>Sun, 20 Sep 2026 08:00:00 GMT<\/pubDate>/);
+ assert.match(xml, /<dc:creator>Alex<\/dc:creator>/);
+ assert.strictEqual((xml.match(/<item>/g) || []).length, 2, 'the thin post is listable, private and VIP are not');
+ assert.doesNotMatch(xml, /Private|VIP/);
+ const item2 = xml.split('<item>')[2];
+ assert.doesNotMatch(item2, /pubDate|dc:creator/);
+ assert.doesNotMatch(xml, /lastBuildDate/, 'no channel date unless provided');
+ assert.throws(() => seo.rssFeed({ title: 'x', link: 'https://x.test' }, []), /description/);
+});
+
+test('Atom: undated entries are left out instead of dated "now"; feed updated = newest entry', () => {
+ const xml = seo.atomFeed(channel, feedFixture());
+ assert.strictEqual((xml.match(/<entry>/g) || []).length, 1);
+ assert.match(xml, /<updated>2026-09-21T08:00:00.000Z<\/updated>/);
+ assert.match(xml, /<id>tag:openvibe.blog,2026:post\/1<\/id>/);
+ assert.doesNotMatch(xml, /Undated/);
+ assert.throws(() => seo.atomFeed(channel, [feedFixture()[1]]), /needs channel.updated/);
+ assert.match(seo.atomFeed({ ...channel, updated: '2026-09-22T00:00:00Z' }, [feedFixture()[1]]), /<feed/);
+});
+
+test('JSON Feed 1.1: only provided fields', () => {
+ const f = seo.jsonFeed(channel, feedFixture());
+ assert.strictEqual(f.version, 'https://jsonfeed.org/version/1.1');
+ assert.strictEqual(f.items.length, 2);
+ assert.deepStrictEqual(Object.keys(f.items[1]).sort(), ['content_text', 'id', 'title', 'url']);
+ assert.strictEqual(f.items[0].date_published, '2026-09-20T08:00:00.000Z');
+ assert.deepStrictEqual(f.items[0].authors, [{ name: 'Alex', url: 'https://openvibe.blog/a' }]);
+ assert.throws(() => seo.jsonFeed(channel, [{ id: 'x', url: 'https://x.test' }]), /decision/);
+ assert.match(seo.feedLinks([{ type: 'atom', href: '/feed.atom', title: 'Atom' }]), /application\/atom\+xml/);
+});
+
+// ---- structured data ---------------------------------------------------------------------------
+
+/** Every leaf in the output must come from the input (or be a schema.org constant). */
+function leaves(obj, out = [], key = null) {
+ if (obj == null) return out;
+ if (Array.isArray(obj)) { for (const x of obj) leaves(x, out, key); return out; }
+ if (typeof obj === 'object') { for (const [k, v] of Object.entries(obj)) leaves(v, out, k); return out; }
+ out.push({ key, value: obj });
+ return out;
+}
+function assertOnlyFrom(output, input, allowed = []) {
+ const inputs = new Set(leaves(input).map((l) => String(l.value)));
+ for (const l of leaves(output)) {
+ if (['@context', '@type', '@id'].includes(l.key)) continue;
+ if (allowed.includes(String(l.value))) continue;
+ assert.ok(inputs.has(String(l.value)) || inputs.has(new Date(l.value).toISOString()) || [...inputs].some((v) => !Number.isNaN(Date.parse(v)) && Date.parse(v) === Date.parse(l.value)),
+ `invented value ${l.key}=${JSON.stringify(l.value)}`);
+ }
+}
+function hasKey(obj, name) { return JSON.stringify(obj).includes(`"${name}"`); }
+
+test('article: a missing author or date is omitted, never defaulted', () => {
+ const input = { type: 'BlogPosting', headline: 'Rye', url: 'https://openvibe.blog/a/1' };
+ const ld = seo.structuredData.article(input);
+ assert.deepStrictEqual(ld, { '@context': 'https://schema.org', '@type': 'BlogPosting', '@id': 'https://openvibe.blog/a/1#article', headline: 'Rye', url: 'https://openvibe.blog/a/1', mainEntityOfPage: 'https://openvibe.blog/a/1' });
+ for (const k of ['author', 'datePublished', 'dateModified', 'publisher', 'image', 'citation']) assert.ok(!hasKey(ld, k), `${k} must be absent`);
+ const full = { type: 'NewsArticle', headline: 'Rye', url: 'https://openvibe.news/s/1', datePublished: '2026-09-20T08:00:00Z', authors: [{ name: 'Sam' }, { name: '' }],
+ publisher: { name: 'OpenVibe News' }, citations: [{ url: 'https://example.org/src', title: 'Source' }, { title: 'no url' }] };
+ const ld2 = seo.structuredData.article(full);
+ assert.deepStrictEqual(ld2.author, [{ '@type': 'Person', name: 'Sam' }], 'nameless author dropped');
+ assert.deepStrictEqual(ld2.citation, [{ '@type': 'CreativeWork', url: 'https://example.org/src', name: 'Source' }]);
+ assertOnlyFrom(ld2, full);
+ assert.strictEqual(seo.structuredData.article({ url: 'https://x.test' }), null, 'no headline → no Article');
+});
+
+test('review: no rating, no reviewRating; no bestRating default', () => {
+ const input = { itemReviewed: { type: 'Product', name: 'Bread knife' }, author: { name: 'Sam' }, body: 'Sharp.' };
+ const ld = seo.structuredData.review(input);
+ assert.ok(!hasKey(ld, 'reviewRating'));
+ assert.ok(!hasKey(ld, 'ratingValue'));
+ assert.ok(!hasKey(ld, 'datePublished'));
+ const rated = seo.structuredData.review({ ...input, rating: { value: 4 } });
+ assert.deepStrictEqual(rated.reviewRating, { '@type': 'Rating', ratingValue: 4 }, 'bestRating not assumed to be 5');
+ assertOnlyFrom(rated, { ...input, rating: { value: 4 } });
+ assert.strictEqual(seo.structuredData.review({ body: 'x' }), null);
+});
+
+test('aggregate ratings need a value AND a real count; otherwise nothing', () => {
+ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5 } }).aggregateRating, undefined);
+ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5, count: 0 } }).aggregateRating, undefined);
+ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { count: 12 } }).aggregateRating, undefined);
+ const ok = seo.structuredData.ratedThing({ type: 'LocalBusiness', name: 'Cafe', aggregateRating: { value: 4.5, count: 12, best: 5 } });
+ assert.deepStrictEqual(ok.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.5, ratingCount: 12, bestRating: 5 });
+});
+
+test('product: no price or currency → no Offer; no availability → none assumed', () => {
+ const noPrice = seo.structuredData.product({ name: 'Rye flour', offers: [{ priceCurrency: 'EUR', availability: 'in_stock' }, { price: 3.2 }] });
+ assert.ok(!hasKey(noPrice, 'offers'));
+ assert.ok(!hasKey(noPrice, 'price'));
+ assert.ok(!hasKey(noPrice, 'aggregateRating'));
+ const input = { name: 'Rye flour', url: 'https://openvibe.deals/o/1', offers: [{ price: '3.20', priceCurrency: 'EUR', url: 'https://shop.example/rye' }] };
+ const priced = seo.structuredData.product(input);
+ assert.deepStrictEqual(priced.offers, [{ '@type': 'Offer', price: 3.2, priceCurrency: 'EUR', url: 'https://shop.example/rye' }]);
+ assert.ok(!hasKey(priced, 'availability'), 'availability never assumed');
+ assertOnlyFrom(priced, input, ['3.2']);
+ const avail = seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'out_of_stock' }] });
+ assert.strictEqual(avail.offers[0].availability, 'https://schema.org/OutOfStock');
+ assert.ok(!hasKey(seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'probably' }] }), 'availability'));
+});
+
+test('breadcrumbs, webPage and the JSON-LD script tag', () => {
+ const bc = seo.structuredData.breadcrumbs([{ name: 'Food', url: 'https://w.test/food' }, { name: 'Rye' }]);
+ assert.deepStrictEqual(bc.itemListElement[1], { '@type': 'ListItem', position: 2, name: 'Rye' });
+ assert.strictEqual(seo.structuredData.webPage({ url: 'https://w.test/' })['@type'], 'WebPage');
+ const tag = seo.jsonLdScript(seo.structuredData.article({ headline: '</script><script>alert(1)</script>', url: 'https://w.test/' }));
+ assert.doesNotMatch(tag, /<\/script><script>/);
+ assert.strictEqual(seo.jsonLdScript(null), '');
+ assert.deepStrictEqual(seo.compact({ a: '', b: [null, { '@type': 'X' }], c: NaN, d: 0, e: false }), { d: 0, e: false });
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+const assert = require('assert');
+const ssr = require('../lib/ssr');
+const { diffText } = require('../lib/diff');
+const { suite } = require('./helpers/db');
+
+const { test, run } = suite();
+
+test('html`` escapes interpolations unless raw()', () => {
+ const name = '<img src=x onerror=alert(1)>';
+ const out = String(ssr.html`<h1>${name}</h1>${ssr.raw('<em>ok</em>')}${null}${['a', '<b>']}`);
+ assert.strictEqual(out, '<h1><img src=x onerror=alert(1)></h1><em>ok</em>a<b>');
+ assert.strictEqual(ssr.escapeHtml(`"'&`), '"'&');
+});
+
+test('markdown: the supported subset renders; raw HTML and unsafe links do not', () => {
+ const src = [
+ '# Title', '', 'Some **bold**, *em*, ~~gone~~ and `code <x>`.', '',
+ '- one', '- two', '', '1. first', '2. second', '', '> quoted', '', '---', '',
+ '```js', 'const a = "<b>";', '```', '',
+ '[site](https://openvibe.wiki/p) [bad](javascript:alert(1)) <script>alert(1)</script>',
+ 'Bare https://example.org/x?a=1&b=2.',
+ ].join('\n');
+ const html = ssr.renderMarkdown(src);
+ assert.match(html, /<h2>Title<\/h2>/, '# is demoted: the page owns <h1>');
+ assert.match(html, /<strong>bold<\/strong>, <em>em<\/em>, <del>gone<\/del> and <code>code <x><\/code>/);
+ assert.match(html, /<ul><li>one<\/li><li>two<\/li><\/ul>/);
+ assert.match(html, /<ol><li>first<\/li><li>second<\/li><\/ol>/);
+ assert.match(html, /<blockquote><p>quoted<\/p><\/blockquote>/);
+ assert.match(html, /<pre><code class="language-js">const a = "<b>";<\/code><\/pre>/);
+ assert.match(html, /<a href="https:\/\/openvibe.wiki\/p" rel="nofollow ugc noopener">site<\/a>/);
+ assert.doesNotMatch(html, /href="javascript/);
+ assert.doesNotMatch(html, /<script>/);
+ assert.match(html, /<a href="https:\/\/example.org\/x\?a=1&b=2" rel="nofollow ugc noopener">https:\/\/example.org\/x\?a=1&b=2<\/a>\./);
+ assert.match(ssr.renderMarkdown('[a](/local)', { rel: 'noopener' }), /<a href="\/local" rel="noopener">a<\/a>/);
+ assert.doesNotMatch(ssr.renderMarkdown('[a](//evil.test)'), /<a /);
+ assert.doesNotMatch(ssr.renderMarkdown('[x](https://a.test/"onmouseover="alert(1))'), /" onmouseover|"onmouseover="alert/);
+});
+
+test('markdownToText and wordCount', () => {
+ assert.strictEqual(ssr.markdownToText('# Hi\n\nSome **bold** [link](https://x.test).\n\n```\ncode\n```'), 'Hi Some bold link. code');
+ assert.strictEqual(ssr.markdownToText('one two three four', 10), 'one two…');
+ assert.strictEqual(ssr.wordCount("It's a rye-bread loaf, 2 kg."), 6);
+ assert.strictEqual(ssr.wordCount(''), 0);
+});
+
+test('pagination is plain links and clamps out-of-range pages', () => {
+ const p = ssr.paginate({ page: 3, perPage: 10, total: 95, href: (n) => `/tags/rye?page=${n}` });
+ assert.strictEqual(p.pages, 10);
+ assert.strictEqual(p.offset, 20);
+ assert.strictEqual(p.prev.href, '/tags/rye?page=2');
+ const html = ssr.paginationHtml(p);
+ assert.match(html, /<a href="\/tags\/rye\?page=2" rel="prev">/);
+ assert.match(html, /<span aria-current="page">3<\/span>/);
+ assert.match(html, /<a href="\/tags\/rye\?page=10">10<\/a>/);
+ const far = ssr.paginate({ page: 99, perPage: 10, total: 95, href: (n) => `?p=${n}` });
+ assert.strictEqual(far.page, 10);
+ assert.strictEqual(far.outOfRange, true);
+ assert.strictEqual(ssr.paginationHtml(ssr.paginate({ total: 3, href: String })), '');
+});
+
+test('breadcrumbs, diff markup and honest <time>', () => {
+ const bc = ssr.breadcrumbsHtml([{ name: 'Food', url: '/food' }, { name: '<Rye>', url: '/food/rye' }]);
+ assert.strictEqual(bc, '<nav class="ov-breadcrumbs" aria-label="Breadcrumb"><ol><li><a href="/food">Food</a></li><li aria-current="page"><Rye></li></ol></nav>');
+ const d = ssr.diffHtml(diffText('a <b>', 'a <i>', { mode: 'word' }));
+ assert.match(d, /<del>b<\/del><ins>i<\/ins>/);
+ assert.doesNotMatch(d, /<b>/);
+ assert.strictEqual(ssr.timeTag(null), '');
+ assert.strictEqual(ssr.timeTag('not a date'), '');
+ assert.strictEqual(ssr.timeTag('2026-09-20T10:00:00Z'), '<time datetime="2026-09-20T10:00:00.000Z">2026-09-20</time>');
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+const assert = require('assert');
+const { openDb, suite } = require('./helpers/db');
+const { createTaxonomy, slugify } = require('../lib/taxonomy');
+
+const { test, run } = suite();
+
+test('slugify', () => {
+ assert.strictEqual(slugify('Crème Brûlée & Co.'), 'creme-brulee-co');
+ assert.strictEqual(slugify("Baker's Guide"), 'bakers-guide');
+ assert.strictEqual(slugify('日本 料理'), '日本-料理');
+ assert.throws(() => slugify('!!!'), /slug/);
+ assert.ok(slugify('x'.repeat(200)).length <= 80);
+});
+
+test('terms are unique per vocabulary slug; ensureTerm is get-or-create', () => {
+ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
+ const a = tax.ensureTerm({ vocabulary: 'tag', name: 'Sourdough' });
+ const b = tax.ensureTerm({ vocabulary: 'tag', name: 'sourdough' });
+ const c = tax.ensureTerm({ vocabulary: 'category', name: 'Sourdough' });
+ assert.strictEqual(a.id, b.id);
+ assert.notStrictEqual(a.id, c.id);
+ assert.strictEqual(tax.bySlug('tag', 'sourdough').name, 'Sourdough');
+ assert.deepStrictEqual(tax.tables, { terms: 'blog_terms', links: 'blog_term_links' });
+});
+
+test('hierarchical categories: ancestors (breadcrumbs), descendants, tree, cycles refused', () => {
+ const tax = createTaxonomy(openDb('tax'), { prefix: 'wiki' });
+ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
+ const baking = tax.ensureTerm({ vocabulary: 'category', name: 'Baking', parentId: food.id });
+ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: baking.id });
+ assert.deepStrictEqual(tax.ancestors(bread.id).map((t) => t.name), ['Food', 'Baking', 'Bread']);
+ assert.deepStrictEqual(tax.descendants(food.id).map((t) => t.name).sort(), ['Baking', 'Bread']);
+ const tree = tax.tree('category');
+ assert.strictEqual(tree.length, 1);
+ assert.strictEqual(tree[0].children[0].children[0].name, 'Bread');
+ assert.throws(() => tax.setParent(food.id, bread.id), (e) => e.code === 'term.cycle');
+ assert.throws(() => tax.setParent(food.id, food.id), (e) => e.code === 'term.cycle');
+ const tag = tax.ensureTerm({ vocabulary: 'tag', name: 'x' });
+ assert.throws(() => tax.setParent(tag.id, food.id), (e) => e.code === 'term.parent_vocabulary');
+});
+
+test('setTerms replaces one vocabulary; entitiesFor includes descendants on request', () => {
+ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
+ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
+ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });
+ tax.setTerms('post_1', 'tag', ['Rye', 'rye', 'Starter']);
+ tax.setTerms('post_1', 'category', [bread.id]);
+ tax.setTerms('post_2', 'category', [food.id]);
+ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['rye', 'starter']);
+ tax.setTerms('post_1', 'tag', ['Starter']);
+ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['starter']);
+ assert.strictEqual(tax.termsFor('post_1', 'category').length, 1, 'other vocabularies untouched');
+ assert.deepStrictEqual(tax.entitiesFor(food.id), ['post_2']);
+ assert.deepStrictEqual(tax.entitiesFor(food.id, { includeDescendants: true }), ['post_1', 'post_2']);
+ assert.throws(() => tax.setTerms('post_1', 'tag', [bread.id]), (e) => e.code === 'term.vocabulary_mismatch');
+});
+
+test('remove re-parents children and drops links', () => {
+ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
+ const a = tax.ensureTerm({ vocabulary: 'category', name: 'A' });
+ const b = tax.ensureTerm({ vocabulary: 'category', name: 'B', parentId: a.id });
+ const c = tax.ensureTerm({ vocabulary: 'category', name: 'C', parentId: b.id });
+ tax.setTerms('e', 'category', [b.id]);
+ tax.remove(b.id);
+ assert.strictEqual(tax.get(c.id).parentId, a.id);
+ assert.deepStrictEqual(tax.termsFor('e'), []);
+ assert.strictEqual(tax.rename(a.id, ' All ').name, 'All');
+});
+
+run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+/**
+ * Wave 15 exit proof: two different products share revision, citation, SEO-gate and feed code
+ * without sharing a database or an authority.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { tempDir, fakeClock, suite } = require('./helpers/db');
+const { createWiki } = require('../examples/two-products/wiki/app');
+const { createBlog } = require('../examples/two-products/blog/app');
+
+const { test, run } = suite();
+const ALEX = 'usr_01J8Z6Q3KX0000000000000000';
+const EDITOR = 'usr_01J8Z6Q3KX0000000000000001';
+const LONG = 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.';
+const MED = 'med_01J8Z6Q3KX0000000000000000';
+
+function tablesOf(db) {
+ return db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).sort();
+}
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const server = http.createServer(handler).listen(0, '127.0.0.1', () => {
+ const base = `http://127.0.0.1:${server.address().port}`;
+ resolve({ base, close: () => server.close(), get: (p) => fetch(base + p, { redirect: 'manual' }) });
+ });
+ });
+}
+
+function setup() {
+ const clock = fakeClock(Date.parse('2026-09-22T12:00:00Z'));
+ const wikiDir = tempDir('wiki');
+ const blogDir = tempDir('blog');
+ const wiki = createWiki({ dbPath: path.join(wikiDir, 'wiki.db'), now: clock });
+ const blog = createBlog({ dbPath: path.join(blogDir, 'blog.db'), now: clock });
+ return { clock, wiki, blog, wikiFile: path.join(wikiDir, 'wiki.db'), blogFile: path.join(blogDir, 'blog.db') };
+}
+
+test('same code: both products run the very same package modules', () => {
+ const { wiki, blog } = setup();
+ const revisionsFile = require.resolve('openvibe-publishing/revisions');
+ assert.strictEqual(revisionsFile, path.join(__dirname, '..', 'lib', 'revisions.js'));
+ for (const mod of ['revisions', 'citations', 'seo', 'ssr']) {
+ const file = require.resolve(`openvibe-publishing/${mod}`);
+ const users = Object.values(require.cache).filter((m) => m.children.some((c) => c.filename === file)).map((m) => path.basename(path.dirname(m.filename)));
+ assert.ok(users.includes('wiki') && users.includes('blog'), `${mod} is loaded by both apps (${users})`);
+ }
+ wiki.close(); blog.close();
+});
+
+test('no shared database: two files, each holding only its own product\'s tables', () => {
+ const { wiki, blog, wikiFile, blogFile } = setup();
+ assert.notStrictEqual(fs.realpathSync(wikiFile), fs.realpathSync(blogFile));
+ const wt = tablesOf(wiki.db);
+ const bt = tablesOf(blog.db);
+ assert.ok(wt.length > 5 && bt.length > 5);
+ assert.ok(wt.every((n) => n.startsWith('wiki_')), `wiki db: ${wt.filter((n) => !n.startsWith('wiki_'))}`);
+ assert.ok(bt.every((n) => n.startsWith('blog_')), `blog db: ${bt.filter((n) => !n.startsWith('blog_'))}`);
+ for (const t of ['wiki_page_revisions', 'wiki_citations', 'wiki_page_redirects', 'wiki_pages']) assert.ok(wt.includes(t), t);
+ for (const t of ['blog_post_revisions', 'blog_post_citations', 'blog_schedule_jobs', 'blog_terms', 'blog_post_attachments', 'blog_posts']) assert.ok(bt.includes(t), t);
+ const attached = wiki.db.prepare('PRAGMA database_list').all();
+ assert.deepStrictEqual(attached.map((d) => d.name), ['main'], 'nothing attached across products');
+ wiki.close(); blog.close();
+});
+
+test('each product owns its state: writes in one never appear in the other', () => {
+ const { wiki, blog } = setup();
+ const { id: pageId } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] });
+ wiki.publish(pageId, { revision: 1, actor: ALEX });
+ const { id: postId } = blog.draft({ title: 'First loaf', body: LONG, authorName: 'Alex', tags: ['rye'] });
+ assert.strictEqual(wiki.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_revisions').get().n, 1);
+ assert.strictEqual(blog.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions').get().n, 1);
+ assert.strictEqual(wiki.revisions.head(postId), null);
+ assert.strictEqual(blog.revisions.head(pageId), null);
+ assert.strictEqual(blog.citations.history(postId).length, 0);
+ assert.strictEqual(wiki.citations.history(pageId).length, 1);
+ wiki.close(); blog.close();
+});
+
+test('revisions and citations: edit, diff, revert; citations stay on the revision that used them', () => {
+ const { wiki, clock } = setup();
+ const { id } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/a' }, { url: 'https://example.org/b' }] });
+ const [a] = wiki.citations.forRevision(id, 1);
+ clock.advance(1000);
+ const r2 = wiki.edit(id, { expectedRevision: 1, body: LONG.replace('grass', 'cereal grass'), author: ALEX, keepSources: [a.id] });
+ assert.throws(() => wiki.edit(id, { expectedRevision: 1, body: 'lost update', author: ALEX }), (e) => e.status === 412);
+ const diff = wiki.revisions.diff(id, 1, r2, { mode: 'word' });
+ assert.ok(diff.content.ops.some((o) => o.op === 'insert' && o.text.includes('cereal')));
+ assert.deepStrictEqual(wiki.citations.forRevision(id, 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
+ assert.deepStrictEqual(wiki.citations.forRevision(id, r2).map((c) => c.url), ['https://example.org/a']);
+ const r3 = wiki.revert(id, { toRevision: 1, expectedRevision: r2, author: ALEX });
+ assert.strictEqual(wiki.revisions.get(id, r3).content, LONG);
+ assert.deepStrictEqual(wiki.citations.forRevision(id, r3).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
+ assert.deepStrictEqual(wiki.revisions.lineage(id).map((r) => r.number), [3, 2, 1]);
+ wiki.close();
+});
+
+test('one gate, two editorial policies: the wiki needs sources, the blog does not', () => {
+ const { wiki, blog } = setup();
+ const { id: unsourced } = wiki.createPage({ title: 'Barley', body: LONG, author: ALEX });
+ wiki.publish(unsourced, { revision: 1, actor: ALEX });
+ const { id: post, revision } = blog.draft({ title: 'Barley notes', body: LONG });
+ blog.publishNow(post, revision);
+ const [w] = wiki.published();
+ const [b] = blog.published();
+ assert.deepStrictEqual(w.decision.codes, ['unsourced']);
+ assert.strictEqual(b.decision.indexable, true);
+ assert.doesNotMatch(wiki.sitemapXml(), /barley/, 'unsourced page stays out of the sitemap');
+ assert.match(wiki.atom(), /Barley/, 'but is listable in the feed (noindex, not hidden)');
+ assert.match(blog.sitemapXml(), /posts\/barley-notes/);
+ wiki.close(); blog.close();
+});
+
+test('AI-generated wiki page: draft + noindex until a person reviews it', () => {
+ const { wiki } = setup();
+ const authorship = require('openvibe-publishing/authorship');
+ const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 1, runId: 'run_1' } });
+ const { id, initial } = wiki.createPage({ title: 'Spelt', body: LONG, author: EDITOR, authorship: rec, sources: [{ sourceItemId: 'src_9' }] });
+ assert.deepStrictEqual(initial, { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
+ assert.throws(() => wiki.publish(id, { revision: 1, actor: EDITOR }), /ai_generated_unreviewed/);
+ wiki.reviews.record({ entityId: id, revision: 1, reviewer: EDITOR, decision: 'approved' });
+ wiki.publish(id, { revision: 1, actor: EDITOR });
+ const [p] = wiki.published();
+ assert.strictEqual(p.decision.indexable, true);
+ assert.match(wiki.renderPage(p.page), /AI-generated by workflow wiki.generate_page v1, reviewed by a person/);
+ wiki.close();
+});
+
+test('scheduled blog publication is idempotent across a worker restart', async () => {
+ const { blog, clock, blogFile } = setup();
+ const { id, revision } = blog.draft({ title: 'Scheduled', body: LONG });
+ const at = clock() + 3600e3;
+ blog.schedulePublish(id, { revision, at });
+ blog.schedulePublish(id, { revision, at });
+ assert.strictEqual(blog.scheduler.jobs(id).length, 1);
+ clock.set(at);
+ const [job] = blog.scheduler.claim({ worker: 'w1' }); // worker 1 dies before finishing
+ assert.ok(job);
+ blog.close();
+ const blog2 = createBlog({ dbPath: blogFile, now: clock }); // "restart"
+ clock.advance(60001);
+ const out = await blog2.runScheduled('w2');
+ assert.strictEqual(out.done.length, 1);
+ assert.strictEqual(blog2.published().length, 1);
+ await blog2.runScheduled('w3');
+ assert.strictEqual(blog2.scheduler.jobs(id)[0].attempts, 2);
+ blog2.close();
+});
+
+test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted leave feeds and sitemaps', async () => {
+ const { wiki, blog, clock } = setup();
+ const { id } = wiki.createPage({ title: 'Rye', body: `${LONG}\n\nSee [wheat](/p/wheat).`, author: ALEX, sources: [{ url: 'https://example.org/rye', title: 'Rye facts', retrievedAt: '2026-09-20T10:00:00Z', licenseNote: 'CC BY 4.0' }] });
+ wiki.publish(id, { revision: 1, actor: ALEX });
+ wiki.rename(id, 'Rye (grain)');
+ const { id: post, revision } = blog.draft({ title: 'Crumb shots', body: LONG, authorName: 'Alex', tags: ['bread'] });
+ blog.media.attach({ entityId: post, mediaId: MED, role: 'inline', alt: 'crumb' });
+ blog.publishNow(post, revision);
+ const w = await listen(wiki.handler);
+ const b = await listen(blog.handler);
+ try {
+ let res = await w.get('/p/rye-grain');
+ let html = await res.text();
+ assert.strictEqual(res.status, 200);
+ assert.match(html, /<h1>Rye<\/h1>/, 'renaming the slug does not touch the title field');
+ assert.match(html, /grown extensively as a grain/, 'the content is in the HTML');
+ assert.match(html, /<a href="https:\/\/example.org\/rye" rel="noopener">Rye facts<\/a>, retrieved <time datetime="2026-09-20T10:00:00.000Z">/);
+ assert.match(html, /<meta name="robots" content="index, follow">/);
+ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye-grain">/);
+ assert.match(html, /application\/ld\+json/);
+ assert.doesNotMatch(html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, ''), /<script/i, 'no JavaScript needed');
+
+ res = await w.get('/p/rye');
+ assert.strictEqual(res.status, 301);
+ assert.strictEqual(res.headers.get('location'), '/p/rye-grain');
+
+ res = await b.get('/posts/crumb-shots');
+ html = await res.text();
+ assert.strictEqual(res.status, 200);
+ assert.match(html, /<img src="https:\/\/openvibe.media\/o\/med_/);
+ blog.media.markBroken(MED, 'deleted');
+ html = await (await b.get('/posts/crumb-shots')).text();
+ assert.match(html, /data-state="broken"/);
+ assert.doesNotMatch(html, /<img/);
+
+ // private: gone from page, feeds and sitemap; the event is internal
+ wiki.setVisibility(id, 'private', { actor: ALEX });
+ assert.strictEqual((await w.get('/p/rye-grain')).status, 404);
+ assert.doesNotMatch(await (await w.get('/sitemap.xml')).text(), /rye/);
+ assert.doesNotMatch(await (await w.get('/feed.atom')).text(), /<entry>/);
+ assert.strictEqual(JSON.parse(await (await w.get('/feed.json')).text()).items.length, 0);
+ const events = wiki.outbox();
+ assert.deepStrictEqual(events.map((e) => e.event_type), ['wiki.page.published', 'wiki.page.updated']);
+ assert.strictEqual(events[1].visibility, 'internal');
+ assert.deepStrictEqual(events[1].payload.document.acl, { public: false, subjects: [ALEX] });
+
+ blog.setVisibility(post, 'gated');
+ assert.strictEqual((await b.get('/posts/crumb-shots')).status, 404);
+ assert.doesNotMatch(await (await b.get('/feed.xml')).text(), /Crumb/);
+ assert.doesNotMatch(await (await b.get('/sitemap.xml')).text(), /crumb/);
+
+ // deleted: 410 on the page and on its old slug; tombstone event
+ clock.advance(1000);
+ wiki.remove(id, { actor: ALEX });
+ assert.strictEqual((await w.get('/p/rye-grain')).status, 410);
+ assert.strictEqual((await w.get('/p/rye')).status, 410);
+ const last = wiki.outbox().pop();
+ assert.strictEqual(last.event_type, 'wiki.page.deleted');
+ assert.strictEqual(last.payload.document.deleted, true);
+ assert.ok(!('body' in last.payload.document));
+ } finally { w.close(); b.close(); wiki.close(); blog.close(); }
+});
+
+test('feeds from both products carry only provided dates and authors', () => {
+ const { wiki, blog } = setup();
+ const { id } = wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });
+ wiki.publish(id, { revision: 1, actor: ALEX });
+ const { id: post, revision } = blog.draft({ title: 'No byline', body: LONG });
+ blog.publishNow(post, revision);
+ const rss = blog.rss();
+ assert.doesNotMatch(rss, /dc:creator/, 'no author invented for a post without a byline');
+ assert.match(rss, /<pubDate>Tue, 22 Sep 2026 12:00:00 GMT<\/pubDate>/);
+ const jf = blog.jsonFeedDoc();
+ assert.ok(!('authors' in jf.items[0]));
+ const atom = wiki.atom();
+ assert.doesNotMatch(atom, /<author>/);
+ assert.match(atom, /<published>2026-09-22T12:00:00.000Z<\/published>/);
+ wiki.close(); blog.close();
+});
+
+run();