openvibers/openvibe.wiki Commits

OpenVibe.Wiki service (Wave 16, Wiki half): spaces, page trees, immutable revisions with diff and revert, citations per revision, typed infoboxes, [[internal links]], Media attachments with an explicit broken state, Community discussion references, watchers, AI proposals held for a person's approval

OP OpenVibers committed 23 Sept 2026 · b869edfddf · 52 files, +7621 −58

- the ten wiki_* tables of roadmap 15.13 in Wiki's own SQLite, on openvibe-publishing v0.2.0 (revisions, citations, redirects, gate, feeds, sitemaps, JSON-LD, authorship, schedule, media, discussion, index hooks)
- server-rendered pages useful without JavaScript, including editing by plain form posts; Network SSO session layer; sitemaps, Atom/JSON feeds, robots.txt, llms.txt and per-page JSON only for public, published, indexable pages; members/private pages served Cache-Control private
- /api/v1 for people and service principals (one proposed wiki.* capability per route, X-OV-Subject), problem+json errors
- events through the SDK transactional outbox: wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted, wiki.watch.triggered, wiki.index_document.upserted|deleted for OpenVibe.Search
- seed: official "OpenVibe" space with ten pages summarised from the repositories' README/STATUS files, cited with GitHub permalinks
- tests, README, STATUS alpha, .env.example, systemd unit, nginx vhost, CI on Node 22.22.1, capability and service manifest proposals

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..be1feb0
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,63 @@
++# ═══════════════════════════════════════════════════════════════
++# OpenVibe.Wiki — Environment Configuration
++# Copy to .env and fill in values. In production this lives at /etc/openvibe/wiki.env (0600);
++# the systemd unit sets NODE_ENV, PORT and WIKI_DB_PATH itself. Names only here: never commit values.
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4800
++HOST=127.0.0.1
++NODE_ENV=development
++#BASE_URL=https://openvibe.wiki
++# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
++#TRUST_PROXY=2
++
++# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
++WIKI_DB_PATH=./data/wiki.db
++
++# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
++# from <url>/api/.well-known/jwks (internal URL first) and retried every 30 s until it loads.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++#OV_NETWORK_ISSUER=https://openvibe.network
++# Pin the key instead of fetching it (PEM; \n escapes allowed).
++#OV_NETWORK_PUBLIC_KEY=
++# A browser's user JWT is accepted when its aud contains one of these.
++#WIKI_USER_AUDIENCES=openvibe.wiki,openvibe.network
++
++# OAuth client `wiki` (browser sign-in, redirect <BASE_URL>/auth/callback) and service principal
++# svc:wiki (client credentials for Events, Community, Sources and Media) — one registration.
++OV_OAUTH_CLIENT_ID=wiki
++OV_OAUTH_CLIENT_SECRET=
++#OV_OAUTH_REDIRECT_URI=https://openvibe.wiki/auth/callback
++#COOKIE_SECURE=true
++
++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL is set (events wait in the outbox until then).
++#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_RELAY_INTERVAL_MS=2000
++
++# OpenVibe.Community — discussion threads (community.comment.write).
++#OV_COMMUNITY_URL=https://openvibe.community
++#OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
++
++# OpenVibe.Sources — citation items (sources.item.read). Unset: Sources item citations are refused.
++#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
++
++# OpenVibe.Media — attachment checks (media.object.read, namespace WIKI_MEDIA_APP) and public URLs.
++#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
++#OV_MEDIA_URL=https://openvibe.media
++#WIKI_MEDIA_APP=wiki
++#WIKI_MEDIA_VERIFY_INTERVAL_MS=21600000
++
++# Scheduled publication worker.
++#WIKI_SCHEDULE_INTERVAL_MS=15000
++#WIKI_WORKER_ID=wiki-1
++
++# Indexability gate policy for pages (openvibe-publishing/seo).
++#WIKI_GATE_MIN_WORDS=80
++#WIKI_GATE_REQUIRE_SOURCES=true
++#WIKI_GATE_MIN_SOURCES=1
++
++# Release reporting (openvibe-shared/release): the deployed commit, read from git when unset.
++#RELEASE_COMMIT=
++#MIN_CLIENT_RELEASE=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..a93248c
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,20 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # The wiki.* capabilities and the wiki service manifest are proposals (docs/) until the lead
++ # releases them in OpenVibe.Contracts; until then this check is expected to fail.
++ - run: npx openvibe-contracts-check --service wiki --src server
++ continue-on-error: true
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..07af271
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/README.md b/README.md
iindex 294aa7d..1ce32de 100644
---- a/README.md
++++ b/README.md
@@@ -2,77 +2,224 @@
> Wiki spaces with page trees, revisions, citations, media and discussion — editable together, source-backed.
--**Status:** placeholder — planning only, no runnable code yet.
--**Domain:** `openvibe.wiki`
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.5 and §12.15.
++**Status:** alpha (roadmap Wave 16, Wiki half). Runs and is tested; **not deployed**. The domain
++`openvibe.wiki` keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites)
++until the launch rule below holds.
++**Domain:** `openvibe.wiki` · **Port:** 4800 · **Service id:** `wiki`
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap Wave 16, §15.13, §29, §32.
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
--The knowledge product: spaces, canonical page identities/slugs, immutable revisions, redirects, citations and infoboxes. AI proposes revisions through registered workflows; humans publish. Community owns talk pages; Media owns attachments; Search indexes only published state.
++The knowledge product of the network: spaces (official editorial spaces and user spaces), page
++trees with canonical slugs, immutable revisions with diff and revert, manual editing by permitted
++people, typed infoboxes, `[[internal links]]`, citations attached to the revision that used them,
++Media attachments and Community discussion. AI proposes revisions; a person approves them; Wiki
++publishes. Search indexes only published, public state.
++
++It is the first full consumer of the shared publishing packages
++([openvibe-publishing](https://github.com/OpenVibers/OpenVibe.Publishing) v0.2.0, ADR-019): revisions,
++citations, redirects, the indexability gate, feeds, sitemaps, JSON-LD, authorship, scheduling,
++media references, discussion references and the Search index hooks all come from there. Wiki owns
++its publication state; the packages supply the mechanics.
## Owns
--- `wiki_spaces`, `wiki_pages`, `wiki_page_revisions`, `wiki_page_links`, `wiki_page_redirects`, `wiki_citations`, `wiki_infobox_values`, `wiki_permissions`, `wiki_watchers`, `wiki_ai_proposals`
++The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PATH`):
++
++| Table | What | Mechanics |
++|---|---|---|
++| `wiki_spaces` | spaces: official / user, public / members / private | Wiki |
++| `wiki_pages` | canonical page identity, slug, tree (`parent_id`), publication state, published revision | Wiki |
++| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE abort in SQLite triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |
++| `wiki_page_links` | `[[links]]` of every revision (backlinks, red links) | Wiki |
++| `wiki_page_redirects` | every historical path of a page or space (301; 410 when gone) | `openvibe-publishing/seo`, prefix `wiki_page` |
++| `wiki_citations` | sources per (page, revision), append-only | `openvibe-publishing/citations`, prefix `wiki` |
++| `wiki_infobox_values` | typed infobox values per (page, revision), append-only | Wiki |
++| `wiki_permissions` | space roles: owner / editor / viewer | Wiki |
++| `wiki_watchers` | who watches a page | Wiki |
++| `wiki_ai_proposals` | AI-authored revisions awaiting a person's decision | Wiki + `openvibe-publishing/authorship` |
++
++The packages add their helper tables under the same prefix (`wiki_page_drafts`, `wiki_page_reviews`,
++`wiki_page_attachments`, `wiki_schedule_jobs`, `wiki_discussion_refs`, `wiki_index_revisions`, the
++purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
## Does not own
--- discussion (Community)
--- attachments (Media)
--- publication truth by AI (never)
--
--## Planned surfaces
--
--- create/import a space, page tree navigation, edit/preview/publish/revert, history/diff, citation inspector, backlinks/link validation, AI research proposals with review state
--
--## Data (authority tables / families)
--
--- see above
--
--## Capabilities and events
--
--- `wiki.space.create`, `wiki.page.create|read`, `wiki.revision.propose|publish|revert`, `wiki.citation.attach`, `wiki.search`
--
--Events: ``wiki.space.updated``, ``wiki.revision.created``, ``wiki.page.published|updated|deleted``
++- **Discussion** — OpenVibe.Community owns comment threads; Wiki stores only the thread id
++ (`wiki_discussion_refs`) and renders or posts through Community's API.
++- **Attachments** — OpenVibe.Media owns the bytes; Wiki stores Media object ids (`med_…`) and their
++ last known state.
++- **Source items** — OpenVibe.Sources owns them; a citation stores the item id plus the URL, title,
++ retrieval time and license read from the item's provenance.
++- **The network index** — OpenVibe.Search owns it; Wiki sends `wiki.index_document.*` events.
++- **Publication truth by AI** — never. There are no AI provider calls in Wiki.
++- **Identity** — OpenVibe.Network (subjects `usr_…`, SSO, service principals).
++
++## What works
++
++- **Spaces**: official (created by Network staff, or a service acting as itself) and user spaces
++ (created by any signed-in person, who becomes owner). Visibility `public` (anyone), `members`
++ (any signed-in OpenVibe account), `private` (people with a role). Roles `owner` (settings, roles,
++ delete, page visibility), `editor` (write, publish, revert, review proposals), `viewer` (read a
++ private space). Staff act as owners of official spaces only; they get no silent access to user spaces.
++- **Pages**: a tree per space, canonical slugs, rename/move with history-aware 301s (chains
++ collapse; renaming a space redirects all its pages), deletion answers 410 at every old address.
++- **Revisions**: immutable, optimistic concurrency (`expected_revision` → 412 on conflict), word and
++ line diffs, revert = a new revision copying the old text, infobox and citations. Readers see the
++ published revision and its history; newer drafts and unapproved AI revisions are editor-only.
++- **Editing without JavaScript**: plain HTML forms with preview, "save" and "save and publish",
++ typed infobox rows (`Label | type | value`), citation rows, keep/drop per existing citation,
++ conflict handling that keeps the typed text.
++- **Infoboxes**: typed values (`text`, `number`, `date`, `url`, `boolean`, `page`, `media`),
++ validated, never coerced silently.
++- **Internal links**: `[[Title]]`, `[[Title|label]]`, `[[space:Title]]`; links follow redirects;
++ missing (or unreadable) targets are red links — a link to the create form for editors, plain text
++ for everyone else. "What links here" per page.
++- **Citations**: OpenVibe.Sources items (url, title, `retrieved_at`, license from the item's
++ provenance) or URLs with a required retrieval time; attached to one revision, carried forward
++ explicitly, never edited; a published revision's sources are fixed.
++- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects
++ `broken` and the page shows an explicit "no longer available" placeholder instead of an image; an
++ outage changes nothing (`check_failed`). A periodic check runs when Media is configured.
++- **Discussion**: public, published pages get a Community thread (resolved once, stored as a
++ reference); comments render server-side; signed-in people comment through a form (posted to
++ Community as that person). Failures show an explicit "could not be loaded" state.
++- **Watchers**: watch/unwatch; changes emit `wiki.watch.triggered` with the recipients who may still
++ read the page (never the actor). No email.
++- **Scheduling**: publish at a time (idempotent scheduling, lease-based worker, re-runs are no-ops).
++- **AI proposals (seam for OpenVibe.AI)**: a service with `wiki.revision.propose` files a revision
++ with `ai` authorship (workflow id + run id, stub-provider flag). It stays a pending proposal,
++ hidden from readers and refused by publish, until a person with the editor role approves it
++ (approval publishes it) or rejects it. Published AI text carries a disclosure at the item.
++- **Discovery (§32)**: server-rendered pages with canonical URLs, robots from the gate, JSON-LD from
++ real fields only (no invented author, image or date), breadcrumbs; `/sitemap.xml` (index) →
++ `/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);
++ `/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
++- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
++ (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text only
++ after review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
++ the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
++ feeds or search, and are served `Cache-Control: private, no-store`.
++- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
++ about the network's repositories, summarised from their README/STATUS files at pinned commits,
++ each cited with a GitHub permalink and retrieval time, authorship recorded as `imported`
++ ("summarised with AI assistance"). Idempotent.
++
++Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objects
++only), consuming Media/Sources change events (Media does not emit deletion events yet; checks are
++pull-based), per-space feeds, full-text ranking (Wiki's own `/api/v1/search` is a simple title/text
++match; network search is OpenVibe.Search).
++
++## Routes
++
++Pages (SSR, useful without JavaScript): `/`, `/recent`, `/search?q=`, `/new-space`, `/s/:space`,
++`/s/:space/new`, `/s/:space/settings`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),
++`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/diff/:a/:b` (`?mode=line`),
++`/w/:space/:slug/edit`, `/w/:space/:slug/revert?to=N`, `/w/:space/:slug/settings`,
++`POST /w/:space/:slug/watch|discuss`. Sign-in: `/auth/login|callback|logout|me|refresh|fedcm`
++(Network SSO, same session layer as OpenVibe.Community). Legal: `/terms`, `/privacy`, `/dmca`.
++Operations: `GET /api/health`, `GET /api/ready` (db required; Network key, Events relay, Community,
++Sources, Media optional → degraded), `GET /release.json`, `GET /metrics` (loopback only).
++
++Caching: anonymous views of public published pages are `public, max-age=60`; signed-in views,
++members/private pages, editing surfaces and errors are `private, no-store`. A page a visitor may
++not read answers 404, so its existence does not leak.
++
++## API `/api/v1`
++
++People use their Network user JWT (Bearer, or the `ov_token` cookie; cross-site cookie writes are
++refused). Services use a Network client-credentials token for audience `openvibe.wiki`; every route
++checks one capability, and content writes need the person in `X-OV-Subject` (whose space role
++applies). Errors are RFC 9457 problem+json. The full route list is at the top of
++[server/http/api.js](server/http/api.js).
++
++| Capability (proposed) | Routes |
++|---|---|
++| `wiki.space.create` | `POST /spaces`, `PATCH /spaces/:space`, `PUT /spaces/:space/roles/:subject` |
++| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
++| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
++| `wiki.revision.propose` | `POST /proposals` |
++| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /proposals/:id/review` |
++| `wiki.revision.revert` | `POST /pages/:id/revert` |
++| `wiki.citation.attach` | `POST /pages/:id/revisions/:n/citations` |
++| `wiki.search.query` | `GET /search` |
++
++The ids are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
++`wiki.search` becomes the three-segment `wiki.search.query`), with the service manifest in
++[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until a contracts
++release defines them, [server/auth/capabilities.js](server/auth/capabilities.js) decides grants with
++the contracts library's own matching rule.
++
++## Events
++
++Through the SDK transactional outbox (`openvibe-sdk/events`, table `wiki_event_outbox`), in the same
++transaction as the change; the relay publishes to OpenVibe.Events when `EVENTS_URL` is set.
++
++- `wiki.space.updated` — created, settings, visibility, roles, renamed, deleted
++- `wiki.revision.created` — every revision (internal: a revision is a draft until published)
++- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages)
++- `wiki.watch.triggered` — recipients who may read the page
++- `wiki.index_document.upserted | deleted` — `search.index-document@1` documents and tombstones for
++ OpenVibe.Search, with a monotonic index revision (`createIndexSequencer`)
++
++## Running it
++
++```bash
++fnm exec --using=22.22.1 npm install
++cp .env.example .env
++fnm exec --using=22.22.1 npm run seed # the official "OpenVibe" space
++fnm exec --using=22.22.1 npm run dev # http://localhost:4800
++fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases, no network
++```
++
++Production (planned): `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
++[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database
++`/var/lib/openvibe-wiki/wiki.db`, nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
## Depends on
--- shared publishing packages (`@openvibe/publishing-*`)
--- OpenVibe.Network
--- OpenVibe.Media
--- OpenVibe.Community
--- OpenVibe.AI + source registry
--- Search
--- OpenVibe.Events
--
--## Acceptance (must be true before "done")
--
--- create → edit → publish → revise → diff → revert keeps an immutable lineage
--- citations stay attached to the exact revision that used them
--- a private/deleted page leaves search, sitemaps and caches consistently
--- a public page is useful with JavaScript disabled
--
--## Bootstrap / extraction source
--
--No current implementation; bootstraps from the shared publishing runtime. First full publishing consumer alongside Blog (Wave 14).
++- `openvibe-publishing` v0.2.0, `openvibe-contracts` v0.13.0, `openvibe-shared` v1.3.0 (chrome,
++ release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.2.2 (auth, events
++ outbox) — pinned release tarballs.
++- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
++ OpenVibe.Sources, OpenVibe.Media, OpenVibe.Search (consumer of the index events). All but the
++ Network key are optional at runtime and degrade to explicit failure states.
++- OpenVibe.AI for proposals (not built yet: the proposal API is the seam).
++
++## Acceptance (tested in `test/`)
++
++- create → edit → publish → revise → diff → revert keeps an immutable lineage (`revisions.test.js`)
++- citations and infobox values stay attached to the exact revision that used them (`revisions.test.js`)
++- scheduled publication is idempotent across worker restarts (`schedule.test.js`)
++- a private, members-only or deleted page leaves sitemaps, feeds and the Search index (tombstone)
++ and is served `Cache-Control: private`; renames 301, deletions 410 (`visibility.test.js`)
++- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
++- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
++- permissions are enforced; visitors without SSO read public content only; AI proposals need a
++ person's approval (`permissions.test.js`)
++- every event is a valid `events.event-envelope@1` and every index document a valid
++ `search.index-document@1` (`visibility.test.js`); the proposals validate against the contracts
++ schemas (`proposals.test.js`); user text is escaped everywhere (`content.test.js`)
## Launch rule
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
++This repository does not make the product real on its own. `openvibe.wiki` keeps its placeholder
++page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
++following hold (plan §12.12):
++
++1. an owning runtime with health/readiness endpoints and observability — **built** (`/api/health`, `/api/ready`, `/metrics`);
++2. canonical identity/auth integration (Network subjects, a scoped service principal) — **built**, principal and grants not provisioned yet;
++3. server-rendered public routes useful without JavaScript — **built**;
++4. real persistence and end-to-end workflows — **built**, not deployed;
++5. capability and event registration against OpenVibe.Contracts — **proposed** in `docs/`, not released;
++6. a migration/seed strategy, a security/threat review, sitemap/robots/feed behaviour — seed and discovery **built**; the security review is the lead's;
++7. acceptance tests proving the advertised functionality — **built** (`npm test`).
++
++The launch release removes `openvibe.wiki` from `OpenVibe.Sites/sites.json`, switches routing to this
++service and registers its maturity in the ecosystem registry **in the same release it goes live**.
++A placeholder is never counted as an implemented service, and this README does not call the
++product live until that release has happened.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 9748be7..64ba3b4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,18 @@
{
"repository": "OpenVibers/OpenVibe.Wiki",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "runtime": "node 22, express, better-sqlite3",
++ "port": 4800,
++ "domain": "openvibe.wiki",
++ "contracts": "openvibe-contracts v0.13.0",
++ "packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
++ "capabilities": ["wiki.space.create", "wiki.page.create", "wiki.page.read", "wiki.revision.propose", "wiki.revision.publish", "wiki.revision.revert", "wiki.citation.attach", "wiki.search.query"],
++ "capabilitiesRegistered": false,
++ "deployed": false,
++ "onOpenVibeSites": true,
++ "seed": "official space \"openvibe\" (10 pages summarised from the repositories' README/STATUS files, cited)",
++ "updated": "2026-09-22"
}
ddiff --git a/deploy/nginx/openvibe.wiki.conf b/deploy/nginx/openvibe.wiki.conf
nnew file mode 100644
iindex 0000000..a59e0b3
---- /dev/null
++++ b/deploy/nginx/openvibe.wiki.conf
@@@ -0,0 +1,100 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.wiki — Nginx Configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.wiki.conf — keep them in sync and reload
++## nginx after changing it. Not live: openvibe.wiki is served by OpenVibe.Sites (placeholder)
++## until the launch release removes it from OpenVibe.Sites/sites.json in the same release.
++##
++## The Node app (port 4800, systemd unit openvibe-wiki) renders every page. nginx terminates
++## TLS, redirects www → apex, rate-limits auth, API and form posts, and never caches anything
++## itself: Wiki's Cache-Control decides (public, max-age=60 only for anonymous views of public
++## pages; private, no-store for everything else).
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovwiki_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovwiki_auth:10m rate=10r/m;
++# Form posts only (reads are not limited here): an empty key is not counted.
++map $request_method $ovwiki_write_key { default ""; POST $binary_remote_addr; }
++limit_req_zone $ovwiki_write_key zone=ovwiki_write:10m rate=30r/m;
++
++# ── HTTP → HTTPS redirect ──────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.wiki www.openvibe.wiki;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.wiki$request_uri; }
++}
++
++# ── www → apex ─────────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.wiki;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.wiki/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.wiki/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.wiki$request_uri;
++}
++
++# ── HTTPS server ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.wiki;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.wiki/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.wiki/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.wiki.access.log;
++ error_log /var/log/nginx/openvibe.wiki.error.log;
++
++ # Pages are at most 200 000 characters; the API accepts 512 KB JSON bodies.
++ client_max_body_size 1m;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header X-Frame-Options SAMEORIGIN always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/feed+json application/atom+xml image/svg+xml text/plain application/xml text/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ # Never let a shared cache here hold a personalised or private answer.
++ proxy_no_cache 1;
++ proxy_cache_bypass 1;
++
++ # Prometheus metrics are for the host only (the app also refuses non-loopback callers).
++ location = /metrics { return 404; }
++
++ location /auth/ {
++ limit_req zone=ovwiki_auth burst=10 nodelay;
++ proxy_pass http://127.0.0.1:4800;
++ }
++
++ location /api/ {
++ limit_req zone=ovwiki_api burst=40 nodelay;
++ proxy_pass http://127.0.0.1:4800;
++ }
++
++ location / {
++ limit_req zone=ovwiki_write burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4800;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-wiki.service b/deploy/systemd/openvibe-wiki.service
nnew file mode 100644
iindex 0000000..b800ab5
---- /dev/null
++++ b/deploy/systemd/openvibe-wiki.service
@@@ -0,0 +1,32 @@
++[Unit]
++Description=OpenVibe.Wiki — wiki spaces, revisions, citations and discussion (server-rendered)
++# Network (openvibe-network) signs the tokens this service verifies. Wiki starts without it and
++# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
++# Events, Community, Sources and Media are optional: each degrades to an explicit failure state.
++After=network.target
++Wants=openvibe-network.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.wiki
++EnvironmentFile=/etc/openvibe/wiki.env
++Environment=NODE_ENV=production
++Environment=PORT=4800
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.wiki/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# The code tree stays read-only; the database lives in /var/lib/openvibe-wiki.
++StateDirectory=openvibe-wiki
++Environment=WIKI_DB_PATH=/var/lib/openvibe-wiki/wiki.db
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/capabilities-proposal/wiki.citation.attach.json b/docs/capabilities-proposal/wiki.citation.attach.json
nnew file mode 100644
iindex 0000000..45357c7
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.citation.attach.json
@@@ -0,0 +1,19 @@
++{
++ "id": "wiki.citation.attach",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Attach citations (an OpenVibe.Sources item id, whose URL, title, retrieval time and license come from the item's provenance, or an http(s) URL with its retrieval time) to the newest, unpublished revision of a page. Citations are append-only and stay with the revision that used them.",
++ "permissions": [
++ "wiki:citation:write"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/pages/:id/revisions/:n/citations"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.page.create.json b/docs/capabilities-proposal/wiki.page.create.json
nnew file mode 100644
iindex 0000000..521cf1f
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.page.create.json
@@@ -0,0 +1,28 @@
++{
++ "id": "wiki.page.create",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages and changing page visibility need the owner role.",
++ "permissions": [
++ "wiki:page:write"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.revision.created",
++ "wiki.page.updated",
++ "wiki.page.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/spaces/:space/pages",
++ "POST /api/v1/pages/:id/revisions",
++ "PATCH /api/v1/pages/:id",
++ "DELETE /api/v1/pages/:id",
++ "POST /api/v1/pages/:id/media",
++ "POST /api/v1/pages/:id/media/verify"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.page.read.json b/docs/capabilities-proposal/wiki.page.read.json
nnew file mode 100644
iindex 0000000..7e99e0b
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.page.read.json
@@@ -0,0 +1,24 @@
++{
++ "id": "wiki.page.read",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read pages, revisions, diffs, citations and AI proposals as the person named in X-OV-Subject sees them (without a subject: public, published content only). Members-only and private pages follow the space's roles; drafts and history of unpublished pages need the editor role.",
++ "permissions": [
++ "wiki:page:read"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/pages/:id",
++ "GET /api/v1/pages/:id/revisions",
++ "GET /api/v1/pages/:id/revisions/:n",
++ "GET /api/v1/pages/:id/diff",
++ "GET /api/v1/pages/:id/revisions/:n/citations",
++ "GET /api/v1/proposals/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.revision.propose.json b/docs/capabilities-proposal/wiki.revision.propose.json
nnew file mode 100644
iindex 0000000..ff73443
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.revision.propose.json
@@@ -0,0 +1,21 @@
++{
++ "id": "wiki.revision.propose",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Propose a revision (or a new page) as the output of an OpenVibe.AI workflow run. The proposal becomes an immutable revision with ai authorship (workflow id and run id, stub-provider flag) and a pending proposal; it is never published until a person with the editor role approves it.",
++ "permissions": [
++ "wiki:revision:propose"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.revision.created"
++ ],
++ "implementedBy": [
++ "POST /api/v1/proposals"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.revision.publish.json b/docs/capabilities-proposal/wiki.revision.publish.json
nnew file mode 100644
iindex 0000000..f30cd16
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.revision.publish.json
@@@ -0,0 +1,28 @@
++{
++ "id": "wiki.revision.publish",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's approval or rejection of an AI proposal, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
++ "permissions": [
++ "wiki:revision:publish"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.page.published",
++ "wiki.page.updated",
++ "wiki.page.unpublished",
++ "wiki.index_document.upserted",
++ "wiki.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/pages/:id/publish",
++ "POST /api/v1/pages/:id/schedule",
++ "POST /api/v1/pages/:id/unpublish",
++ "POST /api/v1/proposals/:id/review"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.revision.revert.json b/docs/capabilities-proposal/wiki.revision.revert.json
nnew file mode 100644
iindex 0000000..e341833
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.revision.revert.json
@@@ -0,0 +1,22 @@
++{
++ "id": "wiki.revision.revert",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Revert a page to an earlier revision by adding a new revision that copies its text, infobox and citations (history is never rewritten), on behalf of the editor named in X-OV-Subject. A revert of a published page is published.",
++ "permissions": [
++ "wiki:revision:revert"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.revision.created",
++ "wiki.page.updated"
++ ],
++ "implementedBy": [
++ "POST /api/v1/pages/:id/revert"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.search.query.json b/docs/capabilities-proposal/wiki.search.query.json
nnew file mode 100644
iindex 0000000..65daa41
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.search.query.json
@@@ -0,0 +1,19 @@
++{
++ "id": "wiki.search.query",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Search published pages by title and text, returning only pages the person named in X-OV-Subject may read (without a subject: public pages only). Network-wide discovery goes through OpenVibe.Search, which Wiki feeds with wiki.index_document.* events.",
++ "permissions": [
++ "wiki:search"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "wiki-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/search"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/wiki.space.create.json b/docs/capabilities-proposal/wiki.space.create.json
nnew file mode 100644
iindex 0000000..938570c
---- /dev/null
++++ b/docs/capabilities-proposal/wiki.space.create.json
@@@ -0,0 +1,23 @@
++{
++ "id": "wiki.space.create",
++ "version": "1.0",
++ "owner": "wiki",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Create a wiki space and change a space's settings and roles. People create user spaces they own; official (editorial) spaces are created by staff or by a service acting as itself. Settings and role changes need the owner role of the space (for a service: the X-OV-Subject person).",
++ "permissions": [
++ "wiki:space:write"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "wiki-write",
++ "events": [
++ "wiki.space.updated"
++ ],
++ "implementedBy": [
++ "POST /api/v1/spaces",
++ "PATCH /api/v1/spaces/:space",
++ "PUT /api/v1/spaces/:space/roles/:subject"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..a51020d
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,42 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.13.0 <1.0.0"
++ },
++ "id": "wiki",
++ "name": "OpenVibe.Wiki",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Wiki",
++ "domains": [
++ "openvibe.wiki"
++ ],
++ "publicOrigin": "https://openvibe.wiki",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "wiki.space.create",
++ "wiki.page.create",
++ "wiki.page.read",
++ "wiki.revision.propose",
++ "wiki.revision.publish",
++ "wiki.revision.revert",
++ "wiki.citation.attach",
++ "wiki.search.query"
++ ],
++ "eventsProduced": [
++ "wiki.space.updated",
++ "wiki.revision.created",
++ "wiki.page.published",
++ "wiki.page.updated",
++ "wiki.page.unpublished",
++ "wiki.page.deleted",
++ "wiki.watch.triggered",
++ "wiki.index_document.upserted",
++ "wiki.index_document.deleted"
++ ],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "wiki.*"
++ ],
++ "notes": "Wiki spaces (official and user), page trees with canonical slugs and history-aware redirects, immutable revisions with diff and revert, typed infoboxes, [[internal links]] with red links, citations per revision (Sources items or URLs with retrieval time), Media attachments with an explicit broken state, Community discussion references, watchers notified through wiki.watch.triggered, AI proposals held for a person's approval. Server-rendered public routes useful without JavaScript; sitemaps, Atom/JSON feeds and Search index events only for public, published, indexable pages (openvibe-publishing v0.2.0). Not deployed; openvibe.wiki stays on OpenVibe.Sites until launch."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..1bc68c5
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1546 @@
++{
++ "name": "openvibe-wiki",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-wiki",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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==",
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "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==",
++ "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==",
++ "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==",
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "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/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "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==",
++ "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==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "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==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "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/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "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==",
++ "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==",
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "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==",
++ "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==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "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==",
++ "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/jsonwebtoken/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/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/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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==",
++ "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==",
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.13.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
++ "integrity": "sha512-MeIGJYHQsbr6ISeEErduUw/PD52X5IBclH2ht/G/t4lSX5X4o2AwcZYLEzOZrcbaXQyplgC7r86RCGFvi1ifUA==",
++ "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-publishing": {
++ "version": "0.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-publishing/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/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "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.",
++ "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/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "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==",
++ "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==",
++ "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/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "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/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/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/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "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==",
++ "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==",
++ "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/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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==",
++ "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==",
++ "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==",
++ "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==",
++ "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/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.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==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..5c3446c
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,31 @@
++{
++ "name": "openvibe-wiki",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Wiki: wiki spaces with page trees, immutable revisions, diff and revert, citations, infoboxes, internal links, Media attachments and Community discussion, server-rendered and useful without JavaScript.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "seed": "node scripts/seed.js",
++ "test": "node test/run.js"
++ },
++ "keywords": ["openvibe", "wiki", "publishing"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Wiki",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/public/css/wiki.css b/public/css/wiki.css
nnew file mode 100644
iindex 0000000..fc890e9
---- /dev/null
++++ b/public/css/wiki.css
@@@ -0,0 +1,73 @@
++/* OpenVibe.Wiki — small, readable, works without JavaScript. Colours follow the network theme
++ variables (set by theme-loader.js) with dark defaults matching the critical canvas. */
++:root {
++ --wk-bg: var(--bg-primary, #0a0f1c);
++ --wk-panel: var(--bg-secondary, #111a2c);
++ --wk-text: var(--text-primary, #e6edf7);
++ --wk-muted: var(--text-secondary, #9fb0c8);
++ --wk-line: var(--border-color, rgba(255, 255, 255, .12));
++ --wk-accent: var(--accent, #60a5fa);
++ --wk-red: #f87171;
++ --wk-ok: #34d399;
++ --wk-warn: #fbbf24;
++}
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--wk-bg); color: var(--wk-text); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--wk-accent); }
++a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--wk-accent); outline-offset: 2px; }
++.wk-skip { position: absolute; left: -9999px; }
++.wk-skip:focus { left: 16px; top: 8px; background: var(--wk-panel); padding: 6px 10px; z-index: 10; }
++.wk-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++.wk-bar { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; max-width: 1100px; margin: 0 auto; padding: 12px 16px; border-bottom: 1px solid var(--wk-line); }
++.wk-brand { font-weight: 700; color: var(--wk-text); text-decoration: none; }
++.wk-search { display: flex; gap: 6px; flex: 1 1 240px; }
++.wk-search input { flex: 1; min-width: 0; }
++.wk-account { font-size: 14px; color: var(--wk-muted); }
++.wk-main { max-width: 1100px; margin: 0 auto; padding: 16px 16px 48px; overflow-wrap: anywhere; }
++h1 { font-size: 1.9rem; line-height: 1.25; margin: .4em 0 .3em; }
++h2 { font-size: 1.3rem; margin: 1.6em 0 .5em; border-bottom: 1px solid var(--wk-line); padding-bottom: .2em; }
++.wk-muted { color: var(--wk-muted); }
++.wk-tag { display: inline-block; font-size: 12px; padding: 0 6px; border: 1px solid var(--wk-line); border-radius: 10px; color: var(--wk-muted); }
++.wk-notice { padding: 8px 12px; border-left: 4px solid var(--wk-accent); background: var(--wk-panel); }
++.wk-notice.wk-warn { border-color: var(--wk-warn); }
++.wk-notice.wk-error { border-color: var(--wk-red); }
++.wk-notice.wk-ok { border-color: var(--wk-ok); }
++.wk-notice.wk-ai { border-color: #a78bfa; }
++.wk-button, button { display: inline-block; background: var(--wk-accent); color: #0a0f1c; border: 0; border-radius: 6px; padding: 6px 14px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }
++input, select, textarea { font: inherit; color: var(--wk-text); background: var(--wk-panel); border: 1px solid var(--wk-line); border-radius: 6px; padding: 6px 8px; max-width: 100%; }
++textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
++.wk-form label { display: block; margin: 10px 0; }
++.wk-form label > input[type=text], .wk-form label > input[type=url], .wk-form label > input[type=search], .wk-form label > select { display: block; width: 100%; margin-top: 4px; }
++.wk-form .wk-check { display: block; }
++.wk-inline { display: inline; }
++fieldset { border: 1px solid var(--wk-line); border-radius: 6px; margin: 12px 0; }
++.ov-breadcrumbs ol { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0 0 8px; font-size: 14px; }
++.ov-breadcrumbs li + li::before { content: "›"; margin-right: 4px; color: var(--wk-muted); }
++.wk-article header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; }
++.wk-actions { font-size: 14px; }
++.wk-infobox { float: right; clear: right; width: min(320px, 100%); margin: 0 0 16px 16px; background: var(--wk-panel); border: 1px solid var(--wk-line); border-radius: 6px; }
++.wk-infobox table { width: 100%; border-collapse: collapse; font-size: 14px; }
++.wk-infobox th, .wk-infobox td { text-align: left; vertical-align: top; padding: 6px 8px; border-top: 1px solid var(--wk-line); }
++.wk-infobox tr:first-child th, .wk-infobox tr:first-child td { border-top: 0; }
++.wk-content pre, .ov-diff { overflow-x: auto; background: var(--wk-panel); padding: 10px; border-radius: 6px; }
++.ov-diff { white-space: pre-wrap; }
++.ov-diff ins { background: rgba(52, 211, 153, .22); text-decoration: underline; }
++.ov-diff del { background: rgba(248, 113, 113, .22); }
++.ov-redlink { color: var(--wk-red); }
++.ov-media { margin: 12px 0; }
++.ov-media img { max-width: 100%; height: auto; }
++.ov-media-missing { padding: 24px; border: 1px dashed var(--wk-red); color: var(--wk-red); border-radius: 6px; }
++.wk-sources blockquote { margin: 4px 0 0 12px; color: var(--wk-muted); font-style: italic; }
++.wk-meta { clear: both; font-size: 14px; color: var(--wk-muted); border-top: 1px solid var(--wk-line); margin-top: 24px; }
++table { border-collapse: collapse; }
++.wk-history, .wk-fields { width: 100%; font-size: 14px; display: block; overflow-x: auto; }
++.wk-history th, .wk-history td, .wk-fields th, .wk-fields td { padding: 4px 8px; border-bottom: 1px solid var(--wk-line); text-align: left; vertical-align: top; }
++.wk-history tr.wk-live { background: var(--wk-panel); }
++.wk-tree { padding-left: 1.2em; }
++.wk-comments { list-style: none; padding: 0; }
++.wk-comments li { border-top: 1px solid var(--wk-line); padding: 8px 0; }
++@media (max-width: 640px) {
++ .wk-infobox { float: none; margin: 0 0 16px; width: 100%; }
++ h1 { font-size: 1.5rem; }
++}
++@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
ddiff --git a/scripts/seed.js b/scripts/seed.js
nnew file mode 100644
iindex 0000000..2e3f859
---- /dev/null
++++ b/scripts/seed.js
@@@ -0,0 +1,32 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Import the official "OpenVibe" space (seeds/openvibe.json) into Wiki's database.
++ *
++ * npm run seed # create + publish (idempotent: existing pages are left alone)
++ * npm run seed -- --draft # create as drafts, publish later from the page settings
++ * npm run seed -- --file other.json
++ *
++ * Uses WIKI_DB_PATH like the server. Events for the new pages go to the outbox and are delivered
++ * by the server's relay (EVENTS_URL) the next time it runs.
++ */
++require('dotenv').config();
++const fs = require('fs');
++const path = require('path');
++const { load } = require('../server/config');
++const { openDb, createStores } = require('../server/db');
++const { createPlatform } = require('../server/integrations/platform');
++const { createWikiService } = require('../server/wiki/service');
++const { seed } = require('../server/wiki/seed');
++
++const args = process.argv.slice(2);
++const fileArg = args.indexOf('--file');
++const file = fileArg >= 0 ? args[fileArg + 1] : path.join(__dirname, '..', 'seeds', 'openvibe.json');
++const config = load();
++const db = openDb(config.dbPath);
++const stores = createStores(db);
++const platform = createPlatform({ config, db });
++const svc = createWikiService({ db, stores, outbox: platform.outbox, config });
++const out = seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });
++console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${platform.outbox.pending()} event(s) waiting in the outbox`);
++db.close();
ddiff --git a/seeds/openvibe.json b/seeds/openvibe.json
nnew file mode 100644
iindex 0000000..45886f4
---- /dev/null
++++ b/seeds/openvibe.json
@@@ -0,0 +1,539 @@
++{
++ "space": {
++ "slug": "openvibe",
++ "name": "OpenVibe",
++ "description": "Official space describing the OpenVibe network and its repositories, summarised from their README and STATUS files.",
++ "kind": "official",
++ "visibility": "public"
++ },
++ "pages": [
++ {
++ "title": "OpenVibe",
++ "parent": null,
++ "body": "The **OpenVibe network** is a set of services built in the open by OpenVibers, whose repositories are published in the [OpenVibers](https://github.com/OpenVibers) GitHub organisation. [[OpenVibe.Network]] is the network's identity and account service. This space summarises nine of the network's repositories as their README and STATUS files describe them at the cited commits.\n\n## Identity, streaming, media and community\n\n- [[OpenVibe.Network]] — accounts, OAuth2 single sign-on, notifications and the network-wide OpenCoins wallet.\n- [[OpenVibe.Live]] — streaming ingest, viewer playback, chat, channels, monetization, moderation and restreaming.\n- [[OpenVibe.Media]] — a multi-tenant media service for VODs, clips, pastes, files and thumbnails.\n- [[OpenVibe.Community]] — pastes, a forum, the comment threads other products embed, and Pulse, the network's public activity.\n\n## Realignment plan repositories\n\nFive repositories refer to the \"OpenVibe End-to-End Realignment & Implementation Plan, revision 3\". Their stated status at the cited commits:\n\n- [[OpenVibe.Events]] — durable events, subscriptions, delivery, retry, dead letters and replay; \"alpha\", not deployed.\n- [[OpenVibe.Search]] — permission-aware discovery; \"alpha\", not deployed.\n- [[OpenVibe.Sources]] — the source registry and ingestion workers; \"alpha\", not deployed, with its seeded sources disabled.\n- [[OpenVibe.Publishing]] — shared publishing packages, which are code with no runtime of their own; \"alpha\".\n- [[OpenVibe.Wiki]] — wiki spaces; a \"placeholder\" with no runnable code at the cited commit.\n\n## How the services relate\n\n[[OpenVibe.Live]] depends on [[OpenVibe.Network]] for SSO and the OpenCoins wallet, and on [[OpenVibe.Media]] for VOD recording, clips, pastes and thumbnails. [[OpenVibe.Events]] is meant to replace the best-effort internal POSTs and webhooks between Live, Media, Network and Community. [[OpenVibe.Sources]] sends its events to Events, and [[OpenVibe.Search]] takes index documents in through Events. [[OpenVibe.Publishing]] builds the index documents Search consumes.\n\nThe Events, Search, Sources and Wiki READMEs each state that the service's domain keeps a placeholder page on OpenVibe.Sites until the plan's launch rule holds.\n",
++ "summary": "Overview of the OpenVibe network and the nine repositories described in this space, with their stated status.",
++ "infobox": [
++ {
++ "key": "organization",
++ "label": "Organization",
++ "type": "url",
++ "value": "https://github.com/OpenVibers"
++ },
++ {
++ "key": "website",
++ "label": "Website",
++ "type": "url",
++ "value": "https://openvibe.network"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/blob/3c70afafb618516ea9a68f0260f47a3ac54d48fc/README.md",
++ "title": "OpenVibe.Network README (commit 3c70afa)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Identity and account service for the OpenVibe network."
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Live/blob/c3847871148541a1eba7ee966809853d49f3b5b2/README.md",
++ "title": "OpenVibe.Live README (commit c384787)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Events/blob/0c7f9c58dc2a31cbb2e0e1ba39086bbe4bcca20a/README.md",
++ "title": "OpenVibe.Events README (commit 0c7f9c5)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Replaces the best-effort internal POSTs/webhooks between Live, Media, Network and Community."
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/blob/12b4046db06a4fd34beb9fe5d4e7a7544787b96b/README.md",
++ "title": "OpenVibe.Search README (commit 12b4046)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Sources/blob/f727a40c3f979f42b3625782b5b71c0ebdc5617a/README.md",
++ "title": "OpenVibe.Sources README (commit f727a40)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/blob/0ff38e191154fb36978569102c7630ef55b30942/README.md",
++ "title": "OpenVibe.Publishing README (commit 0ff38e1)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/blob/0ff38e191154fb36978569102c7630ef55b30942/STATUS.json",
++ "title": "OpenVibe.Publishing STATUS.json (commit 0ff38e1)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"plan\": \"OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)\""
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Wiki/blob/b8aac8e0d7c2d8c31999da3a4ff87d736eba8d3a/README.md",
++ "title": "OpenVibe.Wiki README (commit b8aac8e)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers)."
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Network",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Network** is the identity and account service of the OpenVibe network. Its README says it manages \"user accounts, OAuth2 authorization, theme preferences, notifications, email alerts, anonymous identities, the network-wide OpenCoins wallet, and internal APIs that connect all OpenVibe services together\". The README gives its address as `https://openvibe.network` and its port as 4000.\n\n## What it does\n\n- **Single sign-on** — central registration and login, with an OAuth2 Authorization Code flow for OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games and OpenVibe.Media. Access tokens are RS256 JWTs valid for 24 hours that any service can verify with the public key; refresh tokens rotate on use and last 30 days.\n- **Notifications** — services push notifications to a central API and clients poll every 15 seconds. Critical notifications can also be delivered by email.\n- **Anonymous users and account switching** — anonymous identities that can later be linked to a registered account, and switching between up to five accounts.\n- **Theme catalog** — shared themes whose preferences sync across services.\n- **OpenCoins wallet** — a network-wide currency, with balance and history for users and atomic credit, debit and transfer endpoints for services, deduplicated by idempotency key.\n- **Internal API** — server-to-server endpoints for token verification, user lookup, notification push, account linking and audit logging, protected by `X-Internal-Key`.\n- **Admin panel** and a built-in helper for Let's Encrypt certificates and Nginx configuration previews.\n\n## Relationship to other services\n\nThe README lists [[OpenVibe.Live]] (port 3000), OpenVibe.Tools (4001), OpenVibe.Games (8000) and [[OpenVibe.Media]] (4100) as connected services, each with its own OAuth client id. Network also serves the browser components of the `openvibe-shared` package, such as the navbar, notification UI and account switcher, under `/shared/`.\n\n## Status\n\nThe README does not state a release stage. It documents a readiness endpoint at `GET /api/ready` and an operator page at `GET /status` that shows each OpenVibe service as up, degraded, down, not running or unknown. Its license section says the license is the same as that of the parent OpenVibe project.\n",
++ "summary": "Identity and account service of the OpenVibe network: OAuth2 single sign-on, notifications and the OpenCoins wallet.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Network"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "openvibe.network"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4000
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/blob/3c70afafb618516ea9a68f0260f47a3ac54d48fc/README.md",
++ "title": "OpenVibe.Network README (commit 3c70afa)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Identity and account service for the OpenVibe network."
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Live",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Live** is described in its README as \"the streaming front of the OpenVibe network\". The repository contains the Live server, browser assets and runtime configuration.\n\n## What it owns\n\nAccording to the README, Live owns:\n\n- streaming ingest (RTMP, WHIP, WebRTC and JSMPEG) and viewer playback;\n- chat, moderation and anonymous chat support;\n- channels, streamer controls, admin endpoints and restream management;\n- Vibes (PayPal tipping and cashout), channel points and comments;\n- the song-request/watch-party queue;\n- live-stream thumbnails and AI stream memories.\n\n## Dependencies\n\nLive depends on two sibling services. [[OpenVibe.Network]] provides the SSO/OAuth2 provider and JWT public-key verification, the OpenCoins wallet, URL registry overrides, notifications and shared browser assets. [[OpenVibe.Media]] provides VOD recording, storage, playback and health, asynchronous clip cutting, pastes, VOD and clip thumbnails, and B2/R2 storage tiering.\n\nLive keeps its existing `/api/vods`, `/api/clips`, `/api/pastes` and `/api/thumbnails` paths through thin proxies that forward to Media, and Media calls back at `POST /internal/media-webhook` when a VOD or clip is ready. OpenCoins earn and spend operations go through the Network wallet API, while Vibes and per-streamer channel points stay local to Live.\n\n## Runtime\n\nThe entry point is `server/index.js` on port 3000, a Node/Express backend whose primary database is SQLite (`data/live.db`). RTMP ingest uses `node-media-server`, and an optional WebRTC SFU uses `mediasoup`. The README lists Node.js 18+, npm, FFmpeg, a running OpenVibe.Network and, for media features, a running OpenVibe.Media as requirements. Its nginx reference configuration covers `openvibe.live`, `www.openvibe.live` and `ingest.openvibe.live`.\n\n## Status\n\nThe README does not state a release stage. It documents a production path (`/opt/openvibe.live`) and a deploy script under which static-only changes never restart the server.\n",
++ "summary": "The streaming service of the OpenVibe network: ingest, playback, chat, channels, monetization, moderation and restreaming.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Live"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "openvibe.live"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 3000
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "Node.js 18+, Express, SQLite"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Live/blob/c3847871148541a1eba7ee966809853d49f3b5b2/README.md",
++ "title": "OpenVibe.Live README (commit c384787)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "the streaming front of the OpenVibe network"
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Community",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Community** is the community hub of the OpenVibe network; its README opens with the address `https://openvibe.community`. It is the home of **pastes** (code, text and screenshots with a link), the **forum** (spaces, threads and posts), the **comment threads** other OpenVibe products embed, and **Pulse**, the network's public activity. The README adds: \"Submissions follow.\"\n\nIt is a small Node/Express app (CommonJS, one SQLite database) that server-renders every page, so crawlers and readers without JavaScript get the whole page. Its default listen port is 4200.\n\n## Pastes and the authority setting\n\nPastes are moving to Community (roadmap Wave 5). The `PASTES_AUTHORITY` setting chooses the owner:\n\n- `live` — the default, which the README calls \"today's production\". Paste storage stays in [[OpenVibe.Media]], and Community proxies `/api/pastes/*` to [[OpenVibe.Live]], which resolves visitors against its own accounts.\n- `community` — Community's own database is the authority, people are Network subjects rather than service-local integers, and new screenshots go to Media's file store.\n\n## Comments, forum and Pulse\n\nThe comments API at `/api/v1/comments` is described as \"the comment system every OpenVibe product embeds instead of owning comment tables\". Browsers may resolve threads for types including Live VODs, clips, streams and channels, Media objects, Community pastes and posts, and `wiki` pages. The forum has seeded spaces `general`, `feedback` and `showcase`, and its pages work without JavaScript. Pulse is a read model of public activity across the network; other services publish to it with a service capability, and AI items are never attributed to a person.\n\n## Identity\n\nVisitors sign in through [[OpenVibe.Network]] as OAuth client `community`.\n\n## Status\n\nThe README does not state a single release stage. Several of its capability ids are marked \"proposed\". Its \"What is next\" section lists spaces per streamer, game and project, an inbound Discord relay, and submissions (\"clips, art, ideas, reports for community review\").\n",
++ "summary": "Community hub of the OpenVibe network: pastes, a forum, embeddable comment threads and the Pulse activity feed.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Community"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "openvibe.community"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4200
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "Node/Express, SQLite"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Community/blob/b492398a576d45d5626810f583624c2ec00c58d1/README.md",
++ "title": "OpenVibe.Community README (commit b492398)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "community hub of the OpenVibe network"
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Media",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Media** is a standalone multi-tenant media service extracted from the OpenVibe.Live predecessor. Its README gives its port as 4100 and states that it implements **Media API v1**.\n\n## What it owns\n\nMedia owns VOD ingest and recording, storage tiering (local, then Backblaze B2, then Cloudflare R2), clips, pastes, generic files and thumbnails for the OpenVibe apps `live`, `games`, `tools` and `network`. Each app is a tenant, named by the `:app` path segment, and calls the API with its own app API key; some browser endpoints also accept a Network user JWT, verified offline against the Network public key.\n\nFeatures the README describes include:\n\n- VOD recording from an RTMP pull, RTP ingest or browser MediaRecorder chunks, with codec passthrough;\n- background clip cutting with the states `processing`, `ready` and `failed`;\n- public serving of VODs, clips, pastes, thumbnails and files, with VOD and clip playback able to redirect (302) to presigned B2/R2 URLs;\n- signed outbound webhooks such as `vod.ready` and `clip.ready`;\n- a canonical object model with `med_…` ids, an `/api/v2` objects API and retention holds;\n- a drain policy that offloads least-recently-watched VODs when the local disk is under pressure.\n\n## What it does not own\n\nThe README lists as not ported the song-request media queue, which Live keeps local, server-side live thumbnail grabbers and VOD part rotation. Comments on VODs and clips, notifications and moderation ranks are \"owned by the apps\". AI summary and tag generation for pastes was dropped because Live owns AI.\n\n## Relationship to other services\n\n[[OpenVibe.Live]] creates a VOD in Media when a stream starts and receives Media's webhooks. Visitors to Media's public pages sign in through [[OpenVibe.Network]] as OAuth client `media`. The [[OpenVibe.Community]] README describes pastes moving to Community, with Media's paste storage used under its default setting.\n\n## Status\n\nThe README does not state a release stage. It requires Node 20 or later, `ffmpeg` and `ffprobe`, and uses SQLite through better-sqlite3.\n",
++ "summary": "Multi-tenant media service for OpenVibe apps: VOD recording, storage tiering, clips, pastes, files and thumbnails.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Media"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4100
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "Node 20 or later, SQLite (better-sqlite3)"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Media/blob/30074614849cba19a3d7c62eec7691a7b1d8e0d7/README.md",
++ "title": "OpenVibe.Media README (commit 3007461)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Owns VOD ingest/recording, storage tiering"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Live/blob/c3847871148541a1eba7ee966809853d49f3b5b2/README.md",
++ "title": "OpenVibe.Live README (commit c384787)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "On stream start, Live creates a VOD in Media and starts ingest"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Community/blob/b492398a576d45d5626810f583624c2ec00c58d1/README.md",
++ "title": "OpenVibe.Community README (commit b492398)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Pastes are moving to Community (roadmap Wave 5)."
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Events",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Events** provides \"durable events, subscriptions, delivery, retry, dead letters and replay for the network\". Its README gives the status as **alpha** (roadmap Wave 3): it \"runs and is tested; no service publishes to it in production yet\". Its STATUS.json records it as not deployed. The README names `events.openvibe.network` as its domain and states that the domain keeps its placeholder page on OpenVibe.Sites until the plan's launch rule holds.\n\n## Purpose\n\nThe README calls it \"the durable event backbone the network does not have yet\". Authoritative services write domain changes and outbox events in one transaction, a relay publishes them to Events, consumers use inbox and idempotency receipts, and browsers receive authorized, resumable projections through a Realtime delivery plane. It is meant to replace the best-effort internal POSTs and webhooks between [[OpenVibe.Live]], [[OpenVibe.Media]], [[OpenVibe.Network]] and [[OpenVibe.Community]]. The first planned migrations are notifications, then Media ready/failed, then stream lifecycle.\n\n## What it owns\n\n- the event, subscription, delivery, checkpoint and idempotency-receipt tables, in SQLite today;\n- the canonical event envelope;\n- priority classes, loop guards, backpressure, the dead-letter queue and replay;\n- Realtime over Server-Sent Events, with cursor resume and gap detection; WebSocket and presence are not built yet.\n\nDelivery is at least once and not strictly ordered. A failed delivery is retried up to 8 attempts and is then marked dead. The `openvibe-events` client library provides a transactional outbox for producers and an inbox for consumers.\n\n## What it does not own\n\nMedia bytes, game or media transport packets, and product business rules.\n\n## Dependencies\n\nOpenVibe.Contracts and [[OpenVibe.Network]]; services call Events with Network client-credentials tokens. STATUS.json gives the runtime as Node 22, Express and better-sqlite3 on port 4300. The license is AGPL-3.0.\n",
++ "summary": "Durable event backbone for the OpenVibe network; alpha and not deployed at the cited commit.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Events"
++ },
++ {
++ "key": "status",
++ "label": "Status",
++ "type": "text",
++ "value": "alpha"
++ },
++ {
++ "key": "deployment",
++ "label": "Deployment",
++ "type": "text",
++ "value": "not deployed"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "events.openvibe.network"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4300
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "node 22, express, better-sqlite3"
++ },
++ {
++ "key": "license",
++ "label": "License",
++ "type": "text",
++ "value": "AGPL-3.0"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Events/blob/0c7f9c58dc2a31cbb2e0e1ba39086bbe4bcca20a/README.md",
++ "title": "OpenVibe.Events README (commit 0c7f9c5)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Runs and is tested; no service publishes to it in production yet."
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Events/blob/0c7f9c58dc2a31cbb2e0e1ba39086bbe4bcca20a/STATUS.json",
++ "title": "OpenVibe.Events STATUS.json (commit 0c7f9c5)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"deployed\": false"
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Search",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Search** provides \"permission-aware discovery for the network\": a canonical index document, event-fed indexing, an ACL-filtered query API, and deletion and visibility propagation. Its README gives the status as **alpha** (roadmap Wave 14): it \"runs and is tested; not deployed, and no product indexes into it yet\". The README names `search.openvibe.network` as its domain, which is \"still a placeholder on OpenVibe.Sites\".\n\n## Purpose\n\nPublication products and other services with discoverable content describe their resources to Search with one contract, the proposed `search.index-document@1`, and Search answers queries without ever returning a document the caller may not see. The owning service stays the source of truth; Search keeps a revision-ordered copy that can be reconciled against it.\n\n## How it works\n\n- Owners write documents with a Network service token holding `search.document.write`, or, as the README prefers, by publishing index-document events through [[OpenVibe.Events]].\n- Anonymous callers see only public, published, indexable documents; signed-in users and delegating services can also see restricted documents whose ACL matches them.\n- Drafts, unpublished, retracted and deleted documents are returned to nobody.\n- A change that lowers a document's exposure emits `search.document.removed`, so caches and sitemaps can purge it.\n\n## What it owns and does not own\n\nSearch owns the index document contract, its SQLite index tables, the query API and its ACL filtering. It does not own any product's content, publication state or indexability decision, which belong to the owners and the publishing packages ([[OpenVibe.Publishing]]). It also does not own sitemaps, feeds, page caches, identity, groups or entitlements.\n\n## Dependencies and gaps\n\nSearch depends on OpenVibe.Contracts, [[OpenVibe.Network]] and [[OpenVibe.Events]]. The README states that an end-to-end run with a real product publishing through a deployed Events has not been demonstrated, and that [[OpenVibe.Sources]]' `sources/item` documents are the first producer. There is no HTML search page yet, only the JSON API and a text index at `/`. STATUS.json gives the runtime as Node 22, Express and better-sqlite3 (FTS5) on port 4710. The license is AGPL-3.0.\n",
++ "summary": "Permission-aware search and indexing service for the OpenVibe network; alpha and not deployed at the cited commit.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Search"
++ },
++ {
++ "key": "status",
++ "label": "Status",
++ "type": "text",
++ "value": "alpha"
++ },
++ {
++ "key": "deployment",
++ "label": "Deployment",
++ "type": "text",
++ "value": "not deployed"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "search.openvibe.network"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4710
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "node 22, express, better-sqlite3 (FTS5)"
++ },
++ {
++ "key": "license",
++ "label": "License",
++ "type": "text",
++ "value": "AGPL-3.0"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/blob/12b4046db06a4fd34beb9fe5d4e7a7544787b96b/README.md",
++ "title": "OpenVibe.Search README (commit 12b4046)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Runs and is tested; not deployed, and no product indexes into it yet."
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/blob/12b4046db06a4fd34beb9fe5d4e7a7544787b96b/STATUS.json",
++ "title": "OpenVibe.Search STATUS.json (commit 12b4046)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"deployed\": false"
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Sources",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Sources** is \"the source registry and the ingestion workers the publication products depend on\". Every item records where it came from, when it was retrieved and on what terms, and a failed fetch is recorded as a failure, never replaced by invented content. The README gives the status as **alpha** (roadmap Wave 14): it \"runs and is tested against stub sites; not deployed; the six seeded sources are disabled\". It names `sources.openvibe.network` as the domain, with health only on the public vhost and the API host-local; Sources has no public pages by design.\n\n## Purpose\n\nThe README states that News, Reviews, Deals, Coupons, Trade and AI summarisation all need outside material, and that Sources is the one place it enters the network. Products read items with provenance and decide, under their own review and indexability rules, what to publish.\n\n## What it owns\n\n- the source registry, in which each source carries terms, robots and license notes and a rate limit; a source cannot be enabled without a terms note;\n- ingestion: fetch runs, items and item revisions, the scheduler, and robots.txt and rate-limit state;\n- `sources.*` events.\n\nEach fetch attempt is recorded with an explicit state such as `ok`, `http_error`, `timeout` or `robots_denied`, and \"a failed run never creates or modifies an item\". Adapters read RSS, Atom, sitemaps, JSON-LD, mapped APIs and manually entered items.\n\n## What it does not own\n\nPublication decisions, AI summarisation, and legal clearance: a terms note records what a person verified and does not replace verifying.\n\n## Relationships\n\nCallers use [[OpenVibe.Network]] client-credentials tokens. Sources publishes its events through a transactional outbox to [[OpenVibe.Events]]. For sources marked `search_visibility: \"members\"`, raw items are indexed in [[OpenVibe.Search]] for staff only and always as `noindex`. The consuming products (News, Reviews, Deals, Coupons, Trade) do not exist yet. STATUS.json gives the runtime as Node 22, Express, better-sqlite3 and fast-xml-parser on port 4720. The license is AGPL-3.0.\n",
++ "summary": "Source registry and ingestion workers with recorded provenance; alpha, not deployed, seeded sources disabled at the cited commit.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Sources"
++ },
++ {
++ "key": "status",
++ "label": "Status",
++ "type": "text",
++ "value": "alpha"
++ },
++ {
++ "key": "deployment",
++ "label": "Deployment",
++ "type": "text",
++ "value": "not deployed"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "sources.openvibe.network"
++ },
++ {
++ "key": "port",
++ "label": "Port",
++ "type": "number",
++ "value": 4720
++ },
++ {
++ "key": "runtime",
++ "label": "Runtime",
++ "type": "text",
++ "value": "node 22, express, better-sqlite3, fast-xml-parser"
++ },
++ {
++ "key": "license",
++ "label": "License",
++ "type": "text",
++ "value": "AGPL-3.0"
++ },
++ {
++ "key": "seeded_sources",
++ "label": "Seeded sources",
++ "type": "number",
++ "value": 6
++ },
++ {
++ "key": "enabled_sources",
++ "label": "Enabled sources",
++ "type": "number",
++ "value": 0
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Sources/blob/f727a40c3f979f42b3625782b5b71c0ebdc5617a/README.md",
++ "title": "OpenVibe.Sources README (commit f727a40)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "Runs and is tested against stub sites; not deployed; the six seeded sources are"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Sources/blob/f727a40c3f979f42b3625782b5b71c0ebdc5617a/STATUS.json",
++ "title": "OpenVibe.Sources STATUS.json (commit f727a40)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"enabledSources\": 0"
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Publishing",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Publishing** holds the \"shared publishing packages for the OpenVibe publication products\": Wiki, Blog, News, Reviews, Deals, Coupons and Trade. Its README gives the status as **alpha, 0.2.0** (roadmap Wave 15). The modules are tested; v0.1.0 is tagged, 0.2.0 is not tagged yet, and no product uses the packages in production yet. [[OpenVibe.Wiki]] and Blog are named as the first planned consumers. The package is `openvibe-publishing` (CommonJS, Node 20 or later) and its license is MIT.\n\n## Packages, not an authority\n\nThe README states that the repository is \"code, never state\". It has no runtime, port, domain, database or API of its own. Each product keeps its publication state in its own database: the modules take the product's `better-sqlite3` handle and a table prefix and create prefixed tables inside that database, so two products share code but not data.\n\n## Modules\n\n- `revisions` — drafts, immutable revisions, diffs and revert;\n- `schedule` — scheduled publish and unpublish jobs;\n- `taxonomy` — tags, terms and hierarchical categories;\n- `citations` — append-only source references on one revision, by [[OpenVibe.Sources]] item id and/or URL;\n- `media` — attachments referenced by [[OpenVibe.Media]] object id;\n- `discussion` — references to [[OpenVibe.Community]] comment threads;\n- `seo` — a deterministic indexability gate, canonical URLs, redirects, sitemaps and feeds;\n- `authorship` — human, AI, hybrid and imported records; AI content is held as draft and noindex until a person's review;\n- `index-hooks` — `search.index-document@1` documents and the events [[OpenVibe.Search]] consumes;\n- `ssr` — auto-escaping templates and a safe Markdown subset.\n\n## What it does not do\n\nIt does not own publication state, run workers or decide editorial rules. It stores references instead of comment content, media bytes or source items. It is not a notification, search or event runtime: it builds the documents and envelopes those services, such as [[OpenVibe.Events]], consume.\n",
++ "summary": "Shared publishing packages (code, not a service) for OpenVibe publication products; alpha 0.2.0 at the cited commit.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Publishing"
++ },
++ {
++ "key": "status",
++ "label": "Status",
++ "type": "text",
++ "value": "alpha"
++ },
++ {
++ "key": "version",
++ "label": "Version",
++ "type": "text",
++ "value": "0.2.0"
++ },
++ {
++ "key": "package",
++ "label": "Package",
++ "type": "text",
++ "value": "openvibe-publishing"
++ },
++ {
++ "key": "license",
++ "label": "License",
++ "type": "text",
++ "value": "MIT"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/blob/0ff38e191154fb36978569102c7630ef55b30942/README.md",
++ "title": "OpenVibe.Publishing README (commit 0ff38e1)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "No product uses them in production yet"
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/blob/0ff38e191154fb36978569102c7630ef55b30942/STATUS.json",
++ "title": "OpenVibe.Publishing STATUS.json (commit 0ff38e1)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"stage\": \"alpha\""
++ }
++ ]
++ },
++ {
++ "title": "OpenVibe.Wiki",
++ "parent": "OpenVibe",
++ "body": "**OpenVibe.Wiki** is described in its README as \"wiki spaces with page trees, revisions, citations, media and discussion — editable together, source-backed\". At the cited commit the repository held only a charter: the README gives the status as **placeholder** (\"planning only, no runnable code yet\"), and STATUS.json records that it has no code. The README names `openvibe.wiki` as the domain and AGPL-3.0 as the license, and refers to the OpenVibe End-to-End Realignment & Implementation Plan, revision 3.\n\n## Planned purpose\n\nThe charter describes the knowledge product: spaces, canonical page identities and slugs, immutable revisions, redirects, citations and infoboxes. AI proposes revisions through registered workflows; humans publish.\n\n## Ownership\n\nThe charter lists wiki tables for spaces, pages, revisions, links, redirects, citations, infobox values, permissions, watchers and AI proposals. Wiki does not own discussion, since [[OpenVibe.Community]] owns talk pages; attachments, which [[OpenVibe.Media]] owns; or \"publication truth by AI (never)\". [[OpenVibe.Search]] indexes only published state.\n\n## Planned surfaces\n\nCreating or importing a space, page tree navigation, edit, preview, publish and revert, history and diff, a citation inspector, backlinks and link validation, and AI research proposals with review state.\n\n## Dependencies\n\nThe charter lists the shared publishing packages ([[OpenVibe.Publishing]]), [[OpenVibe.Network]], [[OpenVibe.Media]], [[OpenVibe.Community]], OpenVibe.AI with the source registry, Search and [[OpenVibe.Events]]. It states there is no current implementation and that Wiki bootstraps from the shared publishing runtime.\n\n## Acceptance and launch rule\n\nBefore the service counts as done, the charter requires an immutable lineage through create, edit, publish, revise, diff and revert; citations attached to the exact revision that used them; private or deleted pages leaving search, sitemaps and caches consistently; and public pages that are useful with JavaScript disabled. The domain keeps its placeholder page on OpenVibe.Sites until an owning runtime, identity integration, public routes useful without JavaScript, real persistence, contract registration, a security review and acceptance tests exist. The README states: \"A placeholder is never counted as an implemented service.\"\n",
++ "summary": "Planned wiki service for the OpenVibe network; at the cited commit a placeholder charter with no runnable code.",
++ "infobox": [
++ {
++ "key": "repository",
++ "label": "Repository",
++ "type": "url",
++ "value": "https://github.com/OpenVibers/OpenVibe.Wiki"
++ },
++ {
++ "key": "status",
++ "label": "Status",
++ "type": "text",
++ "value": "placeholder"
++ },
++ {
++ "key": "domain",
++ "label": "Domain",
++ "type": "text",
++ "value": "openvibe.wiki"
++ },
++ {
++ "key": "license",
++ "label": "License",
++ "type": "text",
++ "value": "AGPL-3.0"
++ }
++ ],
++ "citations": [
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Wiki/blob/b8aac8e0d7c2d8c31999da3a4ff87d736eba8d3a/README.md",
++ "title": "OpenVibe.Wiki README (commit b8aac8e)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "placeholder — planning only, no runnable code yet."
++ },
++ {
++ "url": "https://github.com/OpenVibers/OpenVibe.Wiki/blob/b8aac8e0d7c2d8c31999da3a4ff87d736eba8d3a/STATUS.json",
++ "title": "OpenVibe.Wiki STATUS.json (commit b8aac8e)",
++ "retrievedAt": "2026-09-22T12:00:00.000Z",
++ "quote": "\"code\": false"
++ }
++ ]
++ }
++ ]
++}
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..c1e41b4
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * OpenVibe.Wiki — Express app factory. server/index.js starts it; tests build their own.
++ *
++ * Pages (SSR, useful without JavaScript) server/http/pages.js
++ * /api/v1 (people and service principals) server/http/api.js
++ * Discovery: robots, sitemaps, feeds, llms server/http/machine.js
++ * /auth/* (Network SSO) server/auth/session.js
++ * GET /api/health, /api/ready, /release.json, /metrics (loopback only)
++ */
++const path = require('path');
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const { createReadiness } = require('openvibe-shared/ready');
++const { createSessionRoutes } = require('./auth/session');
++const { createApi } = require('./http/api');
++const { createPages } = require('./http/pages');
++const { createMachine } = require('./http/machine');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch }) {
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++
++ const release = require('openvibe-shared/release').createRelease({ service: 'wiki', root: path.join(__dirname, '..') });
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'wiki', release: release.release });
++ metrics.registry.gauge({
++ name: 'wiki_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
++ collect: () => [{ labels: { state: 'pending' }, value: platform.outbox.pending() }, { labels: { state: 'rejected' }, value: platform.outbox.rejected() }],
++ });
++
++ app.use((req, res, next) => {
++ res.set('X-Content-Type-Options', 'nosniff');
++ res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
++ res.set('Content-Security-Policy', [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
++ "img-src 'self' data: https:",
++ "connect-src 'self' https://openvibe.network",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'self'",
++ "form-action 'self' https://openvibe.network",
++ "base-uri 'self'",
++ "object-src 'none'",
++ ].join('; '));
++ next();
++ });
++ app.use(cookieParser());
++
++ const limiter = (windowMs, max) => (rateLimits ? rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false }) : (_q, _s, n) => n());
++ app.use('/auth/', limiter(15 * 60000, 60));
++ app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'wiki', service: 'wiki', host: 'openvibe.wiki', name: 'OpenVibe.Wiki', profile: 'ugc' })); }
++
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-wiki', version: VERSION }));
++ app.get('/release.json', release.handler);
++ const ready = createReadiness({
++ service: 'wiki', release: release.release,
++ checks: [
++ { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'wiki_pages'").get().n === 1 || 'wiki tables missing' },
++ { name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },
++ { name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
++ { name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
++ { name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: Sources item citations are refused (URL citations work)' },
++ { name: 'media', required: false, check: () => platform.media.configured || 'not configured: attachments are not checked against Media' },
++ ],
++ details: () => ({ outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() } }),
++ });
++ app.get('/api/ready', ready.handler);
++
++ app.use('/api/', limiter(60000, 300));
++ app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
++ app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
++
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res) { res.setHeader('Cache-Control', res.req && res.req.query && res.req.query.v ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'); },
++ }));
++ app.use(createMachine({ svc, config }));
++
++ app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
++ const pages = createPages({ svc, viewers, platform, config, log });
++ app.use(pages.router);
++
++ app.use((req, res) => pages.errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address.'));
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[Wiki]', err && err.stack ? err.stack : err);
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) return require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
++ res.status(500).set('Cache-Control', 'private, no-store').type('text/plain').send('Something went wrong on our side. Please try again.');
++ });
++ return app;
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..68882e6
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * Capability checks for service tokens, including the wiki.* ids this service introduces before the
++ * contracts library knows them (proposed in docs/capabilities-proposal/).
++ *
++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
++ * manifests yet. Until a release defines them, a grant of a proposed id is decided locally with the
++ * library's own matching rule (the exact id, or a `prefix.*` grant covering it). An id the library
++ * knows always goes through the library, so the day the release lands nothing changes here.
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const CAPS = Object.freeze({
++ SPACE_CREATE: 'wiki.space.create',
++ PAGE_CREATE: 'wiki.page.create',
++ PAGE_READ: 'wiki.page.read',
++ REVISION_PROPOSE: 'wiki.revision.propose',
++ REVISION_PUBLISH: 'wiki.revision.publish',
++ REVISION_REVERT: 'wiki.revision.revert',
++ CITATION_ATTACH: 'wiki.citation.attach',
++ SEARCH_QUERY: 'wiki.search.query',
++});
++const PROPOSED = new Set(Object.values(CAPS));
++
++/** → { allowed, code, reason } like capabilities.check(). */
++function checkCapability(claims, capabilityId) {
++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
++ return capabilities.grants(claims && claims.cap, capabilityId)
++ ? { allowed: true, code: null, reason: null }
++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
++ }
++ return capabilities.check(claims, capabilityId);
++}
++
++module.exports = { checkCapability, CAPS, PROPOSED };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..d653b17
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * The Network's RS256 public key, used to verify user JWTs (SSO) and service tokens offline.
++ * Pinned with OV_NETWORK_PUBLIC_KEY, or fetched from <network>/api/.well-known/jwks (internal URL
++ * first) and retried at most every 30 s until it loads. Without it nobody can sign in or call the
++ * API with a token; public pages keep working.
++ */
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console, publicKey = null } = {}) {
++ let key = publicKey || config.networkPublicKey || null;
++ let lastFetch = 0;
++ let inflight = null;
++
++ async function fetchKey() {
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) continue;
++ const jwks = await res.json();
++ if (jwks && typeof jwks.public_key === 'string' && jwks.public_key.includes('BEGIN')) {
++ key = jwks.public_key;
++ log.log(`[Wiki] Network public key loaded from ${base}`);
++ return key;
++ }
++ } catch (err) {
++ log.warn(`[Wiki] JWKS fetch failed from ${base}: ${err.message}`);
++ }
++ }
++ return null;
++ }
++
++ return {
++ get() { return key; },
++ loaded() { return !!key; },
++ async ensure() {
++ if (key) return key;
++ if (inflight) return inflight;
++ if (Date.now() - lastFetch < 30000) return null;
++ lastFetch = Date.now();
++ inflight = fetchKey().finally(() => { inflight = null; });
++ return inflight;
++ },
++ };
++}
++
++module.exports = { createKeyStore };
ddiff --git a/server/auth/session.js b/server/auth/session.js
nnew file mode 100644
iindex 0000000..c7be570
---- /dev/null
++++ b/server/auth/session.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * openvibe.wiki as an OAuth2 client of OpenVibe.Network (client `wiki`, redirect
++ * <BASE_URL>/auth/callback) — the same session layer as OpenVibe.Community's server/auth/routes.js.
++ *
++ * GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)
++ * GET /auth/callback → server-side code exchange, sets the cookies
++ * POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)
++ * GET /auth/logout → clears the cookies (best-effort refresh revoke)
++ * GET /auth/me → the signed-in profile (offline JWT verification)
++ * POST /auth/refresh → rotates tokens with the refresh token
++ *
++ * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,
++ * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).
++ */
++const crypto = require('crypto');
++const express = require('express');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++function sanitizeNext(next, config) {
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next;
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++function withParam(target, key, value) {
++ const at = target.indexOf('#');
++ const hash = at >= 0 ? target.slice(at) : '';
++ const base = at >= 0 ? target.slice(0, at) : target;
++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++function authorizeUrl(config, { silent = false } = {}) {
++ const state = crypto.randomBytes(16).toString('hex');
++ const u = new URL(`${config.networkUrl}/oauth/authorize`);
++ u.searchParams.set('client_id', config.oauth.clientId);
++ u.searchParams.set('redirect_uri', config.oauth.redirectUri);
++ u.searchParams.set('response_type', 'code');
++ u.searchParams.set('scope', config.oauth.scope);
++ u.searchParams.set('state', state);
++ if (silent) u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state };
++}
++
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return false;
++ let claims;
++ try { claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return false; }
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
++ const router = express.Router();
++ const secure = config.cookies.secure;
++ const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
++ const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
++ const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
++ const hint = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
++
++ function setSession(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, access());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refresh());
++ res.cookie(HINT_COOKIE, 'account', hint());
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refresh(), maxAge: undefined });
++ }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
++
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
++ signal: AbortSignal.timeout(10000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status, error: data.error || 'invalid_grant' });
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ router.get('/login', async (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ const next = sanitizeNext(req.query.next, config);
++ if (silent) {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
++ }
++ const { url, state } = authorizeUrl(config, { silent });
++ res.cookie(STATE_COOKIE, state, flow());
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(url);
++ });
++
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
++ if (error) {
++ clearFlow(res);
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
++ const expected = req.cookies && req.cookies[STATE_COOKIE];
++ clearFlow(res);
++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');
++ }
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ log.error(`[Wiki] code exchange failed: ${err.message}`);
++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });
++ try {
++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ ok: true, user: data.user || null });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(401).json({ error: 'invalid_grant' });
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ router.get('/logout', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (token) {
++ try {
++ await fetchImpl(`${config.networkInternalUrl || config.networkUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', hint());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ router.get('/me', async (req, res) => {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
++ res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
++ });
++
++ router.post('/refresh', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!token) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ token: data.access_token });
++ } catch (err) {
++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..4564e7d
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * Who is calling — resolved once per request into `req.actor` (see server/wiki/access.js):
++ *
++ * { kind: 'anonymous', subject: null }
++ * { kind: 'user', subject: 'usr_…'|null, staff, user }
++ * A browser or client with the Network user JWT (ov_token cookie or Bearer), verified offline
++ * (RS256, issuer, audience). role admin/global_mod makes the person staff.
++ * { kind: 'service', service: 'svc:x', claims, subject }
++ * A Network client-credentials token for audience openvibe.wiki. It names the person it acts
++ * for in X-OV-Subject (usr_…); every route checks one capability against the token.
++ *
++ * Identity never comes from a body or a query. A request that presents a service token is judged
++ * on that token alone: a bad one is refused, never downgraded to anonymous.
++ */
++const contracts = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++
++const { ids, serviceAuth } = contracts;
++const STAFF_ROLES = new Set(['admin', 'global_mod']);
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++const ACCESS_COOKIE = 'ov_token';
++
++class AuthError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++function bearer(req) {
++ const h = String(req.headers.authorization || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++function createViewerResolver({ keys, config }) {
++ async function fromServiceToken(req, token) {
++ const publicKey = await keys.ensure();
++ if (!publicKey) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) throw new AuthError(401, r.code, r.reason);
++ const header = req.get('x-ov-subject');
++ let subject = null;
++ if (header) {
++ if (!ids.isSubjectId('user', header)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
++ subject = header;
++ }
++ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, staff: false };
++ }
++
++ async function fromUserToken(token, { strict }) {
++ const publicKey = await keys.ensure();
++ if (!publicKey) {
++ if (strict) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ return null;
++ }
++ let claims;
++ try {
++ claims = await verifyUserToken(token, { publicKey, issuer: config.networkIssuer, audience: config.userAudiences });
++ } catch (err) {
++ if (strict) throw new AuthError(401, err.code || 'token.invalid', err.message);
++ return null;
++ }
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ return {
++ kind: 'user', subject, staff: STAFF_ROLES.has(claims.role),
++ user: { username: claims.username || null, display_name: claims.display_name || claims.username || null, avatar_url: claims.avatar_url || null, role: claims.role || null, subject_id: subject },
++ };
++ }
++
++ /**
++ * opts.services=false (server-rendered pages): a service token is no identity at all.
++ * A Bearer user token is strict (a bad one is 401); the cookie is lenient (expired = signed out).
++ */
++ async function resolve(req, opts = {}) {
++ const token = bearer(req);
++ if (token) {
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ if (opts.services === false) return ANONYMOUS;
++ return fromServiceToken(req, token);
++ }
++ return (await fromUserToken(token, { strict: true })) || ANONYMOUS;
++ }
++ const cookie = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!cookie) return ANONYMOUS;
++ return (await fromUserToken(cookie, { strict: false })) || ANONYMOUS;
++ }
++
++ return { resolve };
++}
++
++module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE, STAFF_ROLES };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..a411590
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * OpenVibe.Wiki configuration. load(env) is pure so tests build their own config; the process
++ * entry calls load() with process.env after dotenv.
++ */
++
++function list(value, fallback) {
++ return String(value == null || value === '' ? fallback : value).split(',').map((s) => s.trim()).filter(Boolean);
++}
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4800);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.wiki' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ return {
++ serviceId: 'wiki',
++ nodeEnv,
++ isProduction,
++ port,
++ host: env.HOST || '127.0.0.1',
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
++ dbPath: env.WIKI_DB_PATH || './data/wiki.db',
++
++ // Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? String(env.OV_NETWORK_PUBLIC_KEY).replace(/\\n/g, '\n') : null,
++ // A browser's user JWT is accepted when its aud contains one of these.
++ userAudiences: list(env.WIKI_USER_AUDIENCES, 'openvibe.wiki,openvibe.network'),
++ // Service tokens presented to this API must be minted for this audience.
++ audience: 'openvibe.wiki',
++
++ // OAuth client `wiki` (browser sign-in) and service principal `svc:wiki` (same credentials).
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'wiki',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile theme',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // Other services (all optional: each integration degrades to an explicit failure state).
++ eventsUrl: trim(env.EVENTS_URL || ''),
++ eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ communityUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
++ communityInternalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || ''),
++ sourcesInternalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
++ mediaInternalUrl: trim(env.OV_MEDIA_INTERNAL_URL || ''),
++ mediaPublicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
++ // Media namespace (app) Wiki's attachments live in, checked with media.object.read.
++ mediaApp: env.WIKI_MEDIA_APP || 'wiki',
++ mediaVerifyIntervalMs: int(env.WIKI_MEDIA_VERIFY_INTERVAL_MS, 6 * 60 * 60 * 1000),
++
++ // Scheduled publication worker.
++ scheduleIntervalMs: int(env.WIKI_SCHEDULE_INTERVAL_MS, 15000),
++ workerId: env.WIKI_WORKER_ID || `wiki-${process.pid}`,
++
++ // The indexability gate policy for wiki pages (openvibe-publishing/seo).
++ gate: {
++ minWords: int(env.WIKI_GATE_MIN_WORDS, 80),
++ requireSources: env.WIKI_GATE_REQUIRE_SOURCES ? env.WIKI_GATE_REQUIRE_SOURCES === 'true' : true,
++ minSources: int(env.WIKI_GATE_MIN_SOURCES, 1),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..513a8f3
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,164 @@
++'use strict';
++/**
++ * Wiki's own SQLite database (one per service, WAL, created on boot, idempotent).
++ *
++ * The ten authority tables of roadmap §15.13:
++ * wiki_spaces spaces (official editorial spaces and user spaces)
++ * wiki_pages canonical page identities, slugs, tree, publication state
++ * wiki_page_revisions immutable revisions (openvibe-publishing/revisions, prefix wiki_page)
++ * wiki_page_links [[internal links]] per revision
++ * wiki_page_redirects every historical path → page (openvibe-publishing/seo, prefix wiki_page)
++ * wiki_citations citations per (page, revision) (openvibe-publishing/citations, prefix wiki)
++ * wiki_infobox_values typed infobox values per (page, revision), append-only
++ * wiki_permissions space roles: owner / editor / viewer
++ * wiki_watchers who watches a page (notified through an event, never email)
++ * wiki_ai_proposals AI-authored draft revisions awaiting a person's decision
++ *
++ * The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
++ * wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
++ * wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { createRevisionStore } = require('openvibe-publishing/revisions');
++const { createCitationStore } = require('openvibe-publishing/citations');
++const { createAttachmentStore } = require('openvibe-publishing/media');
++const { createDiscussionRefs } = require('openvibe-publishing/discussion');
++const { createScheduler } = require('openvibe-publishing/schedule');
++const { createReviewLog } = require('openvibe-publishing/authorship');
++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
++const seo = require('openvibe-publishing/seo');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS wiki_spaces (
++ id TEXT PRIMARY KEY,
++ slug TEXT NOT NULL UNIQUE,
++ name TEXT NOT NULL,
++ description TEXT,
++ kind TEXT NOT NULL CHECK (kind IN ('official','user')),
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','private')),
++ owner TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ deleted_at INTEGER
++);
++
++CREATE TABLE IF NOT EXISTS wiki_pages (
++ id TEXT PRIMARY KEY,
++ space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
++ slug TEXT NOT NULL,
++ title TEXT NOT NULL,
++ parent_id TEXT REFERENCES wiki_pages(id),
++ position INTEGER NOT NULL DEFAULT 0,
++ state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','private')),
++ noindex INTEGER NOT NULL DEFAULT 0,
++ published_revision INTEGER,
++ published_at INTEGER,
++ revision_published_at INTEGER,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ UNIQUE (space_id, slug)
++);
++CREATE INDEX IF NOT EXISTS wiki_pages_parent ON wiki_pages (space_id, parent_id, position);
++CREATE INDEX IF NOT EXISTS wiki_pages_updated ON wiki_pages (updated_at);
++
++CREATE TABLE IF NOT EXISTS wiki_page_links (
++ from_page_id TEXT NOT NULL,
++ from_revision INTEGER NOT NULL,
++ target_space TEXT NOT NULL,
++ target_slug TEXT NOT NULL,
++ label TEXT,
++ PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)
++);
++CREATE INDEX IF NOT EXISTS wiki_page_links_target ON wiki_page_links (target_space, target_slug);
++
++CREATE TABLE IF NOT EXISTS wiki_infobox_values (
++ page_id TEXT NOT NULL,
++ revision INTEGER NOT NULL,
++ position INTEGER NOT NULL,
++ key TEXT NOT NULL,
++ label TEXT NOT NULL,
++ type TEXT NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),
++ value_text TEXT,
++ value_number REAL,
++ PRIMARY KEY (page_id, revision, key)
++);
++CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values
++BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values
++WHEN NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id)
++BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are never deleted outside a recorded purge'); END;
++CREATE TRIGGER IF NOT EXISTS wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links
++BEGIN SELECT RAISE(ABORT, 'wiki_page_links rows are immutable'); END;
++
++CREATE TABLE IF NOT EXISTS wiki_permissions (
++ space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
++ subject TEXT NOT NULL,
++ role TEXT NOT NULL CHECK (role IN ('owner','editor','viewer')),
++ granted_by TEXT,
++ granted_at INTEGER NOT NULL,
++ PRIMARY KEY (space_id, subject)
++);
++CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
++
++CREATE TABLE IF NOT EXISTS wiki_watchers (
++ page_id TEXT NOT NULL REFERENCES wiki_pages(id),
++ subject TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ PRIMARY KEY (page_id, subject)
++);
++
++CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
++ id TEXT PRIMARY KEY,
++ page_id TEXT NOT NULL REFERENCES wiki_pages(id),
++ space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
++ revision INTEGER NOT NULL,
++ base_revision INTEGER NOT NULL,
++ workflow_id TEXT NOT NULL,
++ run_id TEXT NOT NULL,
++ stub_provider INTEGER NOT NULL DEFAULT 0,
++ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
++ proposed_by TEXT NOT NULL,
++ note TEXT,
++ reviewed_by TEXT,
++ review_note TEXT,
++ reviewed_at INTEGER,
++ created_at INTEGER NOT NULL,
++ UNIQUE (page_id, revision)
++);
++CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
++`;
++
++function openDb(dbPath) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ return db;
++}
++
++/**
++ * Creates every table (package tables first: the infobox purge trigger references
++ * wiki_page_revision_purges) and returns the package stores bound to this database.
++ */
++function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
++ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
++ db.exec(SCHEMA);
++ return {
++ revisions,
++ citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
++ redirects: seo.createRedirectStore(db, { prefix: 'wiki_page', now }),
++ reviews: createReviewLog(db, { prefix: 'wiki_page', now }),
++ attachments: createAttachmentStore(db, { prefix: 'wiki_page', now }),
++ discussions: createDiscussionRefs(db, { prefix: 'wiki', now }),
++ scheduler: createScheduler(db, { prefix: 'wiki', now, leaseMs: scheduleLeaseMs }),
++ sequencer: createIndexSequencer(db, { prefix: 'wiki', now }),
++ };
++}
++
++module.exports = { openDb, createStores, SCHEMA };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..711501a
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,241 @@
++'use strict';
++/**
++ * /api/v1 — JSON API for people (Network user JWT as Bearer or the ov_token cookie) and services
++ * (Network client-credentials token for audience openvibe.wiki, one capability per route, acting
++ * for the person named in X-OV-Subject). Errors are RFC 9457 problem+json. Responses are
++ * Cache-Control: private, no-store.
++ *
++ * GET /spaces readable spaces
++ * POST /spaces wiki.space.create { slug?, name, description?, kind?, visibility? }
++ * GET /spaces/:space space + page tree
++ * PATCH /spaces/:space wiki.space.create { name?, description?, visibility?, slug? } owner
++ * GET /spaces/:space/roles owner
++ * PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } owner
++ * POST /spaces/:space/pages wiki.page.create { title, body, summary?, infobox?, parent_id?, visibility?, citations?, message? }
++ * GET /pages/:id wiki.page.read page + the revision the caller may read (?revision=)
++ * PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }
++ * DELETE /pages/:id wiki.page.create owner
++ * GET /pages/:id/revisions wiki.page.read history (?before=&limit=)
++ * POST /pages/:id/revisions wiki.page.create { expected_revision, body?, title?, summary?, infobox?, citations?, keep_citations?, message? }
++ * GET /pages/:id/revisions/:n wiki.page.read
++ * GET /pages/:id/diff?from=&to=&mode= wiki.page.read
++ * GET /pages/:id/revisions/:n/citations wiki.page.read
++ * POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
++ * POST /pages/:id/publish wiki.revision.publish { revision? }
++ * POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
++ * POST /pages/:id/unpublish wiki.revision.publish
++ * POST /pages/:id/revert wiki.revision.revert { to_revision, expected_revision, message?, publish? }
++ * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? }
++ * POST /pages/:id/media/verify wiki.page.create
++ * PUT /pages/:id/watch { watching: bool } people only
++ * POST /proposals wiki.revision.propose { space, page_id?, title?, body, summary?, infobox?, citations?, workflow: { id, run_id, version?, model? }, stub_provider?, expected_revision?, note? }
++ * GET /proposals/:id wiki.page.read
++ * POST /proposals/:id/review wiki.revision.publish { decision: approved|rejected, note?, publish? } a person
++ * GET /search?q=&space=&limit= wiki.search.query
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { actorMiddleware, guard, run, resolveCitations } = require('./common');
++
++const { http } = contracts;
++
++function serializeSpace(s) {
++ return { id: s.id, slug: s.slug, name: s.name, description: s.description, kind: s.kind, visibility: s.visibility, created_at: new Date(s.created_at).toISOString(), updated_at: new Date(s.updated_at).toISOString() };
++}
++
++function serializePage(p, svc, space) {
++ return {
++ id: p.id, space: space.slug, slug: p.slug, title: p.title, parent_id: p.parent_id, state: p.state, visibility: p.visibility,
++ noindex: !!p.noindex, published_revision: p.published_revision,
++ published_at: p.published_at ? new Date(p.published_at).toISOString() : null,
++ revision_published_at: p.revision_published_at ? new Date(p.revision_published_at).toISOString() : null,
++ updated_at: new Date(p.updated_at).toISOString(), url: svc.pageUrl(space, p),
++ };
++}
++
++function serializeRevision(r) {
++ return {
++ number: r.number, id: r.id, kind: r.kind, parent_number: r.parentNumber, reverted_to: r.revertedTo,
++ title: r.fields.title, summary: r.fields.summary || null, infobox: r.fields.infobox || [], body: r.content,
++ authorship: r.meta.authorship || null, author: r.author, message: r.message, created_at: r.createdAt,
++ };
++}
++
++function serializeCitation(c) {
++ return { id: c.id, revision: c.revision, url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote, license_note: c.licenseNote, carried_from: c.carriedFrom, attached_by: c.attachedBy, attached_at: c.attachedAt };
++}
++
++function serializeTree(nodes, svc, space) {
++ return nodes.map((n) => ({ ...serializePage(n.page, svc, space), children: serializeTree(n.children, svc, space) }));
++}
++
++function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }
++
++function createApi({ svc, viewers, platform, config, log = console }) {
++ const router = express.Router();
++ const ownOrigin = new URL(config.baseUrl).origin;
++ router.use(http.middleware());
++ router.use(express.json({ limit: '512kb' }));
++ router.use((err, req, res, next) => (err ? http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
++ // Cookie-authenticated writes from another site are refused (Bearer callers are not browsers' ambient credentials).
++ router.use((req, res, next) => {
++ if (req.method === 'GET' || req.method === 'HEAD' || req.headers.authorization) return next();
++ const o = req.get('origin');
++ if (o && o !== ownOrigin) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'Cross-site requests with cookies are not accepted', ctx: req.ov });
++ next();
++ });
++ router.use(actorMiddleware(viewers));
++ const R = (fn, status) => run(fn, status, log);
++
++ const pageAndSpace = (id) => {
++ const page = svc.pageById(id);
++ if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
++ const space = svc.spaceById(page.space_id);
++ return { page, space };
++ };
++ const readable = (req, id) => {
++ const { page, space } = pageAndSpace(id);
++ if (!svc.access.canReadPage(space, page, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
++ return { page, space };
++ };
++
++ // Spaces
++ router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));
++ router.post('/spaces', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));
++ router.get('/spaces/:space', R((req) => {
++ const space = svc.getSpace(req.params.space, req.actor);
++ return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };
++ }));
++ router.patch('/spaces/:space', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
++ router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));
++ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
++ router.post('/spaces/:space/pages', guard('wiki.page.create'), R(async (req) => {
++ const b = req.body || {};
++ const cites = await resolveCitations(b.citations, platform);
++ const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
++ const space = svc.spaceById(out.page.space_id);
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
++ }, 201));
++
++ // Pages
++ router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
++ const { page, space } = readable(req, req.params.id);
++ const v = svc.view(space, page, req.actor, { revision: int(req.query.revision) });
++ return {
++ page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
++ citations: v.citations.map(serializeCitation), infobox: v.infobox,
++ attachments: v.attachments.map((a) => ({ id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt })),
++ links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
++ indexability: { indexable: v.decision.indexable, reasons: v.decision.reasons },
++ discussion_thread: v.discussion ? v.discussion.threadId : null,
++ };
++ }));
++ router.patch('/pages/:id', guard('wiki.page.create'), R((req) => {
++ const b = req.body || {};
++ let page = svc.pageById(req.params.id);
++ if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
++ if (b.slug !== undefined || b.parent_id !== undefined) page = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);
++ if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;
++ return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };
++ }));
++ router.delete('/pages/:id', guard('wiki.page.create'), R((req) => {
++ const out = svc.deletePage(req.params.id, req.actor);
++ return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };
++ }));
++ router.get('/pages/:id/revisions', guard('wiki.page.read'), R((req) => {
++ const { page, space } = readable(req, req.params.id);
++ if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
++ return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
++ }));
++ router.post('/pages/:id/revisions', guard('wiki.page.create'), R(async (req) => {
++ const b = req.body || {};
++ const cites = await resolveCitations(b.citations, platform);
++ const out = svc.editPage(req.params.id, {
++ expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,
++ citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
++ }, req.actor);
++ const space = svc.spaceById(out.page.space_id);
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
++ }, (out) => (out.created ? 201 : 200)));
++ router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R((req) => {
++ const { page, space } = readable(req, req.params.id);
++ const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });
++ return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
++ }));
++ router.get('/pages/:id/diff', guard('wiki.page.read'), R((req) => {
++ const { page, space } = readable(req, req.params.id);
++ if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
++ return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
++ }));
++ router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R((req) => {
++ const { page, space } = readable(req, req.params.id);
++ svc.view(space, page, req.actor, { revision: int(req.params.n) });
++ return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };
++ }));
++ router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), R(async (req) => {
++ const cites = await resolveCitations((req.body || {}).citations, platform);
++ return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
++ }, 201));
++ router.post('/pages/:id/publish', guard('wiki.revision.publish'), R((req) => {
++ const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
++ return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
++ }));
++ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), R((req) => {
++ const b = req.body || {};
++ const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
++ return { job: out.job, created: out.created };
++ }, (out) => (out.created ? 201 : 200)));
++ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), R((req) => {
++ const out = svc.unpublish(req.params.id, req.actor);
++ return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
++ }));
++ router.post('/pages/:id/revert', guard('wiki.revision.revert'), R((req) => {
++ const b = req.body || {};
++ const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
++ return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
++ }, 201));
++ router.post('/pages/:id/media', guard('wiki.page.create'), R((req) => {
++ const b = req.body || {};
++ return { attachment: svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor) };
++ }, 201));
++ router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {
++ const { space } = pageAndSpace(req.params.id);
++ if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
++ return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
++ }));
++ router.put('/pages/:id/watch', R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));
++
++ // AI proposals
++ router.post('/proposals', guard('wiki.revision.propose'), R(async (req) => {
++ const b = req.body || {};
++ const cites = await resolveCitations(b.citations, platform);
++ const wf = b.workflow || {};
++ const out = svc.propose({
++ space: b.space, pageId: b.page_id || null, title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, citations: cites,
++ workflow: { id: wf.id, runId: wf.run_id || wf.runId, version: wf.version, model: wf.model }, stubProvider: !!b.stub_provider,
++ expectedRevision: b.expected_revision, note: b.note,
++ }, req.actor);
++ return { proposal: out.proposal, page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision) };
++ }, 201));
++ router.get('/proposals/:id', guard('wiki.page.read'), R((req) => {
++ const p = svc.getProposal(req.params.id);
++ if (!p || !svc.access.canEdit(svc.spaceById(p.space_id), req.actor)) {
++ // Proposers may read their own proposal's state.
++ if (!p || !req.actor || req.actor.kind !== 'service' || p.proposed_by !== req.actor.service) throw new svc.WikiError(404, 'proposal.not_found', 'No such proposal');
++ }
++ return { proposal: p };
++ }));
++ router.post('/proposals/:id/review', guard('wiki.revision.publish'), R((req) => {
++ const b = req.body || {};
++ return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
++ }));
++
++ router.get('/search', guard('wiki.search.query'), R((req) => ({
++ results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
++ })));
++
++ router.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createApi, serializePage, serializeRevision, serializeCitation, serializeSpace };
ddiff --git a/server/http/common.js b/server/http/common.js
nnew file mode 100644
iindex 0000000..28bafdb
---- /dev/null
++++ b/server/http/common.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * Shared HTTP helpers: the actor middleware, capability guards, error mapping and citation input.
++ */
++const contracts = require('openvibe-contracts');
++const { checkCapability } = require('../auth/capabilities');
++const { AuthError } = require('../auth/viewer');
++
++const { http } = contracts;
++
++/** Resolves req.actor. Pages pass { services: false }: pages are for browsers. */
++function actorMiddleware(viewers, opts = {}) {
++ return async (req, res, next) => {
++ try {
++ req.actor = await viewers.resolve(req, opts);
++ next();
++ } catch (err) {
++ if (!(err instanceof AuthError)) return next(err);
++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ }
++ };
++}
++
++/**
++ * One capability per route for service tokens. People (user JWTs) and anonymous callers pass here
++ * and are judged by the space rules in the service.
++ */
++function guard(capabilityId) {
++ return function wikiCapabilityGuard(req, res, next) {
++ const a = req.actor;
++ if (!a || a.kind !== 'service') return next();
++ const c = checkCapability(a.claims, capabilityId);
++ if (c.allowed) return next();
++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++}
++
++/** Service errors → problem+json. */
++function sendError(res, req, err, log = console) {
++ const status = err && Number.isInteger(err.status) ? err.status : 500;
++ if (status >= 500 && status !== 503) log.error('[Wiki]', err && err.stack ? err.stack : err);
++ const code = (err && err.code) || (status === 500 ? 'internal.error' : 'request.invalid');
++ const detail = status === 500 ? 'Internal error' : (err && err.message) || undefined;
++ return http.sendProblem(res, status, code, { detail, ctx: req.ov, extra: err && err.extra ? err.extra : undefined });
++}
++
++function run(fn, status = 200, log = console) {
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (res.headersSent) return;
++ res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', 'private, no-store').json(out);
++ } catch (err) {
++ sendError(res, req, err, log);
++ }
++ };
++}
++
++/**
++ * API/form citation input → the citations store's shape. Sources item ids are looked up in
++ * OpenVibe.Sources (url, title, retrieval time and license come from the item's provenance);
++ * nothing is filled in for a URL citation that the caller did not give.
++ */
++async function resolveCitations(list, platform) {
++ if (list == null) return [];
++ if (!Array.isArray(list)) throw Object.assign(new Error('citations must be a list'), { status: 422, code: 'citation.invalid' });
++ if (list.length > 50) throw Object.assign(new Error('at most 50 citations per request'), { status: 422, code: 'citation.too_many' });
++ const out = [];
++ for (const c of list) {
++ if (!c || typeof c !== 'object') throw Object.assign(new Error('a citation is an object'), { status: 422, code: 'citation.invalid' });
++ const itemId = c.source_item_id || c.sourceItemId || null;
++ const quoteText = typeof c.quote === 'string' ? c.quote : (c.quote && c.quote.text) || null;
++ const base = {
++ url: c.url || null, title: c.title || null,
++ retrievedAt: c.retrieved_at || c.retrievedAt || null,
++ quote: quoteText ? { text: String(quoteText).slice(0, 5000) } : null,
++ licenseNote: c.license_note || c.licenseNote || null,
++ anchor: c.anchor || null,
++ };
++ if (itemId) {
++ const item = await platform.sources.getItem(itemId);
++ const prov = item.provenance || {};
++ out.push({
++ ...base, sourceItemId: item.id,
++ url: base.url || item.canonical_url || null,
++ title: base.title || item.title || null,
++ retrievedAt: prov.retrieved_at || null,
++ licenseNote: base.licenseNote || prov.license_note || null,
++ });
++ } else {
++ out.push(base);
++ }
++ }
++ return out;
++}
++
++/** The no-JS form's citation rows (cite_url_N, cite_title_N, cite_retrieved_N, cite_quote_N, cite_item_N). */
++function citationsFromForm(body) {
++ const out = [];
++ for (let i = 0; i < 10; i++) {
++ const url = String(body[`cite_url_${i}`] || '').trim();
++ const item = String(body[`cite_item_${i}`] || '').trim();
++ if (!url && !item) continue;
++ const day = String(body[`cite_retrieved_${i}`] || '').trim();
++ out.push({
++ url: url || null, source_item_id: item || null,
++ title: String(body[`cite_title_${i}`] || '').trim() || null,
++ retrieved_at: day ? (/^\d{4}-\d{2}-\d{2}$/.test(day) ? `${day}T00:00:00.000Z` : day) : null,
++ quote: String(body[`cite_quote_${i}`] || '').trim() || null,
++ });
++ }
++ return out;
++}
++
++module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, citationsFromForm };
ddiff --git a/server/http/machine.js b/server/http/machine.js
nnew file mode 100644
iindex 0000000..631a26f
---- /dev/null
++++ b/server/http/machine.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * Discovery artifacts (roadmap §32.4). Every list here is built from the indexability gate's
++ * decision for each page: only public, published, indexable pages enter sitemaps; only listable
++ * ones enter feeds. Members/private/deleted content never appears.
++ *
++ * /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
++ * /sitemap.xml sitemap index → /sitemaps/spaces.xml, /sitemaps/pages-<n>.xml
++ * /feed.atom recent changes (Atom), /feed.json (JSON Feed 1.1)
++ * /llms.txt orientation for language models
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
++const sharedSeo = require('openvibe-shared/seo');
++
++const PER_SITEMAP = 45000;
++
++function createMachine({ svc, config }) {
++ const router = express.Router();
++ const origin = config.baseUrl;
++ const cache = (res, s = 300) => res.set('Cache-Control', `public, max-age=${s}`);
++
++ router.get('/robots.txt', (_req, res) => {
++ cache(res, 3600).type('text/plain').send(seo.robotsTxt({
++ sitemaps: [`${origin}/sitemap.xml`],
++ disallow: ['/auth/', '/api/', '/new-space', '/search', '/proposals/'],
++ }));
++ });
++
++ function pageEntries() {
++ return svc.publishedPublic().map(({ space, page, decision }) => ({
++ loc: svc.pageUrl(space, page), lastmod: page.revision_published_at, decision,
++ }));
++ }
++
++ function spaceEntries() {
++ return svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => {
++ const url = seo.canonicalUrl(origin, svc.spacePath(s));
++ // A space index is navigation, not an article: no word or source minimum.
++ const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
++ return { loc: url, lastmod: s.updated_at, decision };
++ });
++ }
++
++ router.get('/sitemap.xml', (_req, res) => {
++ const pages = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP });
++ const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
++ pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
++ cache(res).type('application/xml').send(seo.sitemapIndex(maps));
++ });
++ router.get('/sitemaps/spaces.xml', (_req, res) => cache(res).type('application/xml').send(seo.sitemap(spaceEntries()).files[0]));
++ router.get('/sitemaps/pages-:n.xml', (req, res) => {
++ const files = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP }).files;
++ const n = Number(req.params.n);
++ if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
++ cache(res).type('application/xml').send(files[n - 1]);
++ });
++
++ function feedItems() {
++ return svc.recentChanges(50).map(({ space, page, rev, decision }) => ({
++ id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
++ url: svc.pageUrl(space, page),
++ title: rev.fields.title,
++ summary: rev.fields.summary || ssr.markdownToText(rev.content, 280),
++ published: page.revision_published_at,
++ updated: page.revision_published_at,
++ tags: [space.name],
++ decision,
++ }));
++ }
++
++ router.get('/feed.atom', (_req, res) => {
++ const items = feedItems();
++ if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Nothing public has been published yet.');
++ cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
++ });
++ router.get('/feed.json', (_req, res) => {
++ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));
++ });
++
++ router.get('/llms.txt', (_req, res) => {
++ const spaces = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public');
++ cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
++ name: 'OpenVibe.Wiki',
++ summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
++ details: 'Every public page has a JSON representation at the same address plus ".json" (same content, same visibility rules). Revision history and diffs are public for public pages. AI-generated revisions are labelled and are published only after a person approves them.',
++ sections: [
++ { title: 'Spaces', links: spaces.map((s) => ({ title: s.name, url: `${origin}${svc.spacePath(s)}`, note: s.description || '' })) },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap', url: `${origin}/sitemap.xml` },
++ { title: 'Recent changes (Atom)', url: `${origin}/feed.atom` },
++ { title: 'Recent changes (JSON Feed)', url: `${origin}/feed.json` },
++ ] },
++ ],
++ }));
++ });
++
++ return router;
++}
++
++module.exports = { createMachine };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..bf3e92c
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,465 @@
++'use strict';
++/**
++ * Server-rendered pages. Everything a reader or an editor does works with plain links and HTML
++ * form posts; the shared navbar is progressive enhancement.
++ *
++ * GET / spaces + recently published
++ * GET /recent, /search?q=
++ * GET|POST /new-space
++ * GET /s/:space the page tree
++ * GET|POST /s/:space/new new page (?title= prefill, from a red link)
++ * GET|POST /s/:space/settings owner: settings, roles, delete
++ * GET /s/:space/proposals editors: AI proposals to review; POST /proposals/:id
++ * GET /w/:space/:slug the article (?rev=N for an old revision)
++ * GET /w/:space/:slug.json the same data as JSON (same visibility rules)
++ * GET /w/:space/:slug/history every revision; /compare?a&b → /diff/:a/:b
++ * GET /w/:space/:slug/diff/:a/:b word (default) or ?mode=line diff
++ * GET|POST /w/:space/:slug/edit edit form (preview / save / save and publish)
++ * GET|POST /w/:space/:slug/revert revert as a new revision
++ * GET /w/:space/:slug/publish?rev= confirm publishing an older revision
++ * GET|POST /w/:space/:slug/settings publish, schedule, unpublish, move, media, visibility, delete
++ * POST /w/:space/:slug/watch, /discuss
++ *
++ * Caching: an anonymous view of a public, published page is `public, max-age=60`; everything
++ * else (signed-in views, members/private pages, editing surfaces, errors) is `private, no-store`.
++ * A page a visitor may not read answers 404 (not 403), so its existence does not leak.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
++const { renderPage } = require('../render/layout');
++const views = require('../render/views');
++const content = require('../wiki/content');
++const { actorMiddleware, resolveCitations, citationsFromForm } = require('./common');
++
++function createPages({ svc, viewers, platform, config, log = console }) {
++ const router = express.Router();
++ const form = express.urlencoded({ extended: false, limit: '600kb' });
++ router.use(actorMiddleware(viewers, { services: false }));
++
++ const origin = new URL(config.baseUrl).origin;
++ // Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
++ router.use((req, res, next) => {
++ if (req.method !== 'POST') return next();
++ const o = req.get('origin');
++ if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
++ next();
++ });
++
++ const send = (req, res, status, body, o = {}) => {
++ const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
++ res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')
++ .send(renderPage({ config, actor: req.actor, path: o.path || req.path, ...o, body }));
++ };
++ const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
++ const notFound = (req, res) => errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address, or it is not visible to you.');
++ const gone = (req, res) => errorPage(req, res, 410, 'Gone', 'This page was deleted. Its history is kept, but it is no longer published.');
++ const needSignIn = (req, res, message) => send(req, res, 401, views.signInPage({ next: req.originalUrl, message }), { title: 'Sign in', robots: 'noindex, nofollow' });
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
++ if (err && err.status && err.status < 500) return errorPage(req, res, err.status, 'That did not work', err.message);
++ next(err);
++ });
++
++ /** Resolve :space/:slug; follows history-aware redirects (301) and reports deleted pages (410). */
++ function locate(req, res, suffix = '') {
++ const space = svc.findSpace(req.params.space);
++ const page = space && !space.deleted_at ? svc.findPage(space.id, req.params.slug) : null;
++ if (page && page.state === 'deleted') { gone(req, res); return null; }
++ if (!page) {
++ const r = svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
++ if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
++ if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
++ notFound(req, res);
++ return null;
++ }
++ if (!svc.access.canReadPage(space, page, req.actor)) { notFound(req, res); return null; }
++ return { space, page };
++ }
++
++ function locateSpace(req, res) {
++ const space = svc.findSpace(req.params.space);
++ if (!space || space.deleted_at) {
++ const r = svc.resolveRedirect(`/s/${req.params.space}`);
++ if (r && r.status === 301) { res.redirect(301, r.location + req.path.replace(/^\/s\/[^/]+/, '')); return null; }
++ if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
++ notFound(req, res);
++ return null;
++ }
++ if (!svc.access.canReadSpace(space, req.actor)) { notFound(req, res); return null; }
++ return space;
++ }
++
++ // ── Home, recent, search ─────────────────────────────────
++ router.get('/', (req, res) => {
++ const spaces = svc.listSpaces(req.actor);
++ const recent = svc.recentChanges(10);
++ send(req, res, 200, views.home({ spaces, recent, actor: req.actor }), {
++ robots: 'index, follow', cache: 'public', active: 'home', path: '/',
++ jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),
++ });
++ });
++ router.get('/recent', (req, res) => send(req, res, 200, views.recentPage({ items: svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' }));
++ router.get('/search', (req, res) => {
++ const query = String(req.query.q || '').slice(0, 200);
++ send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });
++ });
++
++ // ── Spaces ───────────────────────────────────────────────
++ router.get('/new-space', (req, res) => {
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
++ send(req, res, 200, views.newSpacePage({ staff: req.actor.staff }), { title: 'Start a space', robots: 'noindex, nofollow' });
++ });
++ router.post('/new-space', form, wrap(async (req, res) => {
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
++ const b = req.body || {};
++ try {
++ const space = svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);
++ res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ send(req, res, err.status, views.newSpacePage({ values: b, error: err.message, staff: req.actor.staff }), { title: 'Start a space', robots: 'noindex, nofollow' });
++ }
++ }));
++
++ router.get('/s/:space', (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space) return;
++ const canEdit = svc.access.canEdit(space, req.actor);
++ const proposals = canEdit ? svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id) : [];
++ const open = space.visibility === 'public';
++ send(req, res, 200, views.spacePage({ space, tree: svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {
++ title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Wiki.`, path: svc.spacePath(space),
++ robots: open ? 'index, follow' : 'noindex, nofollow', cache: open ? 'public' : null,
++ jsonLd: open ? seo.structuredData.breadcrumbs([{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }]) : null,
++ });
++ });
++
++ function editorsOnly(req, res, space) {
++ if (svc.access.canEdit(space, req.actor)) return true;
++ if (!req.actor.subject) needSignIn(req, res, 'Sign in to edit. Only editors of this space can change it.');
++ else errorPage(req, res, 403, 'Not an editor', 'Only editors of this space can do that. An owner of the space can give you a role.');
++ return false;
++ }
++
++ function parentsOf(space, actor) {
++ const out = [];
++ const walk = (nodes) => { for (const n of nodes) { out.push(n.page); walk(n.children); } };
++ walk(svc.tree(space, actor));
++ return out;
++ }
++
++ router.get('/s/:space/new', (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space || !editorsOnly(req, res, space)) return;
++ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
++ });
++
++ router.post('/s/:space/new', form, wrap(async (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space || !editorsOnly(req, res, space)) return;
++ const b = req.body || {};
++ const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
++ try {
++ const infobox = content.parseInfoboxText(b.infobox);
++ if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
++ const cites = await resolveCitations(citationsFromForm(b), platform);
++ const out = svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);
++ if (b.op === 'publish') svc.publish(out.page.id, { revision: out.revision.number }, req.actor);
++ res.redirect(303, views.wpath(space, out.page));
++ } catch (err) {
++ if (!err.status || err.status >= 500 && err.status !== 503) throw err;
++ again(err.status, err.message);
++ }
++ }));
++
++ router.get('/s/:space/settings', (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space) return;
++ if (!svc.access.canManage(space, req.actor)) return req.actor.subject ? errorPage(req, res, 403, 'Owners only', 'Only an owner of this space can change its settings.') : needSignIn(req, res, 'Sign in as an owner of this space.');
++ send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
++ });
++
++ router.post('/s/:space/settings', form, wrap(async (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space) return;
++ const b = req.body || {};
++ try {
++ if (b.op === 'role') svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
++ else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
++ else { const s = svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
++ res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=1`);
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ if (!svc.access.canManage(space, req.actor)) return errorPage(req, res, err.status, 'Owners only', err.message);
++ send(req, res, err.status, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
++ }
++ }));
++
++ router.get('/s/:space/proposals', (req, res) => {
++ const space = locateSpace(req, res);
++ if (!space || !editorsOnly(req, res, space)) return;
++ const items = svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id).map((proposal) => ({ proposal, page: svc.pageById(proposal.page_id) }));
++ send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
++ });
++
++ router.post('/proposals/:id', form, wrap(async (req, res) => {
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
++ const p = svc.getProposal(req.params.id);
++ if (!p) return notFound(req, res);
++ svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);
++ const space = svc.spaceById(p.space_id);
++ res.redirect(303, `/s/${encodeURIComponent(space.slug)}/proposals`);
++ }));
++
++ // ── Articles ─────────────────────────────────────────────
++ async function discussionFor(space, page, actor) {
++ if (page.state !== 'published' || svc.access.effectiveVisibility(space, page) !== 'public') return { state: 'not_public' };
++ if (!platform.community.configured) return { state: 'unavailable', reason: 'OpenVibe.Community is not configured on this server' };
++ try {
++ const threadId = await svc.discussionThread(space, page, platform.community);
++ const data = await platform.community.getThread(threadId, { subject: actor.subject });
++ return { state: 'ok', threadId, thread: data.thread, comments: data.comments || [] };
++ } catch (err) {
++ return { state: 'unavailable', reason: err.code === 'thread.not_found' ? 'the thread was removed' : null };
++ }
++ }
++
++ function articleData(v, space, page) {
++ return {
++ id: page.id, space: space.slug, slug: page.slug, url: svc.pageUrl(space, page), state: page.state, visibility: svc.access.effectiveVisibility(space, page),
++ revision: v.revision.number, title: v.revision.fields.title, summary: v.revision.fields.summary || null, body_markdown: v.revision.content,
++ published_at: page.published_at ? new Date(page.published_at).toISOString() : null,
++ revision_published_at: page.revision_published_at ? new Date(page.revision_published_at).toISOString() : null,
++ revision_created_at: v.revision.createdAt,
++ authorship: v.authorship ? { mode: v.authorship.mode, workflow: v.authorship.workflow || null, reviewed: !!(v.review && v.review.decision === 'approved') } : null,
++ infobox: v.infobox,
++ citations: v.citations.map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null, license_note: c.licenseNote })),
++ attachments: v.attachments.map((a) => ({ media_id: a.mediaId, state: a.state, broken_reason: a.brokenReason, alt: a.alt, caption: a.caption })),
++ links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug })),
++ indexability: { indexable: v.decision.indexable, reasons: v.decision.codes },
++ };
++ }
++
++ router.get('/w/:space/:slug', wrap(async (req, res) => {
++ const json = /\.json$/.test(req.params.slug);
++ if (json) req.params.slug = req.params.slug.slice(0, -5);
++ const found = locate(req, res, json ? '.json' : '');
++ if (!found) return;
++ const { space, page } = found;
++ const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
++ if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
++ let v;
++ try { v = svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
++ const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
++ const cache = open && v.isPublishedRevision ? 'public' : null;
++ if (json) {
++ res.status(200).set('Cache-Control', cache && req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization')
++ .set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
++ return;
++ }
++ const resolve = (t) => svc.resolveLink(t, req.actor);
++ const bodyHtml = svc.renderRevision(space, v.revision, req.actor);
++ const discussion = v.isPublishedRevision ? await discussionFor(space, page, req.actor) : { state: 'not_public' };
++ const url = svc.pageUrl(space, page);
++ const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];
++ // The gate decides robots for the published revision; any other revision is noindex.
++ const decision = v.isPublishedRevision ? v.decision : { ...v.decision, indexable: false, robots: 'noindex, nofollow', canonical: url };
++ const description = v.revision.fields.summary || ssr.markdownToText(v.revision.content, 160);
++ const jsonLd = open && v.isPublishedRevision ? [
++ seo.structuredData.article({
++ headline: v.revision.fields.title, url, description,
++ datePublished: page.published_at, dateModified: page.revision_published_at,
++ citations: v.citations, wordCount: ssr.wordCount(ssr.markdownToText(v.revision.content)),
++ }),
++ seo.structuredData.breadcrumbs(crumbsLd),
++ ].filter(Boolean) : null;
++ const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
++ send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => resolve(content.parseTarget(title, space.slug) || { space: space.slug, slug: '-', title }), flash: req.query.saved ? 'Saved.' : null }), {
++ head, path: svc.pagePath(space, page), cache,
++ });
++ }));
++
++ router.get('/w/:space/:slug/history', (req, res) => {
++ const found = locate(req, res, '/history');
++ if (!found) return;
++ const { space, page } = found;
++ send(req, res, 200, views.historyPage({ space, page, list: svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {
++ title: `History of ${page.title}`, robots: 'noindex, follow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
++ });
++ });
++
++ router.get('/w/:space/:slug/compare', (req, res) => {
++ const found = locate(req, res);
++ if (!found) return;
++ const a = Number(req.query.a), b = Number(req.query.b);
++ if (!Number.isInteger(a) || !Number.isInteger(b)) return errorPage(req, res, 400, 'Pick two revisions', 'Choose a "from" and a "to" revision to compare.');
++ res.redirect(303, `${views.wpath(found.space, found.page)}/diff/${Math.min(a, b)}/${Math.max(a, b)}`);
++ });
++
++ router.get('/w/:space/:slug/diff/:a/:b', (req, res) => {
++ const found = locate(req, res, `/diff/${req.params.a}/${req.params.b}`);
++ if (!found) return;
++ const { space, page } = found;
++ let diff;
++ try { diff = svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }
++ send(req, res, 200, views.diffPage({ space, page, diff }), {
++ title: `Changes to ${page.title}`, robots: 'noindex, nofollow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
++ });
++ });
++
++ // Editing a missing page (a red link) opens the new-page form with the title filled in.
++ function editTarget(req, res) {
++ const space = svc.findSpace(req.params.space);
++ if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !svc.findPage(space.id, req.params.slug) && !svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {
++ res.redirect(303, `/s/${encodeURIComponent(space.slug)}/new?title=${encodeURIComponent(String(req.query.title || req.params.slug))}`);
++ return null;
++ }
++ return locate(req, res, '/edit');
++ }
++
++ router.get('/w/:space/:slug/edit', (req, res) => {
++ const found = editTarget(req, res);
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ // Start from the newest revision that is not an unapproved AI proposal; concurrency is
++ // still checked against the head.
++ const list = svc.history(page, { limit: 500 });
++ const headNumber = list[0].number;
++ const start = list.find((r) => !r.proposal || r.proposal.status === 'approved') || list[0];
++ const from = svc.view(space, page, req.actor, { revision: start.number });
++ const note = start.number !== headNumber ? `The newest revision (${headNumber}) is an AI proposal that has not been approved; this form starts from revision ${start.number}.` : null;
++ send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
++ });
++
++ router.post('/w/:space/:slug/edit', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/edit');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ const b = req.body || {};
++ const expected = Number(b.expected_revision);
++ const base = Number(b.base_revision) || expected;
++ const keep = [].concat(b.keep_citation || []).map(String);
++ let baseView = null;
++ try { baseView = Number.isInteger(base) && base > 0 ? svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
++ const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };
++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
++ try {
++ const infobox = content.parseInfoboxText(b.infobox);
++ if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
++ const cites = await resolveCitations(citationsFromForm(b), platform);
++ const out = svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);
++ if (b.op === 'publish') svc.publish(page.id, { revision: out.revision.number }, req.actor);
++ res.redirect(303, `${views.wpath(space, out.page)}${b.op === 'publish' ? '' : `?rev=${out.revision.number}`}`);
++ } catch (err) {
++ if (err.code === 'revision.conflict') return again(409, `Someone saved revision ${err.extra ? err.extra.current : ''} while you were editing. Your text is below; open the page in another tab, merge by hand, and save again.`);
++ if (!err.status || err.status >= 500 && err.status !== 503) throw err;
++ again(err.status, err.message);
++ }
++ }));
++
++ router.get('/w/:space/:slug/revert', (req, res) => {
++ const found = locate(req, res, '/revert');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ const to = Number(req.query.to);
++ const head = svc.history(page, { limit: 1 })[0].number;
++ if (!Number.isInteger(to) || to < 1 || to > head) return notFound(req, res);
++ send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
++ });
++
++ router.post('/w/:space/:slug/revert', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/revert');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ const b = req.body || {};
++ const out = svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);
++ res.redirect(303, out.published ? views.wpath(space, out.page) : `${views.wpath(space, out.page)}?rev=${out.revision.number}`);
++ }));
++
++ router.get('/w/:space/:slug/publish', (req, res) => {
++ const found = locate(req, res, '/publish');
++ if (!found) return;
++ if (!editorsOnly(req, res, found.space)) return;
++ const rev = Number(req.query.rev);
++ if (!Number.isInteger(rev)) return notFound(req, res);
++ send(req, res, 200, views.confirmPublishPage({ ...found, rev }), { title: `Publish ${found.page.title}`, robots: 'noindex, nofollow' });
++ });
++
++ router.get('/w/:space/:slug/settings', (req, res) => {
++ const found = locate(req, res, '/settings');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });
++ });
++
++ function renderPageSettings(req, res, space, page, status, extra = {}) {
++ const view = svc.view(space, page, req.actor, {});
++ const headNumber = svc.history(page, { limit: 1 })[0].number;
++ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
++ }
++
++ router.post('/w/:space/:slug/settings', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/settings');
++ if (!found) return;
++ const { space, page } = found;
++ if (!editorsOnly(req, res, space)) return;
++ const b = req.body || {};
++ const back = (p = page, q = 'saved=1') => res.redirect(303, `${views.wpath(space, p)}/settings?${q}`);
++ try {
++ switch (b.op) {
++ case 'publish': svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));
++ case 'schedule': {
++ const at = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(String(b.run_at)) ? b.run_at : `${b.run_at}Z`);
++ svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);
++ return back();
++ }
++ case 'unpublish': svc.unpublish(page.id, req.actor); return back();
++ case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
++ case 'attach': svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor); return back();
++ case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
++ case 'verify': {
++ const results = await svc.verifyMedia(page.id, platform.media.resolve);
++ const count = (k) => results.filter((r) => r.outcome === k).length;
++ return back(page, `saved=verify&summary=${encodeURIComponent(`${count('available')} available, ${count('broken')} broken, ${count('check_failed')} not checked (Media did not answer)`)}`);
++ }
++ case 'visibility': svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();
++ case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
++ default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
++ }
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
++ }
++ }));
++
++ router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/watch');
++ if (!found) return;
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
++ svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');
++ res.redirect(303, views.wpath(found.space, found.page));
++ }));
++
++ router.post('/w/:space/:slug/discuss', form, wrap(async (req, res) => {
++ const found = locate(req, res, '/discuss');
++ if (!found) return;
++ const { space, page } = found;
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in to comment.');
++ const message = String((req.body || {}).message || '').trim();
++ if (!message) return res.redirect(303, `${views.wpath(space, page)}#discussion`);
++ const d = await discussionFor(space, page, req.actor);
++ if (d.state !== 'ok') return errorPage(req, res, 503, 'Discussion unavailable', 'The discussion is held by OpenVibe.Community, which could not take the comment right now. Nothing was posted.');
++ await platform.community.addComment(d.threadId, { subject: req.actor.subject, message });
++ res.redirect(303, `${views.wpath(space, page)}#discussion`);
++ }));
++
++ return { router, notFound, errorPage };
++}
++
++module.exports = { createPages };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..366e448
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * OpenVibe.Wiki entry point.
++ *
++ * node server/index.js (systemd: openvibe-wiki.service, port 4800)
++ *
++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
++ * clock/fetch/log/keys, and returns handles to every part. Workers (scheduled publication, the
++ * Events outbox relay, the Media attachment check) run only when asked (`workers: true`).
++ */
++const { load } = require('./config');
++const { openDb, createStores } = require('./db');
++const { createPlatform } = require('./integrations/platform');
++const { createWikiService } = require('./wiki/service');
++const { createKeyStore } = require('./auth/keys');
++const { createViewerResolver } = require('./auth/viewer');
++const { createApp } = require('./app');
++
++async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true } = {}) {
++ config = config || load();
++ const db = openDb(config.dbPath);
++ const stores = createStores(db, { now });
++ const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config, now, log });
++ const keys = createKeyStore({ config, fetchImpl, log, publicKey });
++ keys.ensure().catch(() => {});
++ const viewers = createViewerResolver({ keys, config });
++ const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl });
++
++ const timers = [];
++ if (workers) {
++ let scheduling = false;
++ timers.push(setInterval(async () => {
++ if (scheduling) return;
++ scheduling = true;
++ try {
++ const out = await svc.runSchedule();
++ for (const j of out.failed) log.warn(`[Wiki] scheduled ${j.action} of ${j.entityId} r${j.revision} failed: ${j.lastError}`);
++ } catch (err) { log.warn(`[Wiki] schedule worker: ${err.message}`); } finally { scheduling = false; }
++ }, config.scheduleIntervalMs));
++ if (platform.media.configured) {
++ timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
++ }
++ if (platform.eventsConfigured) platform.outbox.start();
++ for (const t of timers) if (t.unref) t.unref();
++ }
++
++ let server = null;
++ if (listen) {
++ await new Promise((resolve) => { server = app.listen(config.port, config.host, resolve); });
++ server.keepAliveTimeout = 65000;
++ log.log(`[Wiki] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
++ }
++
++ async function stop() {
++ for (const t of timers) clearInterval(t);
++ await platform.outbox.stop();
++ if (server) await new Promise((resolve) => server.close(resolve));
++ db.close();
++ }
++ return { app, db, svc, stores, platform, keys, viewers, server, config, stop };
++}
++
++module.exports = { start };
++
++if (require.main === module) {
++ require('dotenv').config();
++ start().then((h) => {
++ const shutdown = (signal) => {
++ console.log(`[Wiki] ${signal} — closing`);
++ h.stop().finally(() => process.exit(0));
++ setTimeout(() => process.exit(0), 10000).unref();
++ };
++ process.on('SIGTERM', () => shutdown('SIGTERM'));
++ process.on('SIGINT', () => shutdown('SIGINT'));
++ }).catch((err) => {
++ console.error('[Wiki] failed to start:', err);
++ process.exit(1);
++ });
++}
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
nnew file mode 100644
iindex 0000000..f129b3e
---- /dev/null
++++ b/server/integrations/platform.js
@@@ -0,0 +1,134 @@
++'use strict';
++/**
++ * Clients for the services Wiki composes (roadmap §29): Events (outbox relay), Community
++ * (discussion threads), Sources (citation items) and Media (attachment checks). Each call uses a
++ * Network client-credentials token for svc:wiki, minted per audience by the SDK token client.
++ *
++ * Every integration is optional. Missing configuration or an unreachable service is an explicit
++ * failure (an error with a stable code, or `configured: false`), never invented data.
++ */
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++const { createDiscussionClient } = require('openvibe-publishing/discussion');
++
++class IntegrationError extends Error {
++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
++}
++
++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {
++ const tokenClient = tokens || (config.oauth.clientSecret
++ ? createServiceTokenClient({ network: config.networkInternalUrl || config.networkUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const forAudience = (audience) => ({
++ authHeaders: () => {
++ if (!tokenClient) throw new IntegrationError(503, 'wiki.principal_unconfigured', 'OV_OAUTH_CLIENT_SECRET is not set: Wiki has no service token');
++ return tokenClient.authHeaders({ audience });
++ },
++ invalidate: () => tokenClient && tokenClient.invalidate({ audience }),
++ });
++
++ // ── Events: the outbox always records; the relay runs only with EVENTS_URL ──
++ const sdk = createClient({
++ fetch: fetchImpl,
++ network: config.networkUrl,
++ autoDiscover: false,
++ ...(tokenClient ? { tokenProvider: tokenClient } : {}),
++ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : {},
++ onWarning: (msg) => log.warn(`[Wiki] ${msg}`),
++ });
++ const events = createEventsClient(sdk, { source: 'wiki' });
++ const outbox = createOutbox(db, {
++ events, table: 'wiki_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
++ onError: (err) => log.warn(`[Wiki] event relay: ${err && err.message}`),
++ });
++ outbox.ensureSchema();
++
++ async function getJson(url, audience, { headers = {}, timeoutMs = 5000 } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await forAudience(audience).authHeaders()), ...headers }, signal: AbortSignal.timeout(timeoutMs) });
++ } catch (err) {
++ if (err instanceof IntegrationError) throw err;
++ throw new IntegrationError(503, 'upstream.unavailable', `${new URL(url).host} did not answer: ${err && err.name === 'TimeoutError' ? 'timeout' : err && err.message}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status === 401) forAudience(audience).invalidate();
++ return { status: res.status, body };
++ }
++
++ // ── Community: comment threads (reference, never copy) ──
++ const communityBase = config.communityInternalUrl || config.communityUrl;
++ const discussionClient = createDiscussionClient({ communityUrl: communityBase, tokenClient: forAudience('openvibe.community'), fetchImpl });
++ const community = {
++ configured: !!(tokenClient && communityBase),
++ resolveThread: (ref, opts) => discussionClient.resolveThread(ref, opts),
++ async getThread(threadId, { subject } = {}) {
++ const headers = subject && SUBJECT_RE.test(subject) ? { 'X-OV-Subject': subject } : {};
++ const r = await getJson(`${communityBase}/api/v1/comments/threads/${encodeURIComponent(threadId)}?limit=50`, 'openvibe.community', { headers });
++ if (r.status === 404) throw new IntegrationError(404, 'thread.not_found', 'The discussion thread no longer exists');
++ if (r.status !== 200 || !r.body || !r.body.thread) throw new IntegrationError(503, 'discussion.unavailable', `Community answered ${r.status}`);
++ return r.body;
++ },
++ async addComment(threadId, { subject, message }) {
++ if (!SUBJECT_RE.test(String(subject || ''))) throw new IntegrationError(403, 'wiki.person_required', 'Only a signed-in person can comment');
++ let res;
++ try {
++ res = await fetchImpl(`${communityBase}/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-OV-Subject': subject, ...(await forAudience('openvibe.community').authHeaders()) },
++ body: JSON.stringify({ message: String(message || '').slice(0, 5000) }),
++ signal: AbortSignal.timeout(5000),
++ });
++ } catch (err) {
++ if (err instanceof IntegrationError) throw err;
++ throw new IntegrationError(503, 'discussion.unavailable', 'Community did not answer');
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status !== 201 && res.status !== 200) {
++ throw new IntegrationError(res.status >= 500 ? 503 : res.status, (body && body.code) || 'discussion.unavailable', (body && (body.detail || body.error)) || `Community answered ${res.status}`);
++ }
++ return body;
++ },
++ };
++
++ // ── Sources: citation items (sources.item@1) ──
++ const sources = {
++ configured: !!(tokenClient && config.sourcesInternalUrl),
++ async getItem(id) {
++ if (!/^itm_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw new IntegrationError(422, 'citation.invalid_source_item', 'A Sources item id looks like itm_<ULID>');
++ if (!sources.configured) throw new IntegrationError(503, 'sources.unavailable', 'OpenVibe.Sources is not configured (OV_SOURCES_INTERNAL_URL)');
++ const r = await getJson(`${config.sourcesInternalUrl}/api/v1/items/${encodeURIComponent(id)}`, 'openvibe.sources');
++ if (r.status === 404) throw new IntegrationError(422, 'citation.source_item_not_found', `Sources has no item ${id}`);
++ if (r.status !== 200 || !r.body || !r.body.item) throw new IntegrationError(503, 'sources.unavailable', `Sources answered ${r.status}`);
++ return r.body.item;
++ },
++ };
++
++ // ── Media: does an attached object still exist? (media.object.read, namespace = WIKI_MEDIA_APP) ──
++ const media = {
++ configured: !!(tokenClient && config.mediaInternalUrl),
++ publicUrl: (mediaId) => `${config.mediaPublicUrl}/o/${encodeURIComponent(mediaId)}`,
++ /** { exists: true } | { exists: false, reason } | throws (outage: no verdict). */
++ async resolve(mediaId) {
++ if (!media.configured) throw new IntegrationError(503, 'media.unavailable', 'OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL)');
++ const r = await getJson(`${config.mediaInternalUrl}/api/v2/${encodeURIComponent(config.mediaApp)}/objects/${encodeURIComponent(mediaId)}`, 'openvibe.media');
++ if (r.status === 404) return { exists: false, reason: 'not_found' };
++ // 401/403 is about Wiki's grant, not about the object: no verdict (check_failed).
++ if (r.status === 410) return { exists: false, reason: 'deleted' };
++ if (r.status !== 200 || !r.body) throw new IntegrationError(503, 'media.unavailable', `Media answered ${r.status}`);
++ const obj = r.body.object || r.body;
++ if (obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
++ return { exists: true };
++ },
++ };
++
++ return {
++ tokenClient, sdk, events, outbox, community, sources, media,
++ eventsConfigured: !!(tokenClient && config.eventsUrl),
++ };
++}
++
++module.exports = { createPlatform, IntegrationError };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..de6691a
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * Page shell: every page is server-rendered through this. The <head> SEO block comes from
++ * openvibe-shared/seo (robots always explicit: from the indexability gate for articles, noindex for
++ * editing surfaces), the shared chrome from openvibe-shared (app icon, SSR footer, a <noscript>
++ * navigation) plus the Network's navbar.js/footer.js as progressive enhancement. Nothing on the
++ * page needs JavaScript to be read, navigated, edited or submitted.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const sharedSeo = require('openvibe-shared/seo');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++const { escapeHtml: esc } = require('openvibe-publishing/ssr');
++
++const SITE_NAME = 'OpenVibe.Wiki';
++const NETWORK_URL = 'https://openvibe.network';
++const DEFAULT_DESCRIPTION = 'OpenVibe.Wiki: wiki spaces with page trees, revision history, citations and discussion, part of the OpenVibe network.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++
++const hashes = new Map();
++function asset(rel) {
++ if (!hashes.has(rel)) {
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ }
++ return `/${rel}?v=${hashes.get(rel)}`;
++}
++
++function navConfig(o, config) {
++ return {
++ service: 'wiki',
++ apiBase: NETWORK_URL,
++ links: [
++ { label: 'Spaces', href: '/', active: o.active === 'home' },
++ { label: 'Recent changes', href: '/recent', active: o.active === 'recent' },
++ { label: 'Search', href: '/search', active: o.active === 'search' },
++ ],
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
++ };
++}
++
++/**
++ * o: title, description, path (canonical path), robots (required), head (extra head HTML, e.g. the
++ * gate's metaTags), jsonLd, body, active, actor, config, feeds
++ */
++function renderPage(o) {
++ const { config } = o;
++ if (!o.robots && !o.head) throw new TypeError('renderPage needs explicit robots (or a head block built from the gate)');
++ const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
++ const head = o.head || sharedSeo.headTags({
++ title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.baseUrl}${o.path || '/'}`,
++ robots: o.robots, siteName: SITE_NAME, type: o.ogType || 'website', jsonLd: o.jsonLd || null,
++ });
++ const feeds = o.feeds === false ? [] : [
++ { type: 'application/atom+xml', title: `${SITE_NAME}: recent changes (Atom)`, href: '/feed.atom' },
++ { type: 'application/feed+json', title: `${SITE_NAME}: recent changes (JSON Feed)`, href: '/feed.json' },
++ ];
++ const actor = o.actor || { kind: 'anonymous' };
++ const who = actor.kind === 'user'
++ ? `<span class="wk-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span> <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
++ : `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++${head}
++${appIcon.headTags({ site: 'network' })}
++${feeds.map((f) => `<link rel="alternate" type="${f.type}" title="${esc(f.title)}" href="${f.href}">`).join('\n')}
++<link rel="stylesheet" href="${asset('css/wiki.css')}">
++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
++<script src="${NETWORK_URL}/shared/footer.js" defer></script>
++</head>
++<body>
++<a class="wk-skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Spaces', href: '/' }, { label: 'Recent changes', href: '/recent' }, { label: 'Search', href: '/search' }] })}
++<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><span class="wk-account">${who}</span></header>
++<main id="main" class="wk-main">
++${o.body || ''}
++</main>
++${chrome.footer({ service: 'wiki', variant: 'full' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME } }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* chrome is optional */ }
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
ddiff --git a/server/render/views.js b/server/render/views.js
nnew file mode 100644
iindex 0000000..609ee38
---- /dev/null
++++ b/server/render/views.js
@@@ -0,0 +1,315 @@
++'use strict';
++/**
++ * Page bodies. Every interpolated value goes through openvibe-publishing/ssr's html`` (escaped
++ * unless raw()). Forms are plain HTML posts; nothing here needs JavaScript.
++ */
++const ssr = require('openvibe-publishing/ssr');
++const { figureHtml } = require('openvibe-publishing/media');
++const { infoboxToText } = require('../wiki/content');
++
++const { html, raw } = ssr;
++const t = (v) => raw(ssr.timeTag(v));
++const e = encodeURIComponent;
++const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe account)', private: 'Private (people with a role in this space)' };
++
++function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
++
++function notice(kind, text) { return html`<p class="wk-notice wk-${kind}" role="status">${text}</p>`; }
++
++function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }
++
++function errorBody({ status, title, message }) {
++ return html`<section class="wk-error"><h1>${title}</h1><p>${message}</p><p class="wk-muted">HTTP ${String(status)}</p><p><a href="/">Back to the wiki</a></p></section>`;
++}
++
++function home({ spaces, recent, actor }) {
++ const official = spaces.filter((s) => s.kind === 'official');
++ const user = spaces.filter((s) => s.kind === 'user');
++ const list = (items) => items.length ? html`<ul class="wk-spaces">${items.map((s) => html`<li><a href="/s/${e(s.slug)}"><strong>${s.name}</strong></a>${s.visibility !== 'public' ? html` <span class="wk-tag">${s.visibility}</span>` : ''}${s.description ? html`<br><span class="wk-muted">${s.description}</span>` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None yet.</p>`;
++ return html`<section class="wk-intro"><h1>OpenVibe.Wiki</h1>
++<p>Wiki spaces with page trees, revision history, citations and discussion. Official spaces are edited by OpenVibe staff; anyone signed in with an OpenVibe account can start a space of their own.</p>
++${actor && actor.subject ? html`<p><a class="wk-button" href="/new-space">Start a space</a></p>` : html`<p><a href="/auth/login?next=%2Fnew-space">Sign in</a> to start a space.</p>`}</section>
++<section><h2>Official spaces</h2>${list(official)}</section>
++<section><h2>Community spaces</h2>${list(user)}</section>
++<section><h2>Recently published</h2>${recent.length ? html`<ul class="wk-recent">${recent.map((r) => html`<li><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name} · ${t(r.page.revision_published_at)}</span></li>`)}</ul>` : html`<p class="wk-muted">Nothing published yet.</p>`}
++<p><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>`;
++}
++
++function treeHtml(nodes, space) {
++ if (!nodes.length) return '';
++ return html`<ul class="wk-tree">${nodes.map((n) => html`<li><a href="${wpath(space, n.page)}">${n.page.title}</a>${n.page.state !== 'published' ? html` <span class="wk-tag">${n.page.state}</span>` : ''}${n.page.visibility !== 'public' ? html` <span class="wk-tag">${n.page.visibility}</span>` : ''}${treeHtml(n.children, space)}</li>`)}</ul>`;
++}
++
++function spacePage({ space, tree, canEdit, canManage, proposals }) {
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name }])}
++<section><h1>${space.name}</h1>
++<p class="wk-muted">${space.kind === 'official' ? 'Official space' : 'Community space'} · ${VIS_LABEL[space.visibility]}</p>
++${space.description ? html`<p>${space.description}</p>` : ''}
++${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}
++${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length} AI proposal(s) wait for review.`)} <a href="/s/${e(space.slug)}/proposals">Review them</a></p>` : ''}
++<h2>Pages</h2>${tree.length ? raw(treeHtml(tree, space)) : html`<p class="wk-muted">No pages yet.</p>`}
++</section>`;
++}
++
++function infoboxHtml(entries, resolve) {
++ if (!entries.length) return '';
++ const cell = (x) => {
++ switch (x.type) {
++ case 'url': return html`<a href="${x.value}" rel="noopener nofollow">${x.value.replace(/^https?:\/\//, '')}</a>`;
++ case 'date': return raw(ssr.timeTag(x.value, { label: String(x.value).slice(0, 10) }) || ssr.escapeHtml(x.value));
++ case 'boolean': return x.value ? 'Yes' : 'No';
++ case 'number': return String(x.value);
++ case 'page': { const r = resolve(x.value); return r.exists ? html`<a href="${r.href}">${x.value}</a>` : html`<span class="ov-redlink">${x.value}</span>`; }
++ case 'media': return html`<code>${x.value}</code>`;
++ default: return x.value;
++ }
++ };
++ return html`<aside class="wk-infobox" aria-label="Infobox"><table><tbody>${entries.map((x) => html`<tr data-key="${x.key}" data-type="${x.type}"><th scope="row">${x.label}</th><td>${cell(x)}</td></tr>`)}</tbody></table></aside>`;
++}
++
++function citationsHtml(cites) {
++ if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p></section>`;
++ return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;
++}
++
++function mediaHtml(attachments, urlFor) {
++ if (!attachments.length) return '';
++ return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: a.brokenReason === 'deleted' ? 'This media was deleted from OpenVibe.Media and is no longer available.' : 'This media is no longer available.' })))}</section>`;
++}
++
++function discussionHtml(d, { space, page, actor }) {
++ const head = html`<h2>Discussion</h2>`;
++ if (!d || d.state === 'not_public') return html`<section class="wk-discussion">${head}<p class="wk-muted">Discussion is only open on public, published pages.</p></section>`;
++ if (d.state === 'unavailable') return html`<section class="wk-discussion">${head}<p class="wk-notice wk-warn">The discussion (held by OpenVibe.Community) could not be loaded right now${d.reason ? html`: ${d.reason}` : ''}.</p></section>`;
++ const comments = (d.comments || []).filter((c) => !c.deleted);
++ const form = actor && actor.subject
++ ? html`<form method="post" action="${wpath(space, page)}/discuss" class="wk-form"><label for="wk-comment">Add a comment (posted to OpenVibe.Community as you)</label><textarea id="wk-comment" name="message" rows="3" maxlength="5000" required></textarea><button type="submit">Comment</button></form>`
++ : html`<p><a href="/auth/login?next=${e(wpath(space, page))}">Sign in</a> to comment.</p>`;
++ return html`<section class="wk-discussion" id="discussion">${head}
++${comments.length ? html`<ul class="wk-comments">${comments.map((c) => html`<li><strong>${c.display_name || 'Someone'}</strong> <span class="wk-muted">${t(c.created_at)}${c.origin === 'ai' ? ' · AI' : ''}</span><p>${c.message}</p></li>`)}</ul>` : html`<p class="wk-muted">No comments yet.</p>`}
++${form}</section>`;
++}
++
++function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolve, flash }) {
++ const { space, page, revision: rev } = v;
++ const base = wpath(space, page);
++ const trail = [{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }];
++ if (v.parent) trail.push({ name: v.parent.title, url: wpath(space, v.parent) });
++ trail.push({ name: rev.fields.title || page.title });
++ const banners = [];
++ if (flash) banners.push(notice('ok', flash));
++ if (page.state !== 'published') banners.push(notice('warn', `This page is ${page.state === 'scheduled' ? 'scheduled, not published yet' : page.state}. Only editors of the space can see it.`));
++ else if (!v.isPublishedRevision) banners.push(notice('warn', `You are looking at revision ${rev.number}, not the published revision ${page.published_revision}.`));
++ else if (v.canEdit && v.headNumber > rev.number) banners.push(html`<p class="wk-notice wk-info" role="status">Revision ${String(v.headNumber)} is newer than the published one and only editors can see it. <a href="${base}?rev=${String(v.headNumber)}">View it</a> · <a href="${base}/diff/${String(rev.number)}/${String(v.headNumber)}">compare</a></p>`);
++ if (v.disclosure) banners.push(html`<p class="wk-notice wk-ai" role="note"><strong>${v.disclosure.short}.</strong> ${v.disclosure.long}</p>`);
++ if (v.proposal && v.proposal.status === 'pending') banners.push(notice('info', 'This revision is an AI proposal waiting for a person to approve or reject it.'));
++ if (v.canEdit && v.decision && !v.decision.indexable && page.state === 'published') banners.push(html`<p class="wk-notice wk-info">Search engines are told not to index this page: ${v.decision.reasons.map((r) => r.code + (r.detail ? ` (${r.detail})` : '')).join(', ')}.</p>`);
++ const actions = [html`<a href="${base}/history">History</a>`];
++ if (v.canEdit) actions.unshift(html`<a href="${base}/edit">Edit</a>`), actions.push(html`<a href="${base}/settings">Page settings</a>`);
++ return html`${crumbs(trail)}
++<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
++<header><h1>${rev.fields.title || page.title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav></header>
++${banners}
++${raw(infoboxHtml(v.infobox, resolve))}
++<div class="wk-content">${raw(contentHtml)}</div>
++${raw(mediaHtml(v.attachments, mediaUrl))}
++${raw(citationsHtml(v.citations))}
++${v.children.length ? html`<section><h2>Subpages</h2><ul>${v.children.map((c) => html`<li><a href="${wpath(space, c)}">${c.title}</a></li>`)}</ul></section>` : ''}
++${v.backlinks.length ? html`<section><h2>What links here</h2><ul>${v.backlinks.map((b) => html`<li><a href="${wpath(b.space, b.page)}">${b.page.title}</a>${b.space.id !== space.id ? html` <span class="wk-muted">(${b.space.name})</span>` : ''}</li>`)}</ul></section>` : ''}
++<footer class="wk-meta"><p>Revision ${String(rev.number)}${rev.kind === 'revert' ? html` (a revert to revision ${String(rev.revertedTo)})` : ''}, saved ${t(rev.createdAt)}${page.published_at ? html` · first published ${t(page.published_at)}` : ''} · <a href="${base}/history">history</a> · <a href="${base}.json">JSON</a></p>
++${actor && actor.subject ? html`<form method="post" action="${base}/watch" class="wk-inline"><input type="hidden" name="on" value="${v.watching ? '0' : '1'}"><button type="submit">${v.watching ? 'Stop watching' : 'Watch this page'}</button></form>` : ''}</footer>
++</article>
++${raw(discussionHtml(discussion, { space, page, actor }))}`;
++}
++
++function historyPage({ space, page, list, canEdit }) {
++ const base = wpath(space, page);
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: page.state === 'published' ? base : null }, { name: 'History' }])}
++<section><h1>History of ${page.title}</h1>
++<p class="wk-muted">Every revision is kept. A revert adds a new revision; nothing is rewritten.</p>
++<form method="get" action="${base}/compare" class="wk-form wk-compare">
++<table class="wk-history"><thead><tr><th scope="col">From</th><th scope="col">To</th><th scope="col">Revision</th><th scope="col">Saved</th><th scope="col">By</th><th scope="col">Kind</th><th scope="col">Note</th></tr></thead><tbody>
++${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><input type="radio" name="a" value="${String(r.number)}" aria-label="Compare from revision ${String(r.number)}"${i === 1 ? raw(' checked') : ''}></td><td><input type="radio" name="b" value="${String(r.number)}" aria-label="Compare to revision ${String(r.number)}"${i === 0 ? raw(' checked') : ''}></td>
++<td><a href="${base}?rev=${String(r.number)}">${String(r.number)}</a>${r.published ? html` <span class="wk-tag">published</span>` : ''}</td>
++<td>${t(r.createdAt)}</td><td>${r.author || ''}</td>
++<td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td>
++<td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>
++${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}
++${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}
++</tbody></table>
++${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</button></p>` : ''}
++</form></section>`;
++}
++
++function diffPage({ space, page, diff }) {
++ const base = wpath(space, page);
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'History', url: `${base}/history` }, { name: `Revision ${diff.from} → ${diff.to}` }])}
++<section><h1>Changes to ${page.title}: revision ${String(diff.from)} → ${String(diff.to)}</h1>
++<p><a href="${base}?rev=${String(diff.from)}">View revision ${String(diff.from)}</a> · <a href="${base}?rev=${String(diff.to)}">View revision ${String(diff.to)}</a> · <a href="${base}/diff/${String(diff.from)}/${String(diff.to)}?mode=line">line diff</a> · <a href="${base}/diff/${String(diff.from)}/${String(diff.to)}">word diff</a></p>
++${diff.fields.length ? html`<h2>Fields</h2><table class="wk-fields"><thead><tr><th scope="col">Field</th><th scope="col">Before</th><th scope="col">After</th></tr></thead><tbody>${diff.fields.map((f) => html`<tr><th scope="row">${f.field}</th><td><pre>${JSON.stringify(f.from, null, 1)}</pre></td><td><pre>${JSON.stringify(f.to, null, 1)}</pre></td></tr>`)}</tbody></table>` : ''}
++<h2>Text</h2><p class="wk-muted">Removed text is struck through, added text is underlined.</p>
++${raw(ssr.diffHtml(diff.content))}
++</section>`;
++}
++
++function citationRows(n) {
++ const rows = [];
++ for (let i = 0; i < n; i++) {
++ rows.push(html`<fieldset class="wk-cite"><legend>New source ${String(i + 1)}</legend>
++<label>URL <input type="url" name="cite_url_${String(i)}" placeholder="https://…"></label>
++<label>Title <input type="text" name="cite_title_${String(i)}" maxlength="500"></label>
++<label>Retrieved on <input type="date" name="cite_retrieved_${String(i)}"></label>
++<label>Quote (optional) <input type="text" name="cite_quote_${String(i)}" maxlength="1000"></label>
++<label>or an OpenVibe.Sources item id <input type="text" name="cite_item_${String(i)}" pattern="itm_[0-9A-Z]{26}" placeholder="itm_…"></label></fieldset>`);
++ }
++ return rows;
++}
++
++function editPage({ space, page = null, values, error = null, preview = null, citations = [], parents = [], baseRevision = 0, expectedRevision = null }) {
++ const isNew = !page;
++ const action = isNew ? `/s/${e(space.slug)}/new` : `${wpath(space, page)}/edit`;
++ const title = isNew ? `New page in ${space.name}` : `Editing ${page.title}`;
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, ...(page ? [{ name: page.title, url: wpath(space, page) }] : []), { name: isNew ? 'New page' : 'Edit' }])}
++<section><h1>${title}</h1>
++${error ? notice('error', error) : ''}
++${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (not saved)</h2><div class="wk-content">${raw(preview)}</div></section>` : ''}
++<form method="post" action="${action}" class="wk-form wk-edit">
++<input type="hidden" name="expected_revision" value="${String(expectedRevision == null ? baseRevision : expectedRevision)}">
++<input type="hidden" name="base_revision" value="${String(baseRevision)}">
++<label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>
++${isNew ? html`<label>Parent page <select name="parent_id"><option value="">(top level)</option>${parents.map((p) => html`<option value="${p.id}"${values.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}
++<label>Summary (one sentence, optional) <input type="text" name="summary" maxlength="300" value="${values.summary || ''}"></label>
++<label>Text (Markdown; link pages with [[Page title]] or [[space:Page title|label]]) <textarea name="body" rows="22">${values.body || ''}</textarea></label>
++<label>Infobox (one row per line: <code>Label | type | value</code>; types: text, number, date, url, boolean, page, media) <textarea name="infobox" rows="5">${values.infobox || ''}</textarea></label>
++${citations.length ? html`<fieldset><legend>Sources of revision ${String(baseRevision)} (untick to drop from the new revision; they stay on the old one)</legend>${citations.map((c) => html`<label class="wk-check"><input type="checkbox" name="keep_citation" value="${String(c.id)}"${values.keep && !values.keep.includes(String(c.id)) ? '' : raw(' checked')}> ${c.title || c.url || c.sourceItemId}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</label>`)}</fieldset>` : ''}
++${citationRows(2)}
++<label>Edit note <input type="text" name="message" maxlength="300" value="${values.message || ''}"></label>
++<p class="wk-buttons"><button type="submit" name="op" value="preview">Preview</button> <button type="submit" name="op" value="save">Save revision</button> <button type="submit" name="op" value="publish">Save and publish</button></p>
++</form></section>`;
++}
++
++function formValuesFromRevision(rev, page) {
++ return { title: rev ? rev.fields.title : (page ? page.title : ''), summary: rev ? rev.fields.summary || '' : '', body: rev ? rev.content : '', infobox: rev ? infoboxToText(rev.fields.infobox || []) : '' };
++}
++
++function newSpacePage({ values = {}, error = null, staff = false }) {
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: 'New space' }])}
++<section><h1>Start a space</h1>${error ? notice('error', error) : ''}
++<form method="post" action="/new-space" class="wk-form">
++<label>Name <input type="text" name="name" required maxlength="120" value="${values.name || ''}"></label>
++<label>Address (optional; lowercase letters, digits and dashes) <input type="text" name="slug" maxlength="63" pattern="[a-z0-9][a-z0-9-]{1,62}" value="${values.slug || ''}"></label>
++<label>Description <input type="text" name="description" maxlength="1000" value="${values.description || ''}"></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
++${staff ? html`<label class="wk-check"><input type="checkbox" name="official" value="1"> Official (editorial) space</label>` : ''}
++<p><button type="submit">Create space</button></p></form></section>`;
++}
++
++function spaceSettingsPage({ space, roles, error = null, flash = null }) {
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: 'Settings' }])}
++<section><h1>Settings of ${space.name}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
++<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form">
++<label>Name <input type="text" name="name" maxlength="120" value="${space.name}"></label>
++<label>Description <input type="text" name="description" maxlength="1000" value="${space.description || ''}"></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
++<p class="wk-muted">Making a space members-only or private removes its pages from sitemaps, feeds and OpenVibe.Search.</p>
++<p><button type="submit" name="op" value="settings">Save settings</button></p></form>
++<h2>Roles</h2>
++<table><thead><tr><th scope="col">Person</th><th scope="col">Role</th><th scope="col">Since</th><th scope="col"></th></tr></thead><tbody>
++${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td><td>${t(r.granted_at)}</td><td><form method="post" action="/s/${e(space.slug)}/settings" class="wk-inline"><input type="hidden" name="op" value="role"><input type="hidden" name="subject" value="${r.subject}"><input type="hidden" name="role" value=""><button type="submit">Remove</button></form></td></tr>`)}
++</tbody></table>
++<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="role">
++<label>Person (usr_… subject id) <input type="text" name="subject" required pattern="usr_[0-9A-Z]{26}"></label>
++<label>Role <select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="owner">owner</option></select></label>
++<p><button type="submit">Grant role</button></p></form>
++<h2>Delete</h2><form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this space and take every page offline (addresses answer 410 Gone)</label><p><button type="submit">Delete space</button></p></form>
++</section>`;
++}
++
++function pageSettingsPage({ space, page, v, parents, error = null, flash = null, canManage }) {
++ const base = wpath(space, page);
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'Settings' }])}
++<section><h1>Settings of ${page.title}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
++<p>State: <strong>${page.state}</strong>${page.published_revision ? html` · published revision ${String(page.published_revision)}` : ''} · visibility ${page.visibility} (space: ${space.visibility})</p>
++<h2>Publish</h2>
++<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="publish">
++<label>Revision <input type="number" name="revision" min="1" value="${String(v.headNumber)}"></label>
++<p><button type="submit">Publish now</button></p></form>
++<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="schedule">
++<label>Revision <input type="number" name="revision" min="1" value="${String(v.headNumber)}"></label>
++<label>Publish at (UTC) <input type="datetime-local" name="run_at" required></label>
++<p><button type="submit">Schedule</button></p></form>
++${v.jobs.length ? html`<ul>${v.jobs.map((j) => html`<li>${j.action} revision ${String(j.revision)} at ${t(j.runAt)} (${j.status})</li>`)}</ul>` : ''}
++${page.state === 'published' ? html`<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="unpublish"><p><button type="submit">Unpublish</button></p></form>` : ''}
++<h2>Move or rename</h2>
++<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="move">
++<label>Address <input type="text" name="slug" value="${page.slug}" required></label>
++<label>Parent <select name="parent_id"><option value="">(top level)</option>${parents.filter((p) => p.id !== page.id).map((p) => html`<option value="${p.id}"${page.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>
++<p class="wk-muted">The old address keeps working as a permanent redirect.</p>
++<p><button type="submit">Move</button></p></form>
++<h2>Media attachments</h2>
++${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}
++<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="attach">
++<label>OpenVibe.Media object id <input type="text" name="media_id" required placeholder="med_…"></label>
++<label>Alt text <input type="text" name="alt" maxlength="1000"></label>
++<label>Caption <input type="text" name="caption" maxlength="2000"></label>
++<p><button type="submit">Attach</button> <button type="submit" name="op" value="verify" formnovalidate>Check attachments against Media</button></p></form>
++${canManage ? html`<h2>Visibility</h2><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="visibility">
++<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((x) => html`<option value="${x}"${page.visibility === x ? raw(' selected') : ''}>${VIS_LABEL[x]}</option>`)}</select></label>
++<label class="wk-check"><input type="checkbox" name="noindex" value="1"${page.noindex ? raw(' checked') : ''}> Ask search engines not to index this page</label>
++<p><button type="submit">Save</button></p></form>
++<h2>Delete</h2><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this page (its address answers 410 Gone; history is kept)</label><p><button type="submit">Delete page</button></p></form>` : ''}
++</section>`;
++}
++
++function revertPage({ space, page, to, head }) {
++ const base = wpath(space, page);
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'Revert' }])}
++<section><h1>Revert ${page.title} to revision ${String(to)}</h1>
++<p>This adds revision ${String(head + 1)} with the text, infobox and sources of revision ${String(to)}. Nothing is deleted; <a href="${base}/diff/${String(head)}/${String(to)}">see what changes</a>.</p>
++<form method="post" action="${base}/revert" class="wk-form"><input type="hidden" name="to" value="${String(to)}"><input type="hidden" name="expected_revision" value="${String(head)}">
++<label>Note <input type="text" name="message" maxlength="300"></label>
++${page.state === 'published' ? html`<p class="wk-muted">The page is published, so the revert is published at once.</p>` : ''}
++<p><button type="submit">Revert</button></p></form></section>`;
++}
++
++function confirmPublishPage({ space, page, rev }) {
++ const base = wpath(space, page);
++ return html`<section><h1>Publish revision ${String(rev)} of ${page.title}?</h1><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="publish"><input type="hidden" name="revision" value="${String(rev)}"><p><button type="submit">Publish</button> <a href="${base}/history">Cancel</a></p></form></section>`;
++}
++
++function proposalsPage({ space, items }) {
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: 'AI proposals' }])}
++<section><h1>AI proposals in ${space.name}</h1>
++<p class="wk-muted">AI workflows propose revisions; nothing they write is published until a person approves it here.</p>
++${items.length ? html`<ul class="wk-proposals">${items.map(({ proposal: p, page }) => html`<li><a href="${wpath(space, page)}?rev=${String(p.revision)}">${page.title}, revision ${String(p.revision)}</a> by workflow <code>${p.workflow_id}</code> (run <code>${p.run_id}</code>)${p.stub_provider ? ' · stub provider' : ''} ${t(p.created_at)}
++${p.base_revision ? html` · <a href="${wpath(space, page)}/diff/${String(p.base_revision)}/${String(p.revision)}">diff against revision ${String(p.base_revision)}</a>` : ''}
++${p.note ? html`<br><span class="wk-muted">${p.note}</span>` : ''}
++<form method="post" action="/proposals/${p.id}" class="wk-inline"><input type="hidden" name="decision" value="approved"><button type="submit">Approve and publish</button></form>
++<form method="post" action="/proposals/${p.id}" class="wk-inline"><input type="hidden" name="decision" value="rejected"><button type="submit">Reject</button></form></li>`)}</ul>` : html`<p>No proposals wait for review.</p>`}
++</section>`;
++}
++
++function searchPage({ query, results }) {
++ return html`<section><h1>Search</h1>
++<form action="/search" method="get" class="wk-form" role="search"><label>Words <input type="search" name="q" value="${query || ''}"></label><button type="submit">Search</button></form>
++${query ? (results.length ? html`<ol class="wk-results">${results.map((r) => html`<li><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">${r.space.name}</span><br>${r.summary}</li>`)}</ol>` : html`<p>No published page matches.</p>`) : ''}
++</section>`;
++}
++
++function recentPage({ items }) {
++ return html`<section><h1>Recent changes</h1>
++<p>Public pages by the time their current revision was published. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p>
++${items.length ? html`<ol class="wk-recent">${items.map((r) => html`<li><a href="${wpath(r.space, r.page)}">${r.page.title}</a> revision ${String(r.page.published_revision)} · ${r.space.name} · ${t(r.page.revision_published_at)}</li>`)}</ol>` : html`<p class="wk-muted">Nothing published yet.</p>`}
++</section>`;
++}
++
++function signInPage({ next, message }) {
++ return html`<section><h1>Sign in</h1><p>${message}</p><p><a class="wk-button" href="/auth/login?next=${e(next || '/')}">Sign in with OpenVibe</a></p></section>`;
++}
++
++module.exports = {
++ home, spacePage, articlePage, historyPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
++ spaceSettingsPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
++ signInPage, errorBody, wpath, VIS_LABEL,
++};
ddiff --git a/server/wiki/access.js b/server/wiki/access.js
nnew file mode 100644
iindex 0000000..177b54a
---- /dev/null
++++ b/server/wiki/access.js
@@@ -0,0 +1,86 @@
++'use strict';
++/**
++ * Who may do what in a space. One place, used by the pages, the API and the event fan-out.
++ *
++ * Actors (server/auth/viewer.js):
++ * { kind: 'anonymous' }
++ * { kind: 'user', subject: 'usr_…'|null, staff } Network SSO (browser or Bearer)
++ * { kind: 'service', service: 'svc:x', claims, subject } service token; acts for X-OV-Subject if given
++ * { kind: 'system', service: 'svc:wiki' } this service's own workers and seed
++ *
++ * Space visibility: public (anyone), members (any signed-in OpenVibe account), private (only
++ * subjects holding a role in the space). A page's own visibility can only narrow its space's.
++ * Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,
++ * review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as
++ * owner of official spaces; they get no silent access to user spaces.
++ * A service acting for a person gets that person's rights (and must hold the route's capability);
++ * a service acting as itself reads public content only and writes nothing that needs a person.
++ */
++const RANK = { public: 0, members: 1, private: 2 };
++const VISIBILITIES = ['public', 'members', 'private'];
++
++function strictest(a, b) { return RANK[a] >= RANK[b] ? a : b; }
++
++function effectiveVisibility(space, page) {
++ return page ? strictest(space.visibility, page.visibility) : space.visibility;
++}
++
++function createAccess(db) {
++ const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');
++
++ /** 'owner' | 'editor' | 'viewer' | null */
++ function role(space, actor) {
++ if (!actor || !space) return null;
++ if (actor.kind === 'system') return 'owner';
++ const subject = actor.subject || null;
++ if (actor.kind === 'user' && actor.staff && space.kind === 'official') return 'owner';
++ if (!subject) return null;
++ if (space.owner === subject) return 'owner';
++ const r = roleOf.get(space.id, subject);
++ return r ? r.role : null;
++ }
++
++ function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
++
++ function canReadVisibility(space, visibility, actor) {
++ if (!actor) return visibility === 'public';
++ if (actor.kind === 'system') return true;
++ if (visibility === 'public') return true;
++ if (visibility === 'members') return !!actor.subject || role(space, actor) != null;
++ return role(space, actor) != null;
++ }
++
++ const api = {
++ VISIBILITIES,
++ role,
++ isPerson,
++ effectiveVisibility,
++ canReadSpace(space, actor) {
++ if (!space || space.deleted_at) return false;
++ return canReadVisibility(space, space.visibility, actor);
++ },
++ /** Published content of a page (drafts and history of unpublished pages need canEdit). */
++ canReadPage(space, page, actor) {
++ if (!space || space.deleted_at || !page || page.state === 'deleted') return false;
++ if (!canReadVisibility(space, effectiveVisibility(space, page), actor)) return false;
++ if (page.state === 'published') return true;
++ return api.canEdit(space, actor);
++ },
++ canEdit(space, actor) {
++ if (!space || space.deleted_at) return false;
++ const r = role(space, actor);
++ return r === 'owner' || r === 'editor';
++ },
++ canManage(space, actor) {
++ if (!space || space.deleted_at) return false;
++ return role(space, actor) === 'owner';
++ },
++ /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */
++ subjectCanRead(space, page, subject) {
++ return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
++ },
++ };
++ return api;
++}
++
++module.exports = { createAccess, effectiveVisibility, strictest, VISIBILITIES, RANK };
ddiff --git a/server/wiki/content.js b/server/wiki/content.js
nnew file mode 100644
iindex 0000000..b1ddea9
---- /dev/null
++++ b/server/wiki/content.js
@@@ -0,0 +1,198 @@
++'use strict';
++/**
++ * Page content: [[internal links]], typed infobox values, and rendering to safe HTML.
++ *
++ * Link syntax (outside code):
++ * [[Title]] a page in the same space, slug = slugify(Title)
++ * [[Title|label]] with its own label
++ * [[space-slug:Title]] a page in another space (the prefix must look like a space slug:
++ * lowercase letters, digits and dashes; anything else is part of the title)
++ * Markdown itself goes through openvibe-publishing/ssr (escaped first, a fixed tag set, no raw HTML).
++ */
++const ssr = require('openvibe-publishing/ssr');
++const { slugify } = require('openvibe-publishing/taxonomy');
++
++const LINK_RE = /\[\[([^\[\]|\n]{1,200})(?:\|([^\[\]\n]{1,200}))?\]\]/g;
++const SPACE_PREFIX_RE = /^([a-z0-9][a-z0-9-]{0,62}):(.+)$/;
++const SPACE_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
++const PAGE_SLUG_RE = /^[\p{Ll}\p{Lo}\p{N}][\p{Ll}\p{Lo}\p{N}-]{0,79}$/u;
++const MAX_LINKS = 500;
++
++class ContentError extends Error {
++ constructor(code, message) { super(message); this.status = 422; this.code = code; }
++}
++
++function pageSlug(title) {
++ try { return slugify(String(title || '').trim()); } catch { throw new ContentError('page.invalid_title', 'A title needs at least one letter or digit'); }
++}
++
++function parseTarget(raw, currentSpace) {
++ const text = String(raw).trim();
++ const m = text.match(SPACE_PREFIX_RE);
++ let space = currentSpace;
++ let title = text;
++ if (m && m[1] !== 'media') { space = m[1]; title = m[2].trim(); }
++ let slug;
++ try { slug = slugify(title); } catch { return null; }
++ return { space, slug, title };
++}
++
++/** Run fn over the parts of `source` that are not code (fences and inline code are left alone). */
++function mapOutsideCode(source, fn) {
++ const lines = String(source == null ? '' : source).split('\n');
++ let fence = null;
++ return lines.map((line) => {
++ const f = line.match(/^ {0,3}(`{3,}|~{3,})/);
++ if (fence) { if (f && f[1][0] === fence[0] && f[1].length >= fence.length) fence = null; return line; }
++ if (f) { fence = f[1]; return line; }
++ return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : fn(part))).join('');
++ }).join('\n');
++}
++
++/** Every distinct [[link]] in the content: [{ space, slug, title, label }]. */
++function extractLinks(source, currentSpace) {
++ const seen = new Map();
++ mapOutsideCode(source, (text) => {
++ text.replace(LINK_RE, (_m, target, label) => {
++ const t = parseTarget(target, currentSpace);
++ if (t && seen.size < MAX_LINKS) {
++ const key = `${t.space}\u0000${t.slug}`;
++ if (!seen.has(key)) seen.set(key, { ...t, label: label ? label.trim() : null });
++ }
++ return '';
++ });
++ return text;
++ });
++ return [...seen.values()];
++}
++
++/**
++ * Markdown + [[links]] → HTML. resolve({ space, slug }) → { href, exists } (exists false = red link;
++ * href null = no link at all, e.g. a reader who cannot create the page).
++ */
++function renderContent(source, { currentSpace, resolve, rel = 'noopener' } = {}) {
++ const held = [];
++ const withTokens = mapOutsideCode(source, (text) => text.replace(LINK_RE, (m, target, label) => {
++ const t = parseTarget(target, currentSpace);
++ if (!t) return m;
++ const r = resolve(t) || { href: null, exists: false };
++ const text2 = (label || t.title).trim();
++ let htmlLink;
++ if (r.exists) htmlLink = `<a class="ov-wikilink" href="${ssr.escapeHtml(r.href)}">${ssr.escapeHtml(text2)}</a>`;
++ else if (r.href) htmlLink = `<a class="ov-wikilink ov-redlink" href="${ssr.escapeHtml(r.href)}" rel="nofollow" title="${ssr.escapeHtml(`${t.title} (page does not exist)`)}">${ssr.escapeHtml(text2)}</a>`;
++ else htmlLink = `<span class="ov-wikilink ov-redlink" title="${ssr.escapeHtml(`${t.title} (page does not exist)`)}">${ssr.escapeHtml(text2)}</span>`;
++ held.push(htmlLink);
++ return `\u0001WL${held.length - 1}\u0001`;
++ }));
++ const html = ssr.renderMarkdown(withTokens, { rel, headingShift: 0 });
++ return html.replace(/\u0001WL(\d+)\u0001/g, (_m, n) => held[Number(n)] || '');
++}
++
++// ---- Infobox -----------------------------------------------------------------------------------
++
++const TYPES = ['text', 'number', 'date', 'url', 'boolean', 'page', 'media'];
++const KEY_RE = /^[a-z][a-z0-9_]{0,39}$/;
++const MEDIA_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 MAX_ENTRIES = 30;
++
++function keyFromLabel(label) {
++ return String(label).normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
++ .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').replace(/^(\d)/, 'n$1').slice(0, 40);
++}
++
++/**
++ * Validate and normalise infobox entries [{ key?, label, type, value }] → typed entries.
++ * Nothing is coerced silently: a value that does not fit its type is an error.
++ */
++function normalizeInfobox(entries) {
++ if (entries == null) return [];
++ if (!Array.isArray(entries)) throw new ContentError('infobox.invalid', 'infobox must be a list');
++ if (entries.length > MAX_ENTRIES) throw new ContentError('infobox.too_many', `at most ${MAX_ENTRIES} infobox rows`);
++ const out = [];
++ const keys = new Set();
++ for (const [i, e] of entries.entries()) {
++ const where = `infobox row ${i + 1}`;
++ if (!e || typeof e !== 'object') throw new ContentError('infobox.invalid', `${where} is not an object`);
++ const label = String(e.label == null ? '' : e.label).trim().slice(0, 80);
++ const key = e.key == null || e.key === '' ? keyFromLabel(label) : String(e.key);
++ if (!KEY_RE.test(key)) throw new ContentError('infobox.invalid_key', `${where}: key must match ${KEY_RE}`);
++ if (keys.has(key)) throw new ContentError('infobox.duplicate_key', `${where}: key "${key}" appears twice`);
++ keys.add(key);
++ const type = String(e.type || 'text');
++ if (!TYPES.includes(type)) throw new ContentError('infobox.invalid_type', `${where}: type must be one of ${TYPES.join(', ')}`);
++ const v = e.value;
++ let value;
++ switch (type) {
++ case 'number': {
++ const n = typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN);
++ if (!Number.isFinite(n)) throw new ContentError('infobox.invalid_value', `${where}: "${v}" is not a number`);
++ value = n;
++ break;
++ }
++ case 'boolean': {
++ if (v === true || v === 'true' || v === 'yes') value = true;
++ else if (v === false || v === 'false' || v === 'no') value = false;
++ else throw new ContentError('infobox.invalid_value', `${where}: a boolean is true/false or yes/no`);
++ break;
++ }
++ case 'date': {
++ const s = String(v == null ? '' : v).trim();
++ if (!/^\d{4}-\d{2}-\d{2}([T ][\d:.]+(Z|[+-]\d{2}:?\d{2})?)?$/.test(s) || Number.isNaN(Date.parse(s))) {
++ throw new ContentError('infobox.invalid_value', `${where}: a date is YYYY-MM-DD or an ISO 8601 instant`);
++ }
++ value = s;
++ break;
++ }
++ case 'url': {
++ let u;
++ try { u = new URL(String(v)); } catch { throw new ContentError('infobox.invalid_value', `${where}: not an absolute URL`); }
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new ContentError('infobox.invalid_value', `${where}: only http(s) URLs`);
++ value = u.toString();
++ break;
++ }
++ case 'media': {
++ const s = String(v == null ? '' : v).trim();
++ if (!MEDIA_RE.test(s)) throw new ContentError('infobox.invalid_value', `${where}: a Media object id (med_…)`);
++ value = s;
++ break;
++ }
++ case 'page': {
++ const s = String(v == null ? '' : v).trim();
++ if (!s || s.length > 200 || /[\[\]|\n]/.test(s)) throw new ContentError('infobox.invalid_value', `${where}: a page title`);
++ value = s;
++ break;
++ }
++ default: {
++ const s = String(v == null ? '' : v).trim();
++ if (!s) throw new ContentError('infobox.invalid_value', `${where}: empty value`);
++ value = s.slice(0, 500);
++ }
++ }
++ out.push({ key, label: label || key, type, value });
++ }
++ return out;
++}
++
++/** The no-JS form's textarea: one row per line, "Label | type | value" (type optional → text). */
++function parseInfoboxText(text) {
++ const rows = [];
++ for (const [i, raw] of String(text || '').split(/\r?\n/).entries()) {
++ const line = raw.trim();
++ if (!line) continue;
++ const parts = line.split('|').map((s) => s.trim());
++ if (parts.length === 2) rows.push({ label: parts[0], type: 'text', value: parts[1] });
++ else if (parts.length >= 3) rows.push({ label: parts[0], type: parts[1].toLowerCase(), value: parts.slice(2).join('|').trim() });
++ else throw new ContentError('infobox.invalid', `infobox line ${i + 1}: write "Label | type | value"`);
++ }
++ return normalizeInfobox(rows);
++}
++
++function infoboxToText(entries) {
++ return (entries || []).map((e) => `${e.label} | ${e.type} | ${e.value}`).join('\n');
++}
++
++module.exports = {
++ ContentError, pageSlug, parseTarget, extractLinks, renderContent, mapOutsideCode,
++ normalizeInfobox, parseInfoboxText, infoboxToText, keyFromLabel,
++ TYPES, SPACE_SLUG_RE, PAGE_SLUG_RE, MEDIA_RE,
++};
ddiff --git a/server/wiki/seed.js b/server/wiki/seed.js
nnew file mode 100644
iindex 0000000..766e9e2
---- /dev/null
++++ b/server/wiki/seed.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * Seed import: an official space and its pages from a JSON file (seeds/openvibe.json).
++ *
++ * Idempotent: an existing space is reused and a page whose slug exists is left alone (never
++ * overwritten), so running it twice changes nothing. Authorship is recorded as `imported` with the
++ * truth about where the text comes from: summaries of the cited repository READMEs/STATUS files,
++ * written with AI assistance. Every page carries URL citations (permalinks at the commit read, with
++ * the retrieval time) on the revision that uses them.
++ */
++const { pageSlug } = require('./content');
++
++const SYSTEM = Object.freeze({ kind: 'system', service: 'svc:wiki', subject: null });
++const IMPORTED = {
++ mode: 'imported',
++ importedFrom: {
++ label: 'the OpenVibe repository README and STATUS files at the cited commits, summarised with AI assistance',
++ originalAuthor: 'OpenVibers',
++ },
++};
++
++function seed(svc, data, { publish = true, log = console } = {}) {
++ if (!data || !data.space || !Array.isArray(data.pages)) throw new TypeError('seed data needs { space, pages }');
++ let space = svc.findSpace(data.space.slug);
++ if (!space) {
++ space = svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
++ log.log(`[seed] created space ${space.slug}`);
++ }
++ const bySlug = new Map();
++ const created = [];
++ const skipped = [];
++ // Parents first: a page whose parent is not created yet waits for the next pass.
++ let pending = [...data.pages];
++ for (let pass = 0; pending.length && pass < 10; pass++) {
++ const next = [];
++ for (const p of pending) {
++ const slug = pageSlug(p.title);
++ const existing = svc.findPage(space.id, slug);
++ if (existing) { bySlug.set(slug, existing); skipped.push(p.title); continue; }
++ let parentId = null;
++ if (p.parent) {
++ const parent = bySlug.get(pageSlug(p.parent)) || svc.findPage(space.id, pageSlug(p.parent));
++ if (!parent) { next.push(p); continue; }
++ parentId = parent.id;
++ }
++ const out = svc.createPage(space.id, {
++ title: p.title, body: p.body, summary: p.summary || null, infobox: p.infobox || [], parentId,
++ citations: (p.citations || []).map((c) => ({ url: c.url, title: c.title || null, retrievedAt: c.retrievedAt, quote: c.quote ? { text: c.quote } : null, licenseNote: c.licenseNote || null })),
++ message: 'Seed import', authorship: IMPORTED,
++ }, SYSTEM);
++ if (publish) svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);
++ bySlug.set(slug, svc.pageById(out.page.id));
++ created.push(p.title);
++ }
++ pending = next;
++ }
++ if (pending.length) throw new Error(`seed: parents not found for ${pending.map((p) => p.title).join(', ')}`);
++ return { space: svc.findSpace(data.space.slug), created, skipped };
++}
++
++module.exports = { seed, SYSTEM, IMPORTED };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
nnew file mode 100644
iindex 0000000..a177bab
---- /dev/null
++++ b/server/wiki/service.js
@@@ -0,0 +1,932 @@
++'use strict';
++/**
++ * The wiki domain: spaces, page trees, revisions, publication, links, infoboxes, citations,
++ * attachments, watchers and AI proposals. Wiki owns its publication state; the Publishing
++ * packages supply the mechanics (revisions, citations, the gate, redirects, index hooks).
++ *
++ * Every write runs in one SQLite transaction together with the events it causes (transactional
++ * outbox): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
++ * wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.
++ *
++ * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).
++ */
++const seo = require('openvibe-publishing/seo');
++const hooks = require('openvibe-publishing/index-hooks');
++const authorship = require('openvibe-publishing/authorship');
++const ssr = require('openvibe-publishing/ssr');
++const { ulid } = require('openvibe-contracts').ids;
++const content = require('./content');
++const { createAccess, VISIBILITIES } = require('./access');
++
++class WikiError extends Error {
++ constructor(status, code, message, extra) { super(message); this.status = status; this.code = code; if (extra) this.extra = extra; }
++}
++
++const SPACE_KINDS = ['official', 'user'];
++const MAX_BODY = 200000;
++const MAX_TITLE = 200;
++const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };
++const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function pageIdNew(t) { return `pg_${ulid(t)}`; }
++function spaceIdNew(t) { return `spc_${ulid(t)}`; }
++function proposalIdNew(t) { return `prp_${ulid(t)}`; }
++
++function toIso(ms) { return ms == null ? null : new Date(ms).toISOString(); }
++
++function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
++ const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
++ const access = createAccess(db);
++ const origin = config.baseUrl;
++ const policy = config.gate;
++
++ // ── Queries ───────────────────────────────────────────────
++ const q = {
++ spaceById: db.prepare('SELECT * FROM wiki_spaces WHERE id = ?'),
++ spaceBySlug: db.prepare('SELECT * FROM wiki_spaces WHERE slug = ?'),
++ spaces: db.prepare('SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY kind = \'official\' DESC, name COLLATE NOCASE'),
++ insertSpace: db.prepare(`INSERT INTO wiki_spaces (id, slug, name, description, kind, visibility, owner, created_by, created_at, updated_at)
++ VALUES (@id, @slug, @name, @description, @kind, @visibility, @owner, @created_by, @now, @now)`),
++ pageById: db.prepare('SELECT * FROM wiki_pages WHERE id = ?'),
++ pageBySlug: db.prepare('SELECT * FROM wiki_pages WHERE space_id = ? AND slug = ?'),
++ pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
++ publishedOfSpace: db.prepare("SELECT id FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),
++ insertPage: db.prepare(`INSERT INTO wiki_pages (id, space_id, slug, title, parent_id, position, state, visibility, created_by, created_at, updated_at)
++ VALUES (@id, @space_id, @slug, @title, @parent_id, @position, 'draft', @visibility, @created_by, @now, @now)`),
++ children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
++ insertLink: db.prepare('INSERT OR IGNORE INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?)'),
++ linksOf: db.prepare('SELECT * FROM wiki_page_links WHERE from_page_id = ? AND from_revision = ? ORDER BY target_space, target_slug'),
++ backlinks: db.prepare(`SELECT DISTINCT p.* FROM wiki_page_links l JOIN wiki_pages p ON p.id = l.from_page_id AND l.from_revision = p.published_revision
++ WHERE l.target_space = ? AND l.target_slug = ? AND p.state = 'published' ORDER BY p.title COLLATE NOCASE LIMIT 200`),
++ insertInfobox: db.prepare(`INSERT INTO wiki_infobox_values (page_id, revision, position, key, label, type, value_text, value_number)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
++ infoboxOf: db.prepare('SELECT * FROM wiki_infobox_values WHERE page_id = ? AND revision = ? ORDER BY position'),
++ roles: db.prepare('SELECT * FROM wiki_permissions WHERE space_id = ? ORDER BY role, subject'),
++ putRole: db.prepare(`INSERT INTO wiki_permissions (space_id, subject, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
++ delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
++ owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
++ watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
++ watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
++ unwatch: db.prepare('DELETE FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
++ isWatching: db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
++ proposal: db.prepare('SELECT * FROM wiki_ai_proposals WHERE id = ?'),
++ proposalByRev: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? AND revision = ?'),
++ proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC'),
++ pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC LIMIT 200"),
++ insertProposal: db.prepare(`INSERT INTO wiki_ai_proposals (id, page_id, space_id, revision, base_revision, workflow_id, run_id, stub_provider, status, proposed_by, note, created_at)
++ VALUES (@id, @page_id, @space_id, @revision, @base_revision, @workflow_id, @run_id, @stub_provider, 'pending', @proposed_by, @note, @now)`),
++ reviewProposal: db.prepare("UPDATE wiki_ai_proposals SET status = @status, reviewed_by = @by, review_note = @note, reviewed_at = @now WHERE id = @id AND status = 'pending'"),
++ recent: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
++ ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
++ allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
++ ORDER BY p.id`),
++ search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
++ WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
++ AND (p.title LIKE @like ESCAPE '\\' OR r.content LIKE @like ESCAPE '\\')
++ ORDER BY (p.title LIKE @like ESCAPE '\\') DESC, p.revision_published_at DESC LIMIT 200`),
++ };
++
++ // ── Paths and small helpers ───────────────────────────────
++ const spacePath = (space) => `/s/${encodeURIComponent(space.slug)}`;
++ const pagePath = (space, page) => `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(page.slug)}`;
++ const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
++ const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
++ const tx = (fn) => db.transaction(fn)();
++
++ function actorId(actor) {
++ if (!actor) return 'svc:wiki';
++ if (actor.subject) return actor.subject;
++ if (actor.service) return actor.service;
++ return 'svc:wiki';
++ }
++
++ function requirePerson(actor) {
++ if (!access.isPerson(actor)) fail(403, 'wiki.person_required', 'This action is taken by a signed-in person (a service must name one in X-OV-Subject)');
++ return actor.subject;
++ }
++
++ function spaceOrFail(idOrSlug) {
++ const s = /^spc_/.test(String(idOrSlug)) ? q.spaceById.get(idOrSlug) : q.spaceBySlug.get(String(idOrSlug));
++ if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');
++ return s;
++ }
++
++ function pageOrFail(id) {
++ const p = PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) : null;
++ if (!p) fail(404, 'page.not_found', 'No such page');
++ return p;
++ }
++
++ function checkTitle(title) {
++ const t = String(title == null ? '' : title).replace(/\s+/g, ' ').trim();
++ if (!t) fail(422, 'page.invalid_title', 'A page needs a title');
++ if (t.length > MAX_TITLE) fail(422, 'page.invalid_title', `A title is at most ${MAX_TITLE} characters`);
++ return t;
++ }
++
++ function checkBody(body) {
++ if (typeof body !== 'string') fail(422, 'page.invalid_body', 'body must be Markdown text');
++ if (body.length > MAX_BODY) fail(413, 'page.body_too_large', `A page is at most ${MAX_BODY} characters`);
++ return body.replace(/\r\n?/g, '\n');
++ }
++
++ function checkVisibility(v, fallback) {
++ const out = v == null || v === '' ? fallback : String(v);
++ if (!VISIBILITIES.includes(out)) fail(422, 'wiki.invalid_visibility', `visibility is one of ${VISIBILITIES.join(', ')}`);
++ return out;
++ }
++
++ function wrapContentError(fn) {
++ try { return fn(); } catch (err) {
++ if (err instanceof content.ContentError) fail(422, err.code, err.message);
++ if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);
++ if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);
++ throw err;
++ }
++ }
++
++ // ── Events ───────────────────────────────────────────────
++ function emit(envelope) { return outbox.enqueue(envelope); }
++
++ function spaceEvent(space, actor, change) {
++ emit({
++ event_type: 'wiki.space.updated',
++ actor: hooks.subjectRef(actorId(actor)),
++ subject: { type: 'space', id: space.id },
++ visibility: space.visibility === 'public' && !space.deleted_at ? 'public' : 'internal',
++ payload: {
++ slug: space.slug, name: space.name, kind: space.kind, visibility: space.visibility,
++ deleted: !!space.deleted_at, change, url: seo.canonicalUrl(origin, spacePath(space)),
++ },
++ });
++ }
++
++ function revisionEvent(space, page, rev, actor, extra = {}) {
++ const rec = rev.meta && rev.meta.authorship;
++ // A new revision is a draft until it is published: the event is internal.
++ emit({
++ event_type: 'wiki.revision.created',
++ actor: hooks.subjectRef(actorId(actor)),
++ subject: { type: 'page', id: page.id, revision: rev.number },
++ visibility: 'internal',
++ payload: {
++ space_id: space.id, space: space.slug, slug: page.slug, number: rev.number, kind: rev.kind,
++ parent_number: rev.parentNumber, reverted_to: rev.revertedTo, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null,
++ author: rev.author, message: rev.message, ...extra,
++ },
++ });
++ }
++
++ /** One event per change listing the watchers who may read the page (never the actor). */
++ function notifyWatchers(space, page, action, revision, actor) {
++ const who = actorId(actor);
++ const recipients = q.watchers.all(page.id).map((r) => r.subject)
++ .filter((s) => s !== who && access.subjectCanRead(space, page.state === 'deleted' ? { ...page, state: 'published' } : page, s));
++ if (!recipients.length) return;
++ emit({
++ event_type: 'wiki.watch.triggered',
++ actor: hooks.subjectRef(who),
++ subject: { type: 'page', id: page.id, revision: revision || 0 },
++ visibility: 'internal',
++ payload: { action, space: space.slug, slug: page.slug, title: page.title, revision: revision || null, url: pageUrl(space, page), recipients },
++ });
++ }
++
++ // ── The gate, index documents and publication events ─────
++ function gateFacts(space, page, rev) {
++ const text = ssr.markdownToText(rev.content);
++ const rec = rev.meta && rev.meta.authorship;
++ return {
++ state: space.deleted_at ? 'deleted' : page.state,
++ visibility: GATE_VIS[access.effectiveVisibility(space, page)],
++ canonicalUrl: pageUrl(space, page),
++ text,
++ citationCount: citations.forRevision(page.id, rev.number).length,
++ noindex: !!page.noindex,
++ ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
++ };
++ }
++
++ function decide(space, page, rev) {
++ return seo.evaluate(gateFacts(space, page, rev), { policy, now: now() });
++ }
++
++ function snapshot(page) {
++ if (!page) return null;
++ const space = q.spaceById.get(page.space_id);
++ return { state: space.deleted_at ? 'deleted' : page.state, visibility: access.effectiveVisibility(space, page), revision: page.published_revision };
++ }
++
++ /**
++ * After any change to a page: send Search the current document or a tombstone when it differs
++ * from the last one sent (members/private pages are always tombstones: never in search), and the
++ * product event when the publication state moved. Both go to the outbox in the caller's transaction.
++ */
++ function sync(before, pageId, actor) {
++ const page = q.pageById.get(pageId);
++ const space = q.spaceById.get(page.space_id);
++ const eff = access.effectiveVisibility(space, page);
++ const state = space.deleted_at ? 'deleted' : page.state;
++ const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
++ const decision = rev && state === 'published' ? decide(space, page, rev) : null;
++ const cites = rev ? citations.forRevision(page.id, rev.number) : [];
++ const doc = sequencer.stamp(hooks.buildIndexDocument({
++ owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],
++ deleted: eff !== 'public' || !decision,
++ canonicalUrl: pageUrl(space, page), title: rev ? rev.fields.title : page.title,
++ summary: rev ? (rev.fields.summary || ssr.markdownToText(rev.content, 300)) : null,
++ body: rev ? ssr.markdownToText(rev.content) : '',
++ facets: { space: space.slug, space_kind: space.kind },
++ authorship: rev && rev.meta.authorship, citations: cites, decision,
++ publishedAt: page.published_at, updatedAt: page.revision_published_at,
++ }));
++ const sentBefore = before ? before.indexRevision : null;
++ if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
++
++ const after = { state, visibility: eff, revision: page.published_revision };
++ const action = hooks.actionFor(before && before.state ? before : null, after);
++ if (action) {
++ let evDoc = doc;
++ if (doc.deleted && (action === 'published' || action === 'updated')) {
++ // A members/private page is published but never indexed: the product event carries
++ // its state, not a Search document.
++ evDoc = { owner: 'wiki', type: 'page', id: page.id, revision: doc.revision, deleted: false, visibility: GATE_VIS[eff], canonical_url: pageUrl(space, page), publication_state: 'published', indexability: decision ? hooks.searchIndexability(decision) : null };
++ }
++ emit(hooks.publicationEvent({
++ product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0,
++ actor: hooks.subjectRef(actorId(actor)), document: evDoc, decision, now: now(),
++ extra: { space: space.slug, slug: page.slug, visibility: eff },
++ }));
++ notifyWatchers(space, page, action, page.published_revision, actor);
++ }
++ return { action, indexRevision: doc.revision, deleted: doc.deleted };
++ }
++
++ function before(page) {
++ const s = snapshot(page);
++ return s ? { ...s, indexRevision: sequencer.current('wiki', 'page', page.id) } : null;
++ }
++
++ // ── Side tables written with each revision ───────────────
++ function writeRevisionExtras(space, page, rev, infobox) {
++ for (const l of content.extractLinks(rev.content, space.slug)) q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);
++ infobox.forEach((e, i) => q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type,
++ e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null));
++ }
++
++ function infoboxOf(pageId, revision) {
++ return q.infoboxOf.all(pageId, revision).map((r) => ({
++ key: r.key, label: r.label, type: r.type,
++ value: r.type === 'number' ? r.value_number : r.type === 'boolean' ? r.value_text === 'true' : r.value_text,
++ }));
++ }
++
++ function attachCitationList(page, revisionNumber, list, actor) {
++ if (!list || !list.length) return [];
++ return wrapContentError(() => citations.attachMany(page.id, revisionNumber, list.map((c) => {
++ if (!c || (!c.url && !c.sourceItemId)) fail(422, 'citation.no_source', 'A citation needs a URL or a Sources item id');
++ if (c.url && !c.sourceItemId && !c.retrievedAt) fail(422, 'citation.retrieved_at_required', `When was ${c.url} read? A URL citation needs retrievedAt`);
++ return { ...c, attachedBy: actorId(actor) };
++ })));
++ }
++
++ function carryCitations(page, from, to, keep, actor) {
++ if (!from || keep === 'none' || (Array.isArray(keep) && !keep.length)) return [];
++ const ids = keep === 'all' || keep == null ? null : keep.map(Number).filter(Number.isInteger);
++ return citations.carryForward({ entityId: page.id, fromRevision: from, toRevision: to, ids, attachedBy: actorId(actor) });
++ }
++
++ function humanRecord(actor) {
++ return authorship.record({ mode: 'human', authors: [requirePerson(actor)] });
++ }
++
++ // ── Publication (internal: no permission checks) ─────────
++ function publishRevision(page, revisionNumber, actor) {
++ const rev = revisions.get(page.id, revisionNumber);
++ if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
++ const rec = rev.meta.authorship;
++ if (rec) {
++ const ok = authorship.canPublish(rec, reviews.latest(page.id, revisionNumber));
++ if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');
++ }
++ const b = before(page);
++ const t = now();
++ db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = ?, title = ?, published_at = COALESCE(published_at, ?),
++ revision_published_at = CASE WHEN published_revision IS ? AND state = 'published' THEN revision_published_at ELSE ? END, updated_at = ? WHERE id = ?`)
++ .run(revisionNumber, rev.fields.title || page.title, t, revisionNumber, t, t, page.id);
++ return sync(b, page.id, actor);
++ }
++
++ /**
++ * Readers who are not editors see the published revision and the history before it, never
++ * newer drafts and never an AI proposal a person has not approved.
++ */
++ function revisionVisible(space, page, n, actor) {
++ if (access.canEdit(space, actor)) return true;
++ if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
++ const p = q.proposalByRev.get(page.id, n);
++ return !p || p.status === 'approved';
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const svc = {
++ access, spacePath, pagePath, pageUrl, WikiError,
++
++ // Spaces ---------------------------------------------------------------------------
++ listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },
++
++ getSpace(idOrSlug, actor) {
++ const s = spaceOrFail(idOrSlug);
++ if (!access.canReadSpace(s, actor)) fail(404, 'space.not_found', 'No such space');
++ return s;
++ },
++
++ /** Raw lookup for redirects and routing (no permission check; callers decide). */
++ findSpace(slug) { return q.spaceBySlug.get(String(slug)) || null; },
++ findPage(spaceId, slug) { return q.pageBySlug.get(spaceId, String(slug)) || null; },
++ pageById(id) { return PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) || null : null; },
++ spaceById(id) { return q.spaceById.get(id) || null; },
++
++ createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {
++ const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 120);
++ if (!cleanName) fail(422, 'space.invalid_name', 'A space needs a name');
++ let cleanSlug;
++ try { cleanSlug = slug ? String(slug).trim().toLowerCase() : content.pageSlug(cleanName); } catch (err) { fail(422, 'space.invalid_slug', err.message); }
++ if (!content.SPACE_SLUG_RE.test(cleanSlug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
++ if (!SPACE_KINDS.includes(kind)) fail(422, 'space.invalid_kind', 'kind is official or user');
++ const vis = checkVisibility(visibility, 'public');
++ // Official (editorial) spaces: Network staff, this service, or a service acting as itself.
++ if (kind === 'official' && !(actor && (actor.kind === 'system' || (actor.kind === 'user' && actor.staff) || (actor.kind === 'service' && !actor.subject)))) {
++ fail(403, 'space.official_staff_only', 'Only OpenVibe staff create official spaces');
++ }
++ if (kind === 'user') requirePerson(actor);
++ const owner = kind === 'user' ? actor.subject : (actor && actor.kind === 'service' ? actor.service : 'svc:wiki');
++ return tx(() => {
++ if (q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);
++ const t = now();
++ const id = spaceIdNew(t);
++ q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });
++ if (/^usr_/.test(owner)) q.putRole.run(id, owner, 'owner', actorId(actor), t);
++ redirects.release(`/s/${cleanSlug}`);
++ const space = q.spaceById.get(id);
++ spaceEvent(space, actor, 'created');
++ return space;
++ });
++ },
++
++ updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {
++ const space = spaceOrFail(idOrSlug);
++ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can change it');
++ return tx(() => {
++ const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
++ const snaps = new Map(pages.map((p) => [p.id, before(p)]));
++ const t = now();
++ const next = {
++ name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
++ description: description === undefined ? space.description : (description == null ? null : String(description).slice(0, 1000)),
++ visibility: checkVisibility(visibility, space.visibility),
++ slug: slug == null || slug === '' ? space.slug : String(slug).trim().toLowerCase(),
++ };
++ if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
++ if (next.slug !== space.slug) {
++ if (q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);
++ redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });
++ for (const p of q.pagesOfSpace.all(space.id)) redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });
++ }
++ db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')
++ .run(next.name, next.description, next.visibility, next.slug, t, space.id);
++ const updated = q.spaceById.get(space.id);
++ spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));
++ for (const p of pages) sync(snaps.get(p.id), p.id, actor);
++ return updated;
++ });
++ },
++
++ deleteSpace(idOrSlug, actor) {
++ const space = spaceOrFail(idOrSlug);
++ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can delete it');
++ return tx(() => {
++ const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
++ const snaps = new Map(pages.map((p) => [p.id, before(p)]));
++ db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);
++ for (const p of pages) { scheduler.cancelPending(p.id); sync(snaps.get(p.id), p.id, actor); }
++ spaceEvent(q.spaceById.get(space.id), actor, 'deleted');
++ return { deleted: true };
++ });
++ },
++
++ roles(idOrSlug, actor) {
++ const space = spaceOrFail(idOrSlug);
++ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner sees the role list');
++ return q.roles.all(space.id).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));
++ },
++
++ setRole(idOrSlug, subject, role, actor) {
++ const space = spaceOrFail(idOrSlug);
++ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner can change roles');
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) fail(422, 'wiki.invalid_subject', 'Roles go to people: a usr_… subject id');
++ if (role != null && !['owner', 'editor', 'viewer'].includes(role)) fail(422, 'wiki.invalid_role', 'role is owner, editor or viewer (or null to remove)');
++ return tx(() => {
++ const current = q.roles.all(space.id).find((r) => r.subject === subject);
++ const losingOwner = current && current.role === 'owner' && role !== 'owner';
++ if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
++ if (role == null) q.delRole.run(space.id, subject);
++ else q.putRole.run(space.id, subject, role, actorId(actor), now());
++ spaceEvent(space, actor, 'roles');
++ return svc.roles(space.id, actor);
++ });
++ },
++
++ // Pages ----------------------------------------------------------------------------
++ tree(space, actor) {
++ const pages = q.pagesOfSpace.all(space.id).filter((p) => access.canReadPage(space, p, actor));
++ const byParent = new Map();
++ for (const p of pages) {
++ const k = p.parent_id && pages.some((x) => x.id === p.parent_id) ? p.parent_id : null;
++ if (!byParent.has(k)) byParent.set(k, []);
++ byParent.get(k).push(p);
++ }
++ const build = (parent, depth) => (byParent.get(parent) || []).map((p) => ({ page: p, depth, children: depth < 12 ? build(p.id, depth + 1) : [] }));
++ return build(null, 0);
++ },
++
++ createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
++ const space = spaceOrFail(spaceIdOrSlug);
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
++ // Only this service's own import path (the seed) may state another authorship; people are human authors.
++ const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
++ const cleanTitle = checkTitle(title);
++ const slug = wrapContentError(() => content.pageSlug(cleanTitle));
++ const text = checkBody(body == null ? '' : body);
++ const box = wrapContentError(() => content.normalizeInfobox(infobox));
++ const vis = checkVisibility(visibility, 'public');
++ return tx(() => {
++ const existing = q.pageBySlug.get(space.id, slug);
++ if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
++ if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
++ const t = now();
++ const id = pageIdNew(t);
++ q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
++ redirects.release(`/w/${space.slug}/${slug}`);
++ const { revision } = wrapContentError(() => revisions.create({
++ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },
++ meta: { authorship: rec }, author: actorId(actor), message,
++ }));
++ const page = q.pageById.get(id);
++ writeRevisionExtras(space, page, revision, box);
++ attachCitationList(page, revision.number, cites, actor);
++ revisionEvent(space, page, revision, actor);
++ if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);
++ return { page, revision };
++ });
++ },
++
++ /**
++ * New revision. keepCitations: 'all' (default) carries the base revision's citations
++ * forward, an array keeps only those citation ids, 'none' drops them all (they stay on
++ * the older revision). citations: new ones for this revision.
++ */
++ editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can edit it');
++ const rec = humanRecord(actor);
++ if (!Number.isInteger(expectedRevision)) fail(422, 'revision.expected_required', 'expectedRevision (the revision you edited) is required');
++ return tx(() => {
++ const head = revisions.head(page.id);
++ if (!head) fail(404, 'revision.not_found', 'This page has no revision');
++ const text = body == null ? head.content : checkBody(body);
++ const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
++ const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : (summary || null), infobox: box };
++ const { revision, created } = wrapContentError(() => revisions.create({
++ entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
++ }));
++ if (!created) return { page, revision, created: false };
++ writeRevisionExtras(space, page, revision, box);
++ // Citations come from the revision the editor started from (the head unless the
++ // head is an AI proposal the editor chose not to build on).
++ const from = baseRevision != null && revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;
++ carryCitations(page, from, revision.number, keepCitations, actor);
++ attachCitationList(page, revision.number, cites, actor);
++ db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
++ revisionEvent(space, page, revision, actor);
++ notifyWatchers(space, page, 'revised', revision.number, actor);
++ return { page: q.pageById.get(page.id), revision, created: true };
++ });
++ },
++
++ /** Revert = a new revision copying an old one (content, fields, citations, infobox). */
++ revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can revert');
++ const who = requirePerson(actor);
++ return tx(() => {
++ const target = revisions.get(page.id, Number(toRevision));
++ if (!target) fail(404, 'revision.not_found', `No revision ${toRevision}`);
++ const trec = target.meta.authorship;
++ // Restoring AI text keeps the AI workflow on record: the reverting person is accountable (hybrid).
++ const rec = trec && (trec.mode === 'ai' || trec.mode === 'hybrid') && trec.workflow
++ ? authorship.record({ mode: 'hybrid', authors: [who], workflow: trec.workflow })
++ : authorship.record({ mode: 'human', authors: [who] });
++ const { revision } = wrapContentError(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));
++ writeRevisionExtras(space, page, revision, target.fields.infobox || []);
++ carryCitations(page, target.number, revision.number, 'all', actor);
++ db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
++ revisionEvent(space, page, revision, actor, { reverted_to: target.number });
++ const livePage = q.pageById.get(page.id);
++ const shouldPublish = publish == null ? livePage.state === 'published' : !!publish;
++ if (shouldPublish) publishRevision(livePage, revision.number, actor);
++ else notifyWatchers(space, livePage, 'revised', revision.number, actor);
++ return { page: q.pageById.get(page.id), revision, published: shouldPublish };
++ });
++ },
++
++ publish(pageId, { revision } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can publish');
++ if (!actor || actor.kind !== 'system') requirePerson(actor);
++ return tx(() => {
++ const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
++ const out = publishRevision(q.pageById.get(page.id), n, actor);
++ return { page: q.pageById.get(page.id), ...out };
++ });
++ },
++
++ unpublish(pageId, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can unpublish');
++ if (page.state !== 'published') fail(409, 'page.not_published', 'The page is not published');
++ return tx(() => {
++ const b = before(page);
++ db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
++ scheduler.cancelPending(page.id, 'unpublish');
++ return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ });
++ },
++
++ /** Idempotent: the same (page, revision, time) returns the existing job. */
++ schedulePublish(pageId, { revision, runAt } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can schedule');
++ requirePerson(actor);
++ const at = typeof runAt === 'number' ? runAt : Date.parse(runAt);
++ if (!Number.isFinite(at)) fail(422, 'schedule.invalid_time', 'runAt must be an ISO 8601 time');
++ const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
++ if (!revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);
++ return tx(() => {
++ const { job, created } = scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });
++ if (page.state === 'draft' || page.state === 'unpublished') {
++ const b = before(page);
++ db.prepare("UPDATE wiki_pages SET state = 'scheduled', updated_at = ? WHERE id = ?").run(now(), page.id);
++ sync(b, page.id, actor);
++ }
++ return { job, created, page: q.pageById.get(page.id) };
++ });
++ },
++
++ jobs(pageId) { return scheduler.jobs(pageId); },
++
++ /** The worker: publishes due jobs. Re-running a job that already took effect changes nothing. */
++ async runSchedule({ worker = config.workerId } = {}) {
++ return scheduler.runDue({
++ worker,
++ handler: (job) => tx(() => {
++ const page = q.pageById.get(job.entityId);
++ if (!page || page.state === 'deleted') return { skipped: 'deleted' };
++ const space = q.spaceById.get(page.space_id);
++ if (!space || space.deleted_at) return { skipped: 'space_deleted' };
++ if (job.action === 'publish') {
++ if (page.state === 'published' && page.published_revision === job.revision) return { noop: true };
++ publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });
++ return { published: job.revision };
++ }
++ if (page.state !== 'published') return { noop: true };
++ const b = before(page);
++ db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
++ sync(b, page.id, { kind: 'system', service: 'svc:wiki' });
++ return { unpublished: true };
++ }),
++ });
++ },
++
++ movePage(pageId, { slug, title, parentId } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can move pages');
++ return tx(() => {
++ const b = before(page);
++ let nextSlug = page.slug;
++ if (slug != null || title != null) nextSlug = slug != null ? String(slug).trim().toLowerCase() : wrapContentError(() => content.pageSlug(checkTitle(title)));
++ if (!content.PAGE_SLUG_RE.test(nextSlug)) fail(422, 'page.invalid_slug', 'A page slug is lowercase letters, digits and dashes');
++ if (nextSlug !== page.slug) {
++ const clash = q.pageBySlug.get(space.id, nextSlug);
++ if (clash) fail(409, 'page.slug_taken', `A page "${nextSlug}" already exists in this space`);
++ redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);
++ }
++ let parent = page.parent_id;
++ if (parentId !== undefined) {
++ parent = parentId || null;
++ for (let p = parent ? q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? q.pageById.get(p.parent_id) : null, i++) {
++ if (p.id === page.id || i > 50) fail(422, 'page.invalid_parent', 'A page cannot be moved under itself');
++ if (p.space_id !== space.id) fail(422, 'page.invalid_parent', 'The parent must be a page of the same space');
++ }
++ }
++ db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);
++ sync(b, page.id, actor);
++ return q.pageById.get(page.id);
++ });
++ },
++
++ setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
++ return tx(() => {
++ const b = before(page);
++ db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
++ .run(checkVisibility(visibility, page.visibility), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
++ return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ });
++ },
++
++ deletePage(pageId, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space can delete pages');
++ if (page.state === 'deleted') return { page, action: null };
++ return tx(() => {
++ const b = before(page);
++ db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), page.id);
++ db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);
++ scheduler.cancelPending(page.id);
++ return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ });
++ },
++
++ // Reading --------------------------------------------------------------------------
++ /** The page as `actor` may see it; revision defaults to the published one (editors: head of a draft). */
++ view(space, page, actor, { revision } = {}) {
++ if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
++ const canEdit = access.canEdit(space, actor);
++ let n = revision == null ? (page.state === 'published' ? page.published_revision : revisions.headNumber(page.id)) : Number(revision);
++ if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
++ const rev = revisions.get(page.id, n);
++ if (!rev) fail(404, 'revision.not_found', 'No such revision');
++ n = rev.number;
++ const review = reviews.latest(page.id, n);
++ const rec = rev.meta.authorship || null;
++ const decision = page.state === 'published' && n === page.published_revision ? decide(space, page, rev) : seo.evaluate({ ...gateFacts(space, page, rev), state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });
++ return {
++ space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
++ isPublishedRevision: n === page.published_revision && page.state === 'published',
++ authorship: rec, review, disclosure: rec ? authorship.disclosure(rec, review) : null,
++ proposal: q.proposalByRev.get(page.id, n) || null,
++ infobox: infoboxOf(page.id, n),
++ citations: citations.forRevision(page.id, n),
++ links: q.linksOf.all(page.id, n),
++ attachments: attachments.list(page.id),
++ children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
++ parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
++ backlinks: svc.backlinks(space, page, actor),
++ discussion: discussions.get(page.id),
++ watching: actor && actor.subject ? !!q.isWatching.get(page.id, actor.subject) : false,
++ pendingProposals: canEdit ? q.proposalsOfPage.all(page.id).filter((p) => p.status === 'pending') : [],
++ jobs: canEdit ? scheduler.jobs(page.id).filter((j) => j.status === 'pending' || j.status === 'running') : [],
++ headNumber: canEdit ? revisions.headNumber(page.id) : null,
++ };
++ },
++
++ backlinks(space, page, actor) {
++ return q.backlinks.all(space.slug, page.slug).filter((p) => p.id !== page.id).map((p) => ({ page: p, space: q.spaceById.get(p.space_id) }))
++ .filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));
++ },
++
++ /** [[link]] target → { href, exists }. Pages the actor cannot read look missing. */
++ resolveLink({ space: spaceSlug, slug, title }, actor) {
++ const space = q.spaceBySlug.get(spaceSlug);
++ if (!space || space.deleted_at || !access.canReadSpace(space, actor)) return { href: null, exists: false };
++ let page = q.pageBySlug.get(space.id, slug);
++ if (!page) {
++ const r = redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: (id) => { const p = q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(q.spaceById.get(p.space_id), p) : null; } });
++ if (r && r.status === 301) { const moved = q.pageById.get(r.entityId); if (moved) page = moved; }
++ }
++ if (page && page.state === 'published' && access.canReadPage(q.spaceById.get(page.space_id), page, actor)) {
++ return { href: pagePath(q.spaceById.get(page.space_id), page), exists: true };
++ }
++ if (!page && access.canEdit(space, actor)) return { href: `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(slug)}/edit?title=${encodeURIComponent(title || slug)}`, exists: false };
++ return { href: null, exists: false };
++ },
++
++ renderRevision(space, rev, actor) {
++ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor) });
++ },
++
++ history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
++ const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
++ const space = q.spaceById.get(page.space_id);
++ return revisions.list(page.id, { limit, before: cursor }).filter((r) => revisionVisible(space, page, r.number, actor)).map((r) => ({
++ ...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
++ citationCount: citations.forRevision(page.id, r.number).length,
++ }));
++ },
++
++ diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {
++ const space = q.spaceById.get(page.space_id);
++ for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
++ return wrapContentError(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));
++ },
++
++ resolveRedirect(path) {
++ return redirects.resolve(path, {
++ currentPath: (id) => {
++ if (/^spc_/.test(id)) { const s = q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }
++ const p = q.pageById.get(id);
++ if (!p || p.state === 'deleted') return null;
++ const s = q.spaceById.get(p.space_id);
++ return s && !s.deleted_at ? pagePath(s, p) : null;
++ },
++ });
++ },
++
++ search(text, actor, { space = null, limit = 20 } = {}) {
++ const words = String(text || '').trim().slice(0, 200);
++ if (!words) return [];
++ const like = `%${words.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
++ const out = [];
++ for (const p of q.search.all({ like, space })) {
++ const s = q.spaceById.get(p.space_id);
++ if (!access.canReadPage(s, p, actor)) continue;
++ const rev = revisions.get(p.id, p.published_revision);
++ out.push({ page: p, space: s, summary: rev.fields.summary || ssr.markdownToText(rev.content, 200) });
++ if (out.length >= Math.min(50, Math.max(1, Number(limit) || 20))) break;
++ }
++ return out;
++ },
++
++ // Discovery (public, indexable only) -----------------------------------------------
++ publishedPublic() {
++ return q.allPublishedPublic.all().map((page) => {
++ const space = q.spaceById.get(page.space_id);
++ const rev = revisions.get(page.id, page.published_revision);
++ return { space, page, rev, decision: decide(space, page, rev) };
++ });
++ },
++
++ recentChanges(limit = 50) {
++ return q.recent.all(Math.min(200, Math.max(1, limit))).map((page) => {
++ const space = q.spaceById.get(page.space_id);
++ const rev = revisions.get(page.id, page.published_revision);
++ return { space, page, rev, decision: decide(space, page, rev) };
++ });
++ },
++
++ // Citations and media ----------------------------------------------------------------
++ /** Citations go on an unpublished head revision (a published revision's sources are fixed). */
++ attachCitations(pageId, revisionNumber, list, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can cite');
++ const n = Number(revisionNumber);
++ if (n !== revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');
++ if (page.state === 'published' && page.published_revision === n) fail(409, 'citation.revision_published', 'That revision is published; its sources are fixed. Edit the page to add sources to a new revision');
++ return tx(() => attachCitationList(page, n, list, actor));
++ },
++
++ citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },
++ citationHistory(page) { return citations.history(page.id); },
++
++ attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
++ return wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: String(mediaId || '').trim(), alt, caption, role }));
++ },
++
++ detachMedia(pageId, attachmentId, actor) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can detach media');
++ return attachments.detach(page.id, attachmentId);
++ },
++
++ markMediaBroken(mediaId, reason) { return attachments.markBroken(mediaId, reason); },
++
++ /** Check every attachment against Media; outages change nothing (check_failed). */
++ async verifyMedia(pageId, resolve) { return attachments.verify(pageId, { resolve }); },
++
++ async verifyAllMedia(resolve) {
++ const ids = db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments').all().map((r) => r.entity_id);
++ const out = [];
++ for (const id of ids) out.push(...await attachments.verify(id, { resolve }));
++ return out;
++ },
++
++ // Discussion -------------------------------------------------------------------------
++ /** The Community thread of a public, published page (resolved once, then stored as a reference). */
++ async discussionThread(space, page, client) {
++ const known = discussions.get(page.id);
++ if (known) return known.threadId;
++ if (page.state !== 'published' || access.effectiveVisibility(space, page) !== 'public') return null;
++ const out = await discussions.threadFor(page.id, { service: 'wiki', type: 'page', id: page.id, label: page.title.slice(0, 200) }, { client });
++ return out.threadId;
++ },
++
++ // Watchers ---------------------------------------------------------------------------
++ watch(pageId, actor, on = true) {
++ const page = pageOrFail(pageId);
++ const space = spaceOrFail(page.space_id);
++ const who = requirePerson(actor);
++ if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
++ if (on) q.watch.run(page.id, who, now()); else q.unwatch.run(page.id, who);
++ return { watching: on };
++ },
++
++ // AI proposals (a seam for OpenVibe.AI: Wiki never calls a model) -------------------
++ /**
++ * An AI workflow proposes a revision. It becomes an immutable revision with ai authorship
++ * (workflow + run id) and a pending proposal; nothing is published until a person approves.
++ * pageId null + title = propose a new page (created as a draft).
++ */
++ propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
++ if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'proposal.service_only', 'AI proposals come from a service principal');
++ if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
++ const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
++ const text = checkBody(body == null ? '' : body);
++ const box = wrapContentError(() => content.normalizeInfobox(infobox));
++ return tx(() => {
++ let page = pageId ? pageOrFail(pageId) : null;
++ const space = page ? spaceOrFail(page.space_id) : spaceOrFail(spaceIdOrSlug);
++ const t = now();
++ let base = 0;
++ if (!page) {
++ const cleanTitle = checkTitle(title);
++ const slug = wrapContentError(() => content.pageSlug(cleanTitle));
++ if (q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);
++ const id = pageIdNew(t);
++ q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });
++ page = q.pageById.get(id);
++ } else {
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
++ base = revisions.headNumber(page.id);
++ if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
++ }
++ const head = base ? revisions.head(page.id) : null;
++ const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary, infobox: box };
++ const { revision } = wrapContentError(() => revisions.create({
++ entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
++ message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
++ }));
++ writeRevisionExtras(space, page, revision, box);
++ attachCitationList(page, revision.number, cites, actor);
++ const id = proposalIdNew(t);
++ q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });
++ revisionEvent(space, page, revision, actor, { proposal_id: id });
++ notifyWatchers(space, page, 'proposed', revision.number, actor);
++ return { proposal: q.proposal.get(id), page: q.pageById.get(page.id), revision };
++ });
++ },
++
++ proposals(pageId) { return q.proposalsOfPage.all(pageFor(pageId).id); },
++ pendingProposals(actor) {
++ return q.pendingProposals.all().filter((p) => access.canEdit(q.spaceById.get(p.space_id), actor));
++ },
++ getProposal(id) { return q.proposal.get(String(id)) || null; },
++
++ /** A person approves (and by default publishes) or rejects an AI proposal. */
++ reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {
++ const p = q.proposal.get(String(id));
++ if (!p) fail(404, 'proposal.not_found', 'No such proposal');
++ const space = spaceOrFail(p.space_id);
++ if (!access.canEdit(space, actor)) fail(403, 'proposal.forbidden', 'Only editors of this space review proposals');
++ const who = requirePerson(actor);
++ if (decision !== 'approved' && decision !== 'rejected') fail(422, 'proposal.invalid_decision', 'decision is approved or rejected');
++ if (p.status !== 'pending') fail(409, 'proposal.already_reviewed', `This proposal was already ${p.status}`);
++ return tx(() => {
++ reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });
++ q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });
++ let published = null;
++ if (decision === 'approved' && publish) published = publishRevision(q.pageById.get(p.page_id), p.revision, actor);
++ return { proposal: q.proposal.get(p.id), published: !!published };
++ });
++ },
++ };
++
++ function pageFor(id) { return pageOrFail(id); }
++
++ return svc;
++}
++
++module.exports = { createWikiService, WikiError, GATE_VIS };
ddiff --git a/test/auth.test.js b/test/auth.test.js
nnew file mode 100644
iindex 0000000..31ecca1
---- /dev/null
++++ b/test/auth.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * Network SSO session layer (same shape as OpenVibe.Community): the authorize redirect, state
++ * checking on the callback, the code exchange, open-redirect protection, /auth/me.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { sanitizeNext } = require('../server/auth/session');
++
++(async () => {
++ const tokenCalls = [];
++ const token = H.userToken({ username: 'ana' });
++ const fetchStub = async (url, init = {}) => {
++ if (String(url).endsWith('/oauth/token')) {
++ tokenCalls.push(JSON.parse(init.body));
++ return new Response(JSON.stringify({ access_token: token, refresh_token: 'r1' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
++ throw new Error(`unexpected ${url}`);
++ };
++ const h = await H.boot({ fetch: fetchStub, env: { OV_OAUTH_CLIENT_SECRET: 'x' } });
++ try {
++ const cfg = { baseUrl: 'https://openvibe.wiki', networkUrl: 'https://openvibe.network' };
++ assert.strictEqual(sanitizeNext('/w/a/b', cfg), '/w/a/b');
++ assert.strictEqual(sanitizeNext('//evil.example/x', cfg), '/');
++ assert.strictEqual(sanitizeNext('https://evil.example/', cfg), '/');
++ assert.strictEqual(sanitizeNext('https://openvibe.network/sso', cfg), 'https://openvibe.network/sso');
++
++ let r = await H.req(h, 'GET', '/auth/login?next=%2Fw%2Fx%2Fy');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, 'https://network.test/oauth/authorize');
++ assert.strictEqual(loc.searchParams.get('client_id'), 'wiki');
++ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'http://wiki.test/auth/callback');
++ const state = loc.searchParams.get('state');
++ const cookies = r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
++ assert.ok(cookies.includes(`ov_oauth_state=${state}`));
++
++ // A callback without the matching state cookie is refused.
++ r = await H.req(h, 'GET', `/auth/callback?code=abc&state=${state}`);
++ assert.strictEqual(r.status, 400);
++ r = await H.req(h, 'GET', '/auth/callback?code=abc&state=wrong', { cookie: cookies });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(tokenCalls.length, 0);
++
++ // With it: code exchange, session cookies, back to next.
++ r = await H.req(h, 'GET', `/auth/callback?code=abc&state=${state}`, { cookie: cookies });
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.get('location'), '/w/x/y');
++ assert.strictEqual(tokenCalls[0].grant_type, 'authorization_code');
++ assert.strictEqual(tokenCalls[0].client_id, 'wiki');
++ const set = r.headers.getSetCookie();
++ assert.ok(set.some((c) => c.startsWith(`ov_token=${token}`)));
++ assert.ok(set.some((c) => c.startsWith('ov_refresh=r1') && /HttpOnly/i.test(c) && /Path=\/auth/.test(c)));
++
++ r = await H.req(h, 'GET', '/auth/me', { cookie: `ov_token=${token}` });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.user.username, 'ana');
++ assert.strictEqual((await H.req(h, 'GET', '/auth/me')).status, 401);
++ // An expired cookie is simply signed out on pages.
++ r = await H.req(h, 'GET', '/', { cookie: `ov_token=${H.userToken({ exp: -120 })}` });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sign in with OpenVibe'));
++ // Silent login goes out with prompt=none.
++ r = await H.req(h, 'GET', '/auth/login?silent=1');
++ assert.strictEqual(new URL(r.headers.get('location')).searchParams.get('prompt'), 'none');
++ // Health and readiness.
++ assert.strictEqual((await H.req(h, 'GET', '/api/health')).json.service, 'openvibe-wiki');
++ const ready = await H.req(h, 'GET', '/api/ready');
++ assert.strictEqual(ready.status, 200);
++ assert.strictEqual(ready.json.checks.db.status, 'ok');
++ console.log('auth ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/content.test.js b/test/content.test.js
nnew file mode 100644
iindex 0000000..038ae71
---- /dev/null
++++ b/test/content.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * Content safety and parsing: [[links]] (code is left alone, cross-space prefixes), typed infobox
++ * values (nothing coerced silently), and escaping of everything a person can type — titles,
++ * link labels, infobox values, Markdown links with dangerous schemes.
++ */
++const assert = require('assert');
++const c = require('../server/wiki/content');
++const H = require('./helpers');
++
++// Links
++assert.deepStrictEqual(c.extractLinks('See [[Rye bread]], [[Rye bread|again]] and [[other-space:Wheat|wheat]].', 'bread').map((l) => [l.space, l.slug, l.label]),
++ [['bread', 'rye-bread', null], ['other-space', 'wheat', 'wheat']]);
++assert.deepStrictEqual(c.extractLinks('`[[Not a link]]`\n```\n[[Nor this]]\n```\n[[Yes]]', 's').map((l) => l.slug), ['yes']);
++assert.deepStrictEqual(c.extractLinks('[[Note: colon titles]]', 's').map((l) => [l.space, l.slug]), [['s', 'note-colon-titles']], 'Uppercase before a colon is part of the title');
++assert.deepStrictEqual(c.extractLinks('[[media:med_x]] [[!!!]]', 's'), [{ space: 's', slug: 'media-med-x', title: 'media:med_x', label: null }]);
++
++// Rendering escapes labels and never produces script or javascript: links.
++const html = c.renderContent('[[Page|<img src=x onerror=alert(1)>]] [x](javascript:alert(1)) <script>alert(1)</script>', {
++ currentSpace: 's', resolve: () => ({ href: '/w/s/page', exists: true }),
++});
++assert.ok(!/<script|<img|href="javascript/i.test(html), html);
++assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'));
++
++// Infobox
++assert.deepStrictEqual(c.parseInfoboxText('Founded | date | 2026-09-20\nPlayers | number | 2\nOpen | boolean | no\nSite | url | https://example.org\nName | Chess'), [
++ { key: 'founded', label: 'Founded', type: 'date', value: '2026-09-20' },
++ { key: 'players', label: 'Players', type: 'number', value: 2 },
++ { key: 'open', label: 'Open', type: 'boolean', value: false },
++ { key: 'site', label: 'Site', type: 'url', value: 'https://example.org/' },
++ { key: 'name', label: 'Name', type: 'text', value: 'Chess' },
++]);
++for (const bad of ['Site | url | javascript:alert(1)', 'N | number | lots', 'D | date | yesterday', 'B | boolean | maybe', 'M | media | https://x', 'A | text | x\nA | text | y', 'T | tensor | 1']) {
++ assert.throws(() => c.parseInfoboxText(bad), (e) => e instanceof c.ContentError, bad);
++}
++
++(async () => {
++ const h = await H.boot();
++ const me = { kind: 'user', subject: H.subject() };
++ try {
++ const s = h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);
++ const { page } = h.svc.createPage(s.id, {
++ title: '<script>alert(1)</script> title', body: `Body ${H.LONG}`, summary: '"><script>alert(2)</script>',
++ infobox: [{ label: '<i>L</i>', type: 'text', value: '<img src=x onerror=alert(3)>' }],
++ citations: [{ url: 'https://example.org/"><script>', title: '<script>alert(4)</script>', retrievedAt: '2026-09-01' }],
++ }, me);
++ h.svc.publish(page.id, {}, me);
++ const r = await H.req(h, 'GET', `/w/xss/${page.slug}`);
++ assert.strictEqual(r.status, 200);
++ const body = r.text.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, '').replace(/<script>\s*window\.__OV_PAGE[\s\S]*?<\/script>/, '').replace(/<script>\(function\(\)\{try\{var r=localStorage[\s\S]*?<\/script>/, '');
++ assert.ok(!/<script>alert|<img src=x|<i>L<\/i>|<b>Space<\/b>/.test(body), 'user text is escaped everywhere');
++ // JSON-LD cannot be broken out of.
++ for (const m of r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) assert.ok(!m[1].includes('</script'), 'no </script> inside JSON-LD');
++ console.log('content ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..768f356
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,81 @@
++'use strict';
++/**
++ * Test helpers: a Wiki instance on a temp database and a random port, a generated Network RSA
++ * key, signed user JWTs and service tokens, and a stub fetch for the other services.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { serviceAuth } = require('openvibe-contracts');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++
++const ISSUER = 'https://network.test';
++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++
++const ALPHA = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++function subject(prefix = 'usr') { let s = ''; for (let i = 0; i < 26; i++) s += ALPHA[crypto.randomInt(32)]; return `${prefix}_${s}`; }
++
++function userToken({ subject: sub = subject(), username = 'someone', role = 'user', aud = ['openvibe.network', 'openvibe.live'], exp = 3600, issuer = ISSUER } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, aud, sub: 42, id: 42, subject_id: sub, username, display_name: username, role, iat: now, exp: now + exp }, privateKey);
++}
++
++function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', exp = 300 } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${client}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privateKey);
++}
++
++const quiet = { log() {}, warn() {}, error() {} };
++
++/**
++ * A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
++ * (Community, Sources, Media, Events, Network token endpoint).
++ */
++async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens } = {}) {
++ const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
++ const config = load({
++ NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
++ WIKI_DB_PATH: dbPath || path.join(dir, 'wiki.db'),
++ OV_NETWORK_URL: ISSUER, OV_NETWORK_INTERNAL_URL: '', WIKI_GATE_MIN_WORDS: '20',
++ ...env,
++ });
++ const h = await start({
++ config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now,
++ fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
++ tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
++ });
++ const base = `http://127.0.0.1:${h.server.address().port}`;
++ return { ...h, base, dir, dbPath: config.dbPath };
++}
++
++async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
++ const hs = { ...headers };
++ if (token) hs.Authorization = `Bearer ${token}`;
++ if (cookie) hs.Cookie = cookie;
++ let payload;
++ if (body !== undefined) { hs['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
++ if (form !== undefined) {
++ hs['Content-Type'] = 'application/x-www-form-urlencoded';
++ const params = new URLSearchParams();
++ for (const [k, v] of Object.entries(form)) for (const x of [].concat(v)) params.append(k, x == null ? '' : String(x));
++ payload = params.toString();
++ }
++ const res = await fetch(h.base + p, { method, headers: hs, body: payload, redirect: 'manual' });
++ const text = await res.text();
++ let json = null;
++ try { json = JSON.parse(text); } catch { /* html or text */ }
++ return { status: res.status, headers: res.headers, text, json };
++}
++
++const cookieFor = (token) => `ov_token=${token}`;
++
++/** Every envelope in the outbox, oldest first. */
++function outbox(h) {
++ return h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
++}
++
++const LONG = 'This page has enough words to pass the thin content rule of the indexability gate, which the tests set to twenty words so that a short paragraph is enough for a published page to be indexable.';
++
++module.exports = { boot, req, userToken, serviceToken, subject, cookieFor, outbox, publicKey, privateKey, ISSUER, quiet, LONG };
ddiff --git a/test/integrations.test.js b/test/integrations.test.js
nnew file mode 100644
iindex 0000000..ce9afd3
---- /dev/null
++++ b/test/integrations.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * The services Wiki composes, against stubs: Media (a deleted or missing object is an explicit
++ * broken-asset state; an outage changes nothing), Sources (item citations carry the item's own
++ * provenance; unknown items and outages are refused, never invented), Community (the discussion is
++ * a thread reference; comments are posted as the signed-in person), Events (the outbox relay
++ * publishes with the service token).
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++const MED = (c) => `med_01J8Z6Q3KX00000000000000${c}${c}`.slice(0, 30);
++const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
++
++(async () => {
++ const calls = [];
++ let sourcesUp = true;
++ const thread = { id: 7, visibility: 'public', comment_count: 1 };
++ const comments = [{ id: 1, thread_id: 7, display_name: 'Bea', message: 'Nice page <b>really</b>', created_at: '2026-09-21T10:00:00.000Z', origin: 'user', deleted: false }];
++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ const fetchStub = async (url, init = {}) => {
++ const u = new URL(url);
++ const headers = Object.fromEntries(new Headers(init.headers || {}).entries());
++ calls.push({ url: u.pathname, method: init.method || 'GET', headers, body: init.body ? String(init.body) : null, host: u.host });
++ if (u.host === 'media.internal') {
++ const id = u.pathname.split('/').pop();
++ if (id === MED('A')) return json(200, { id, lifecycle_status: 'ready' });
++ if (id === MED('B')) return json(404, { code: 'media.object.not_found' });
++ if (id === MED('C')) return json(200, { id, lifecycle_status: 'deleted' });
++ throw new Error('ECONNREFUSED');
++ }
++ if (u.host === 'sources.internal') {
++ if (!sourcesUp) throw new Error('ECONNREFUSED');
++ if (u.pathname === `/api/v1/items/${ITEM}`) return json(200, { item: { id: ITEM, canonical_url: 'https://news.example/story', title: 'A story', provenance: { retrieved_at: '2026-09-19T08:30:00.000Z', license_note: 'CC BY 4.0' } } });
++ return json(404, { code: 'sources.not_found' });
++ }
++ if (u.host === 'community.internal') {
++ if (u.pathname === '/api/v1/comments/threads/resolve') return json(201, { thread, created: true });
++ if (u.pathname === '/api/v1/comments/threads/7' && (init.method || 'GET') === 'GET') return json(200, { thread, comments, next_cursor: null });
++ if (u.pathname === '/api/v1/comments/threads/7/comments') return json(201, { comment: { id: 2 } });
++ }
++ if (u.host === 'events.internal' && u.pathname === '/api/v1/events') {
++ const b = JSON.parse(init.body);
++ const list = b.events || [b];
++ const results = list.map((e, i) => ({ event_id: e.event_id, seq: i + 1, duplicate: false }));
++ return json(200, b.events ? { results } : results[0]);
++ }
++ return json(404, { error: 'stub: no route' });
++ };
++ const h = await H.boot({
++ fetch: fetchStub,
++ env: { OV_MEDIA_INTERNAL_URL: 'http://media.internal', OV_SOURCES_INTERNAL_URL: 'http://sources.internal', OV_COMMUNITY_INTERNAL_URL: 'http://community.internal', EVENTS_URL: 'http://events.internal' },
++ });
++ const me = H.subject();
++ const tok = H.userToken({ subject: me });
++ try {
++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok, body: { name: 'Media test', slug: 'mt' } });
++ assert.strictEqual(r.status, 201);
++
++ // Sources: an item citation takes url, title, retrieval time and license from the item.
++ r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Cited', body: H.LONG, citations: [{ source_item_id: ITEM, quote: 'a line' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ const pageId = r.json.page.id;
++ assert.deepStrictEqual(
++ { url: r.json.citations[0].url, title: r.json.citations[0].title, retrieved_at: r.json.citations[0].retrieved_at, license_note: r.json.citations[0].license_note, source_item_id: r.json.citations[0].source_item_id },
++ { url: 'https://news.example/story', title: 'A story', retrieved_at: '2026-09-19T08:30:00.000Z', license_note: 'CC BY 4.0', source_item_id: ITEM },
++ );
++ const srcCall = calls.find((c) => c.host === 'sources.internal');
++ assert.strictEqual(srcCall.headers.authorization, 'Bearer stub-token');
++ r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Unknown', body: H.LONG, citations: [{ source_item_id: 'itm_01J8Z6Q3KX0000000000000009' }] } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json.code, 'citation.source_item_not_found');
++ sourcesUp = false;
++ r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Down', body: H.LONG, citations: [{ source_item_id: ITEM }] } });
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(h.svc.findPage(h.svc.findSpace('mt').id, 'down'), null, 'nothing is written without the source');
++ sourcesUp = true;
++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
++
++ // Media: attach four objects, check them.
++ for (const c of ['A', 'B', 'C', 'D']) {
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: MED(c), alt: `Picture ${c}`, caption: `Caption ${c}` } });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: 'https://evil.example/x.png' } });
++ assert.strictEqual(r.status, 400, 'only Media object ids, never URLs');
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tok, body: {} });
++ const outcome = Object.fromEntries(r.json.results.map((x) => [x.mediaId, x.outcome + (x.reason ? `:${x.reason}` : '')]));
++ assert.deepStrictEqual(outcome, { [MED('A')]: 'available', [MED('B')]: 'broken:not_found', [MED('C')]: 'broken:deleted', [MED('D')]: 'check_failed' });
++ const html = (await H.req(h, 'GET', '/w/mt/cited')).text;
++ assert.ok(html.includes(`<img src="https://openvibe.media/o/${MED('A')}" alt="Picture A"`));
++ assert.ok(html.includes('This media was deleted from OpenVibe.Media and is no longer available.'));
++ assert.ok(html.includes('This media is no longer available.'));
++ assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('B')}"`), 'no image for a broken object');
++ assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('C')}"`));
++ assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="unverified"`), 'an outage leaves the state alone');
++ const pj = (await H.req(h, 'GET', '/w/mt/cited.json')).json;
++ assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'unverified']);
++
++ // Community: the thread is resolved once and stored as a reference; comments are Community's.
++ r = await H.req(h, 'GET', '/w/mt/cited');
++ assert.ok(r.text.includes('Nice page &lt;b&gt;really&lt;/b&gt;'), 'comment text is escaped');
++ await H.req(h, 'GET', '/w/mt/cited');
++ const resolves = calls.filter((c) => c.url === '/api/v1/comments/threads/resolve');
++ assert.strictEqual(resolves.length, 1);
++ assert.deepStrictEqual(JSON.parse(resolves[0].body).ref, { service: 'wiki', type: 'page', id: pageId, label: 'Cited' });
++ assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '7');
++ assert.deepStrictEqual(h.db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');
++ r = await H.req(h, 'POST', '/w/mt/cited/discuss', { cookie: H.cookieFor(tok), form: { message: 'Thanks' } });
++ assert.strictEqual(r.status, 303);
++ const post = calls.find((c) => c.url === '/api/v1/comments/threads/7/comments');
++ assert.strictEqual(post.headers['x-ov-subject'], me);
++ assert.deepStrictEqual(JSON.parse(post.body), { message: 'Thanks' });
++ r = await H.req(h, 'POST', '/w/mt/cited/discuss', { form: { message: 'anon' } });
++ assert.strictEqual(r.status, 401, 'anonymous visitors do not post through Wiki');
++
++ // Events: the relay publishes the outbox with the service token.
++ const pending = h.platform.outbox.pending();
++ assert.ok(pending > 0);
++ const flushed = await h.platform.outbox.flush();
++ assert.strictEqual(flushed.sent, pending);
++ assert.strictEqual(h.platform.outbox.pending(), 0);
++ const ev = calls.filter((c) => c.host === 'events.internal');
++ assert.ok(ev.length >= 1);
++ assert.strictEqual(ev[0].headers.authorization, 'Bearer stub-token');
++ const published = ev.flatMap((c) => { const b = JSON.parse(c.body); return b.events || [b]; });
++ assert.ok(published.some((e) => e.event_type === 'wiki.index_document.upserted'));
++ assert.ok(published.every((e) => /^evt_[0-9A-Z]{26}$/.test(e.event_id)));
++ console.log('integrations ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
nnew file mode 100644
iindex 0000000..baa4278
---- /dev/null
++++ b/test/nojs.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * A public page is useful with JavaScript disabled, and so is editing: every step here is a plain
++ * GET or an HTML form post (application/x-www-form-urlencoded), exactly what a browser without
++ * scripts sends. Also: red links, preview, edit conflicts, revert, cross-site post refusal.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
++
++(async () => {
++ const h = await H.boot();
++ const me = H.subject();
++ const cookie = H.cookieFor(H.userToken({ subject: me, username: 'ana' }));
++ try {
++ // Anonymous: the home page offers sign-in, the new-space form asks for it.
++ let r = await H.req(h, 'GET', '/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sign in with OpenVibe'));
++ assert.ok(r.text.includes('<noscript><nav'), 'a navigation exists without the JavaScript navbar');
++ r = await H.req(h, 'GET', '/new-space');
++ assert.strictEqual(r.status, 401);
++ assert.ok(r.text.includes('/auth/login?next=%2Fnew-space'));
++
++ // Create a space with a form post.
++ r = await H.req(h, 'POST', '/new-space', { cookie, form: { name: 'Board games', slug: 'board-games', visibility: 'public', description: 'Rules and notes' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), '/s/board-games');
++ r = await H.req(h, 'GET', '/s/board-games', { cookie });
++ assert.ok(r.text.includes('href="/s/board-games/new"'));
++
++ // Preview does not save.
++ const body = `Chess is played on a board of sixty-four squares. See [[Go]] and [[Missing rules|the missing rules]]. ${H.LONG}`;
++ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'preview', title: 'Chess', body, infobox: 'Players | number | 2\nInvented | text | unknown' } });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Preview (not saved)'));
++ assert.strictEqual(h.svc.findPage(h.svc.findSpace('board-games').id, 'chess'), null);
++ // Invalid infobox rows come back with the text intact.
++ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'save', title: 'Chess', body, infobox: 'Players | number | two' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('is not a number'));
++ assert.ok(r.text.includes('Chess is played on a board'));
++
++ // Save and publish with a source from the form's citation rows.
++ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: {
++ op: 'publish', title: 'Chess', body, summary: 'A two-player board game.', infobox: 'Players | number | 2',
++ cite_url_0: 'https://example.org/chess', cite_title_0: 'Chess rules', cite_retrieved_0: '2026-09-20', cite_quote_0: 'sixty-four squares',
++ } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('location'), '/w/board-games/chess');
++
++ // The published page read without any script: content, infobox, sources, history link.
++ r = await H.req(h, 'GET', '/w/board-games/chess');
++ assert.strictEqual(r.status, 200);
++ const page = stripScripts(r.text);
++ assert.ok(page.includes('<h1>Chess</h1>'));
++ assert.ok(page.includes('Chess is played on a board of sixty-four squares.'));
++ assert.ok(/<tr data-key="players" data-type="number"><th scope="row">Players<\/th><td>2<\/td><\/tr>/.test(page));
++ assert.ok(page.includes('href="https://example.org/chess"'));
++ assert.ok(page.includes('retrieved <time datetime="2026-09-20T00:00:00.000Z">2026-09-20</time>'));
++ assert.ok(page.includes('href="/w/board-games/chess/history"'));
++ assert.ok(page.includes('<nav class="ov-breadcrumbs"'));
++ // Red links: anonymous readers get plain text, not a dead link.
++ assert.ok(page.includes('<span class="ov-wikilink ov-redlink" title="Go (page does not exist)">Go</span>'));
++ // Structured data only from real fields (no invented author or image).
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const article = ld.find((x) => x['@type'] === 'Article');
++ assert.strictEqual(article.headline, 'Chess');
++ assert.strictEqual(article.author, undefined);
++ assert.strictEqual(article.image, undefined);
++ assert.deepStrictEqual(article.citation, [{ '@type': 'CreativeWork', url: 'https://example.org/chess', name: 'Chess rules' }]);
++ // The JSON representation describes the same content.
++ const j = (await H.req(h, 'GET', '/w/board-games/chess.json')).json;
++ assert.strictEqual(j.title, 'Chess');
++ assert.strictEqual(j.citations[0].retrieved_at, '2026-09-20T00:00:00.000Z');
++ assert.deepStrictEqual(j.infobox, [{ key: 'players', label: 'Players', type: 'number', value: 2 }]);
++
++ // Editors get red links that open the new-page form with the title filled in.
++ r = await H.req(h, 'GET', '/w/board-games/chess', { cookie });
++ assert.ok(r.text.includes('href="/w/board-games/go/edit?title=Go"'));
++ r = await H.req(h, 'GET', '/w/board-games/go/edit?title=Go', { cookie });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), '/s/board-games/new?title=Go');
++ r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'publish', title: 'Go', body: `Go is played with stones. Compare [[Chess]]. ${H.LONG}`, cite_url_0: 'https://example.org/go', cite_retrieved_0: '2026-09-21' } });
++ assert.strictEqual(r.status, 303);
++ r = await H.req(h, 'GET', '/w/board-games/chess');
++ assert.ok(r.text.includes('<a class="ov-wikilink" href="/w/board-games/go">Go</a>'), 'the red link turned blue');
++ assert.ok(r.text.includes('What links here'), 'backlinks from Go');
++
++ // Edit form: prefilled, keeps sources by default, publishes a new revision.
++ r = await H.req(h, 'GET', '/w/board-games/chess/edit', { cookie });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('name="expected_revision" value="1"'));
++ assert.ok(r.text.includes('name="keep_citation"'));
++ const citeId = r.text.match(/name="keep_citation" value="(\d+)"/)[1];
++ r = await H.req(h, 'POST', '/w/board-games/chess/edit', { cookie, form: { op: 'publish', expected_revision: '1', base_revision: '1', title: 'Chess', body: `${body} Castling exists.`, infobox: 'Players | number | 2', keep_citation: citeId } });
++ assert.strictEqual(r.status, 303);
++ r = await H.req(h, 'GET', '/w/board-games/chess');
++ assert.ok(r.text.includes('Castling exists.'));
++ assert.ok(r.text.includes('href="https://example.org/chess"'), 'the kept source moved to revision 2');
++ // A stale form is a conflict, and the typed text survives.
++ r = await H.req(h, 'POST', '/w/board-games/chess/edit', { cookie, form: { op: 'save', expected_revision: '1', base_revision: '1', title: 'Chess', body: 'My stale text', infobox: '' } });
++ assert.strictEqual(r.status, 409);
++ assert.ok(r.text.includes('My stale text'));
++
++ // Revert through the form.
++ r = await H.req(h, 'GET', '/w/board-games/chess/revert?to=1', { cookie });
++ assert.strictEqual(r.status, 200);
++ r = await H.req(h, 'POST', '/w/board-games/chess/revert', { cookie, form: { to: '1', expected_revision: '2' } });
++ assert.strictEqual(r.status, 303);
++ r = await H.req(h, 'GET', '/w/board-games/chess');
++ assert.ok(!r.text.includes('Castling exists.'));
++ assert.ok(r.text.includes('Revision 3 (a revert to revision 1)'));
++ // History compare form → diff page.
++ r = await H.req(h, 'GET', '/w/board-games/chess/compare?a=3&b=2');
++ assert.strictEqual(r.headers.get('location'), '/w/board-games/chess/diff/2/3');
++
++ // Page settings: move with the form; the old address redirects.
++ r = await H.req(h, 'POST', '/w/board-games/chess/settings', { cookie, form: { op: 'move', slug: 'chess-game', parent_id: '' } });
++ assert.strictEqual(r.status, 303);
++ r = await H.req(h, 'GET', '/w/board-games/chess');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/w/board-games/chess-game');
++ // Links written as [[Chess]] still resolve through the redirect.
++ r = await H.req(h, 'GET', '/w/board-games/go');
++ assert.ok(r.text.includes('href="/w/board-games/chess-game"'));
++
++ // Watch with a form.
++ r = await H.req(h, 'POST', '/w/board-games/go/watch', { cookie, form: { on: '1' } });
++ assert.strictEqual(r.status, 303);
++ assert.ok((await H.req(h, 'GET', '/w/board-games/go', { cookie })).text.includes('Stop watching'));
++
++ // Cross-site form posts are refused.
++ r = await H.req(h, 'POST', '/w/board-games/go/watch', { cookie, headers: { Origin: 'https://evil.example' }, form: { on: '0' } });
++ assert.strictEqual(r.status, 403);
++
++ // Search without JavaScript.
++ r = await H.req(h, 'GET', '/search?q=stones');
++ assert.ok(r.text.includes('href="/w/board-games/go"'));
++ // Discussion degrades to an explicit state when Community is not configured.
++ r = await H.req(h, 'GET', '/w/board-games/go');
++ assert.ok(r.text.includes('could not be loaded right now'));
++ console.log('nojs ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/permissions.test.js b/test/permissions.test.js
nnew file mode 100644
iindex 0000000..77a88d4
---- /dev/null
++++ b/test/permissions.test.js
@@@ -0,0 +1,172 @@
++'use strict';
++/**
++ * Permissions: space roles (owner/editor/viewer), visibility (public/members/private), visitors
++ * without SSO read public content only, service tokens are judged on one capability per route and
++ * act for the person in X-OV-Subject, AI proposals need a person's approval before publication.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ const h = await H.boot();
++ const owner = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject(), staff = H.subject();
++ const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
++ try {
++ // A person creates a user space over the API; they become its owner.
++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(owner), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.space.kind, 'user');
++ // Anonymous visitors cannot create spaces; a normal user cannot create an official one.
++ r = await H.req(h, 'POST', '/api/v1/spaces', { body: { name: 'Nope' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
++ assert.strictEqual(r.json.code, 'wiki.person_required');
++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(stranger), body: { name: 'Official?', kind: 'official' } });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(staff, 'admin'), body: { name: 'Handbook', slug: 'handbook', kind: 'official' } });
++ assert.strictEqual(r.status, 201);
++ // A bad Bearer token is refused, never downgraded to anonymous.
++ r = await H.req(h, 'GET', '/api/v1/spaces', { token: 'not.a.jwt' });
++ assert.strictEqual(r.status, 401);
++ r = await H.req(h, 'GET', '/api/v1/spaces', { token: H.userToken({ issuer: 'https://evil.example' }) });
++ assert.strictEqual(r.status, 401);
++
++ // Roles: only the owner grants them.
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${editor}`, { token: tok(stranger), body: { role: 'editor' } });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${editor}`, { token: tok(owner), body: { role: 'editor' } });
++ assert.strictEqual(r.status, 200);
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${viewer}`, { token: tok(owner), body: { role: 'viewer' } });
++ assert.strictEqual(r.status, 200);
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${owner}`, { token: tok(owner), body: { role: 'editor' } });
++ assert.strictEqual(r.status, 409, 'the last owner cannot step down');
++
++ // Editors write; strangers and viewers do not.
++ const page = { title: 'Tomatoes', body: H.LONG, citations: [{ url: 'https://example.org/t', retrieved_at: '2026-09-01T00:00:00Z' }] };
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: tok(stranger), body: page });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: tok(viewer), body: page });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: tok(editor), body: page });
++ assert.strictEqual(r.status, 201, r.text);
++ const pageId = r.json.page.id;
++ assert.strictEqual(r.json.citations[0].retrieved_at, '2026-09-01T00:00:00.000Z');
++ // Viewers cannot publish; editors can.
++ assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok(viewer), body: {} })).status, 403);
++ assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok(editor), body: {} })).status, 200);
++ // Only owners delete pages or change page visibility.
++ assert.strictEqual((await H.req(h, 'DELETE', `/api/v1/pages/${pageId}`, { token: tok(editor) })).status, 403);
++ assert.strictEqual((await H.req(h, 'PATCH', `/api/v1/pages/${pageId}`, { token: tok(editor), body: { visibility: 'private' } })).status, 403);
++
++ // Visitors without SSO read public content only.
++ assert.strictEqual((await H.req(h, 'GET', `/api/v1/pages/${pageId}`)).status, 200);
++ assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes')).status, 200);
++ await H.req(h, 'PATCH', '/api/v1/spaces/garden', { token: tok(owner), body: { visibility: 'private' } });
++ for (const p of ['/w/garden/tomatoes', '/w/garden/tomatoes/history', '/w/garden/tomatoes.json', '/s/garden']) {
++ assert.strictEqual((await H.req(h, 'GET', p)).status, 404, `anonymous ${p}`);
++ assert.strictEqual((await H.req(h, 'GET', p, { cookie: H.cookieFor(tok(stranger)) })).status, 404, `stranger ${p}`);
++ assert.strictEqual((await H.req(h, 'GET', p, { cookie: H.cookieFor(tok(viewer)) })).status, 200, `viewer ${p}`);
++ }
++ assert.strictEqual((await H.req(h, 'GET', `/api/v1/pages/${pageId}`)).status, 404);
++ assert.ok(!(await H.req(h, 'GET', '/api/v1/spaces')).json.spaces.some((s) => s.slug === 'garden'));
++ assert.ok((await H.req(h, 'GET', '/api/v1/spaces', { token: tok(viewer) })).json.spaces.some((s) => s.slug === 'garden'));
++ // Staff get no silent access to a private user space.
++ assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes', { cookie: H.cookieFor(tok(staff, 'admin')) })).status, 404);
++ // A watcher who loses access is not notified.
++ h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);
++ h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());
++ h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });
++ const watch = H.outbox(h).filter((e) => e.event_type === 'wiki.watch.triggered').pop();
++ assert.ok(watch.payload.recipients.includes(viewer));
++ assert.ok(!watch.payload.recipients.includes(stranger), 'no notification about a page the person cannot read');
++ assert.ok(!watch.payload.recipients.includes(editor), 'the actor is not notified of their own change');
++ await H.req(h, 'PATCH', '/api/v1/spaces/garden', { token: tok(owner), body: { visibility: 'public' } });
++
++ // Service tokens: one capability per route, audience openvibe.wiki, acting for X-OV-Subject.
++ const svcTok = (cap, aud) => H.serviceToken({ client: 'tools', cap, aud });
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: svcTok(['wiki.page.read']), headers: { 'X-OV-Subject': editor }, body: { ...page, title: 'Beans' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json.code, 'capability.denied');
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: svcTok(['wiki.page.create'], 'openvibe.media'), headers: { 'X-OV-Subject': editor }, body: { ...page, title: 'Beans' } });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.json.code, 'token.wrong_audience');
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: svcTok(['wiki.page.create']), body: { ...page, title: 'Beans' } });
++ assert.strictEqual(r.status, 403, 'a service writes content only for a named person');
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: svcTok(['wiki.page.create']), headers: { 'X-OV-Subject': stranger }, body: { ...page, title: 'Beans' } });
++ assert.strictEqual(r.status, 403, 'the named person must be an editor');
++ r = await H.req(h, 'POST', '/api/v1/spaces/garden/pages', { token: svcTok(['wiki.*']), headers: { 'X-OV-Subject': editor }, body: { ...page, title: 'Beans' } });
++ assert.strictEqual(r.status, 201, 'a wiki.* grant covers wiki.page.create');
++ assert.strictEqual(r.json.revision.author, editor);
++ // A service without a subject reads public content only.
++ r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: svcTok(['wiki.page.read']) });
++ assert.strictEqual(r.status, 200);
++ r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: svcTok(['wiki.search.query']) });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'GET', '/api/v1/search?q=Tomatoes', { token: svcTok(['wiki.search.query']) });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.results[0].id, pageId);
++
++ // AI proposals: a service with wiki.revision.propose proposes; nothing publishes until a person approves.
++ const ai = H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose', 'wiki.page.read'] });
++ const proposal = { space: 'garden', page_id: pageId, body: `${H.LONG} Proposed by a workflow.`, workflow: { id: 'wiki.generate_page', run_id: 'run_01', version: 1 }, citations: [{ url: 'https://example.org/ai', retrieved_at: '2026-09-10T00:00:00Z' }] };
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: tok(editor), body: proposal });
++ assert.strictEqual(r.status, 403, 'people do not file AI proposals');
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: ai, body: { ...proposal, workflow: {} } });
++ assert.strictEqual(r.status, 400, 'a proposal names its workflow and run');
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: ai, body: proposal });
++ assert.strictEqual(r.status, 201, r.text);
++ const prop = r.json.proposal;
++ assert.strictEqual(prop.status, 'pending');
++ assert.strictEqual(r.json.revision.authorship.mode, 'ai');
++ const n = prop.revision;
++ // Publishing the AI revision without approval is refused (by the Publishing authorship rules).
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok(editor), body: { revision: n } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'ai_generated_unreviewed');
++ // The public page still shows the human revision.
++ assert.ok(!(await H.req(h, 'GET', '/w/garden/tomatoes')).text.includes('Proposed by a workflow'));
++ // Readers never see an unapproved AI revision, not even through history or ?rev=.
++ assert.strictEqual((await H.req(h, 'GET', `/w/garden/tomatoes?rev=${n}`)).status, 404);
++ assert.strictEqual((await H.req(h, 'GET', `/w/garden/tomatoes/diff/1/${n}`)).status, 404);
++ assert.ok(!(await H.req(h, 'GET', '/w/garden/tomatoes/history')).text.includes(`?rev=${n}"`));
++ assert.ok(!(await H.req(h, 'GET', `/api/v1/pages/${pageId}/revisions`)).json.revisions.some((x) => x.number === n));
++ // Editors do, labelled as a pending AI proposal.
++ const ed = await H.req(h, 'GET', `/w/garden/tomatoes?rev=${n}`, { cookie: H.cookieFor(tok(editor)) });
++ assert.strictEqual(ed.status, 200);
++ assert.ok(ed.text.includes('AI proposal waiting for a person'));
++ assert.ok(ed.text.includes('not yet reviewed by a person'));
++ // A service cannot approve (reviews are by people); a viewer cannot either.
++ r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.publish'] }), body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(viewer), body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 403);
++ r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'approved' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.published, true);
++ const shown = await H.req(h, 'GET', '/w/garden/tomatoes');
++ assert.ok(shown.text.includes('Proposed by a workflow'));
++ assert.ok(shown.text.includes('AI-generated'), 'the AI disclosure is shown at the item');
++ assert.ok(shown.text.includes('reviewed by a person'));
++ const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;
++ assert.strictEqual(doc.authorship, 'ai_generated');
++ assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === 'run_01'));
++ r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'rejected' } });
++ assert.strictEqual(r.status, 409, 'a proposal is reviewed once');
++ // The proposing service can read its proposal's state.
++ r = await H.req(h, 'GET', `/api/v1/proposals/${prop.id}`, { token: ai });
++ assert.strictEqual(r.json.proposal.status, 'approved');
++ assert.strictEqual(r.json.proposal.reviewed_by, editor);
++
++ // Stub-provider output is also held for review; a rejected proposal never publishes.
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: ai, body: { space: 'garden', title: 'Peppers', body: H.LONG, stub_provider: true, workflow: { id: 'wiki.generate_page', run_id: 'run_02' } } });
++ assert.strictEqual(r.status, 201);
++ const p2 = r.json.proposal;
++ r = await H.req(h, 'POST', `/api/v1/proposals/${p2.id}/review`, { token: tok(editor), body: { decision: 'rejected', note: 'not sourced' } });
++ assert.strictEqual(r.json.published, false);
++ assert.strictEqual(h.svc.pageById(p2.page_id).state, 'draft');
++ assert.strictEqual((await H.req(h, 'GET', '/w/garden/peppers')).status, 404);
++ console.log('permissions ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
nnew file mode 100644
iindex 0000000..f0f9341
---- /dev/null
++++ b/test/proposals.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * The proposals for OpenVibe.Contracts are valid against the contracts' own schemas and match
++ * what the code enforces: every guarded capability has a manifest, every manifest is guarded,
++ * and the service manifest lists them all with the events the code produces.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { PROPOSED } = require('../server/auth/capabilities');
++
++const DOCS = path.join(__dirname, '..', 'docs');
++const dir = path.join(DOCS, 'capabilities-proposal');
++const ids = [];
++for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
++ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
++ const v = contracts.validate('capabilities.capability@1', m);
++ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(`${m.id}.json`, f);
++ assert.strictEqual(m.owner, 'wiki');
++ assert.ok(m.id.split('.').length === 3, `${m.id} has three segments`);
++ ids.push(m.id);
++}
++assert.deepStrictEqual([...ids].sort(), [...PROPOSED].sort());
++
++// Every guard('…') in the API is a proposed capability, and every proposed capability is guarded.
++const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
++const guarded = new Set([...api.matchAll(/guard\('([a-z.]+)'\)/g)].map((m) => m[1]));
++assert.deepStrictEqual([...guarded].sort(), [...PROPOSED].sort());
++
++const manifest = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
++const v = contracts.validate('registry.service-manifest@1', manifest);
++assert.ok(v.valid, JSON.stringify(v.errors));
++assert.strictEqual(manifest.id, 'wiki');
++assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
++
++// Events the code emits (literal types in the service + the Publishing hooks' page/index events).
++const service = fs.readFileSync(path.join(__dirname, '..', 'server', 'wiki', 'service.js'), 'utf8');
++const literal = [...service.matchAll(/event_type: '(wiki\.[a-z_.]+)'/g)].map((m) => m[1]);
++const produced = new Set([...literal, 'wiki.page.published', 'wiki.page.updated', 'wiki.page.unpublished', 'wiki.page.deleted', 'wiki.index_document.upserted', 'wiki.index_document.deleted']);
++assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...produced].sort());
++for (const t of produced) assert.ok(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/.test(t), t);
++
++// The §15.13 minimum set is covered.
++for (const need of ['wiki.space.create', 'wiki.page.create', 'wiki.page.read', 'wiki.revision.propose', 'wiki.revision.publish', 'wiki.revision.revert', 'wiki.citation.attach']) assert.ok(ids.includes(need), need);
++for (const need of ['wiki.space.updated', 'wiki.revision.created', 'wiki.page.published', 'wiki.page.updated', 'wiki.page.deleted']) assert.ok(produced.has(need), need);
++console.log('proposals ok');
ddiff --git a/test/revisions.test.js b/test/revisions.test.js
nnew file mode 100644
iindex 0000000..431f011
---- /dev/null
++++ b/test/revisions.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * create → edit → publish → revise → diff → revert keeps an immutable lineage; citations and
++ * infobox values stay attached to the exact revision that used them.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ const h = await H.boot();
++ const alice = { kind: 'user', subject: H.subject(), staff: false };
++ try {
++ const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);
++ const src1 = { url: 'https://example.org/rye', title: 'Rye notes', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass.' } };
++ const src2 = { url: 'https://example.org/wheat', title: 'Wheat notes', retrievedAt: '2026-09-21T10:00:00Z' };
++
++ // r1: created with a citation and a typed infobox
++ const { page, revision: r1 } = h.svc.createPage(space.id, {
++ title: 'Rye bread', body: `# Rye bread\n\n${H.LONG}`, citations: [src1],
++ infobox: [{ label: 'Flour', type: 'text', value: 'rye' }, { label: 'Hydration', type: 'number', value: '78' }, { label: 'Sourdough', type: 'boolean', value: 'yes' }],
++ }, alice);
++ assert.strictEqual(r1.number, 1);
++ assert.strictEqual(page.state, 'draft');
++
++ // publish r1
++ h.svc.publish(page.id, { revision: 1 }, alice);
++ assert.strictEqual(h.svc.pageById(page.id).published_revision, 1);
++
++ // r2: edit drops src1, adds src2, changes the infobox
++ const e2 = h.svc.editPage(page.id, {
++ expectedRevision: 1, body: `# Rye bread\n\nA different text. ${H.LONG}`, citations: [src2], keepCitations: 'none',
++ infobox: [{ label: 'Flour', type: 'text', value: 'rye and wheat' }],
++ }, alice);
++ assert.strictEqual(e2.revision.number, 2);
++ assert.strictEqual(e2.created, true);
++
++ // Optimistic concurrency: an edit based on r1 is a 412 conflict.
++ assert.throws(() => h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');
++ // An identical edit creates nothing.
++ const same = h.svc.editPage(page.id, { expectedRevision: 2 }, alice);
++ assert.strictEqual(same.created, false);
++
++ // Citations stay with their revision.
++ const c1 = h.svc.citationsOf(page, 1);
++ const c2 = h.svc.citationsOf(page, 2);
++ assert.deepStrictEqual(c1.map((c) => c.url), ['https://example.org/rye']);
++ assert.deepStrictEqual(c2.map((c) => c.url), ['https://example.org/wheat']);
++ assert.strictEqual(c1[0].retrievedAt, '2026-09-20T10:00:00.000Z');
++ assert.deepStrictEqual(c1[0].quote, { text: 'Rye is a grass.', start: null, end: null });
++ // The published page (still r1) shows r1's sources, not r2's.
++ const pub = h.svc.view(space, h.svc.pageById(page.id), alice, {});
++ assert.strictEqual(pub.revision.number, 1);
++ assert.deepStrictEqual(pub.citations.map((c) => c.url), ['https://example.org/rye']);
++ assert.deepStrictEqual(pub.infobox, [
++ { key: 'flour', label: 'Flour', type: 'text', value: 'rye' },
++ { key: 'hydration', label: 'Hydration', type: 'number', value: 78 },
++ { key: 'sourdough', label: 'Sourdough', type: 'boolean', value: true },
++ ]);
++
++ // Citations cannot be attached to the published revision after the fact.
++ assert.throws(() => h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);
++ // ...but can be to the unpublished head.
++ h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);
++ assert.strictEqual(h.svc.citationsOf(page, 2).length, 2);
++ // A URL citation without a retrieval time is refused (never invented).
++ assert.throws(() => h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');
++
++ // Publish r2, then diff r1 → r2
++ h.svc.publish(page.id, { revision: 2 }, alice);
++ const d = h.svc.diff(h.svc.pageById(page.id), 1, 2);
++ assert.ok(d.content.ops.some((o) => o.op === 'insert' && /different/.test(o.text)));
++ assert.ok(d.fields.some((f) => f.field === 'infobox'));
++
++ // Revert to r1 = r3, a copy of r1 with r1's citations carried forward; history is untouched.
++ const rv = h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);
++ assert.strictEqual(rv.revision.number, 3);
++ assert.strictEqual(rv.revision.kind, 'revert');
++ assert.strictEqual(rv.revision.revertedTo, 1);
++ assert.strictEqual(rv.revision.content, h.stores.revisions.get(page.id, 1).content);
++ assert.strictEqual(rv.published, true, 'a revert of a published page is published');
++ const c3 = h.svc.citationsOf(page, 3);
++ assert.deepStrictEqual(c3.map((c) => c.url), ['https://example.org/rye']);
++ assert.strictEqual(c3[0].carriedFrom, c1[0].id);
++ assert.strictEqual(h.svc.citationsOf(page, 2).length, 2, 'r2 keeps its own citations');
++ assert.deepStrictEqual(h.svc.view(space, h.svc.pageById(page.id), alice, {}).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);
++
++ // Lineage: r3 → r2 → r1 by parent pointers.
++ assert.deepStrictEqual(h.stores.revisions.lineage(page.id).map((r) => r.number), [3, 2, 1]);
++ // Immutability is enforced by the database, not by convention.
++ assert.throws(() => h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);
++ assert.throws(() => h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);
++ assert.throws(() => h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);
++ assert.throws(() => h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);
++ assert.throws(() => h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);
++
++ // The same flow over HTTP: history lists every revision, the diff page renders.
++ const hist = await H.req(h, 'GET', `/w/bread/rye-bread/history`);
++ assert.strictEqual(hist.status, 200);
++ for (const n of [1, 2, 3]) assert.ok(hist.text.includes(`?rev=${n}"`), `history lists r${n}`);
++ const diff = await H.req(h, 'GET', '/w/bread/rye-bread/diff/1/2');
++ assert.strictEqual(diff.status, 200);
++ assert.ok(/<ins>|<del>/.test(diff.text));
++ assert.ok(diff.text.includes('<meta name="robots" content="noindex, nofollow">'));
++ // An old revision is viewable and marked noindex.
++ const old = await H.req(h, 'GET', '/w/bread/rye-bread?rev=2');
++ assert.strictEqual(old.status, 200);
++ assert.ok(old.text.includes('not the published revision 3'));
++ assert.ok(old.text.includes('content="noindex, nofollow"'));
++
++ // Every revision produced a wiki.revision.created event.
++ const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);
++ assert.deepStrictEqual(created, [1, 2, 3]);
++ console.log('revisions ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..35b9837
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if
++ * any of them fails. They use temp SQLite databases, a generated RSA key and stub upstreams; none
++ * of them needs the network or a running OpenVibe service.
++ *
++ * npm test # everything
++ * npm test -- revisions seo # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++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 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 results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${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(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
nnew file mode 100644
iindex 0000000..2c21c69
---- /dev/null
++++ b/test/schedule.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * Scheduled publication is idempotent across worker restarts: scheduling twice makes one job, a
++ * worker that dies mid-job leaves the lease to expire and the next process finishes it, and
++ * re-running a job that already took effect changes nothing (no second publication event, no new
++ * index revision).
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ let clock = Date.parse('2026-10-01T08:00:00Z');
++ const now = () => clock;
++ const h1 = await H.boot({ now });
++ const dbPath = h1.dbPath;
++ const editor = { kind: 'user', subject: H.subject(), staff: false };
++ const space = h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);
++ const { page } = h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);
++ const runAt = '2026-10-01T09:00:00Z';
++
++ const a = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
++ const b = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
++ assert.strictEqual(a.created, true);
++ assert.strictEqual(b.created, false, 'the same schedule twice is one job');
++ assert.strictEqual(a.job.id, b.job.id);
++ assert.strictEqual(h1.svc.pageById(page.id).state, 'scheduled');
++ assert.strictEqual((await H.req(h1, 'GET', '/w/diary/launch-notes')).status, 404, 'scheduled is not public yet');
++
++ // Not due yet: nothing happens.
++ let out = await h1.svc.runSchedule({ worker: 'w1' });
++ assert.strictEqual(out.done.length, 0);
++
++ // Due: worker w1 claims it and "crashes" before completing (lease taken, never completed).
++ clock = Date.parse('2026-10-01T09:00:01Z');
++ const claimed = h1.stores.scheduler.claim({ worker: 'w1' });
++ assert.strictEqual(claimed.length, 1);
++ await h1.stop();
++
++ // A new process on the same database. Inside the lease nothing runs twice.
++ const h2 = await H.boot({ now, dbPath });
++ out = await h2.svc.runSchedule({ worker: 'w2' });
++ assert.strictEqual(out.done.length, 0, 'the lease still belongs to w1');
++ // After the lease expires the new worker publishes.
++ clock += 61000;
++ out = await h2.svc.runSchedule({ worker: 'w2' });
++ assert.strictEqual(out.done.length, 1);
++ assert.deepStrictEqual(out.done[0].result, { published: 1 });
++ let p = h2.svc.pageById(page.id);
++ assert.strictEqual(p.state, 'published');
++ assert.strictEqual(p.published_revision, 1);
++ assert.strictEqual(p.published_at, clock);
++ const publishedAt = p.published_at;
++ assert.strictEqual((await H.req(h2, 'GET', '/w/diary/launch-notes')).status, 200);
++
++ const count = (h) => {
++ const all = H.outbox(h).filter((e) => e.subject.id === page.id);
++ return { published: all.filter((e) => e.event_type === 'wiki.page.published').length, index: all.filter((e) => e.event_type === 'wiki.index_document.upserted').length };
++ };
++ const once = count(h2);
++ assert.deepStrictEqual(once, { published: 1, index: 1 });
++
++ // Re-running the same job (a replay after a crash between publish and complete) is a no-op.
++ const replay = await h2.stores.scheduler.runDue({ worker: 'w3', handler: async () => ({}) }); // nothing due any more
++ assert.strictEqual(replay.done.length, 0);
++ h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });
++ clock += 1000;
++ out = await h2.svc.runSchedule({ worker: 'w3' });
++ assert.deepStrictEqual(out.done[0].result, { noop: true });
++ assert.deepStrictEqual(count(h2), once, 'no second publication event and no new index revision');
++ p = h2.svc.pageById(page.id);
++ assert.strictEqual(p.published_at, publishedAt, 'the first publication time is kept');
++ await h2.stop();
++
++ // A third restart: still exactly one publication.
++ const h3 = await H.boot({ now, dbPath });
++ out = await h3.svc.runSchedule({ worker: 'w4' });
++ assert.strictEqual(out.done.length, 0);
++ assert.deepStrictEqual(count(h3), once);
++ // Deleting a page cancels its pending jobs.
++ h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);
++ h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });
++ assert.ok(h3.svc.jobs(page.id).filter((j) => j.status === 'pending').length === 0);
++ await h3.stop();
++ console.log('schedule ok');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/seed.test.js b/test/seed.test.js
nnew file mode 100644
iindex 0000000..cd8fbce
---- /dev/null
++++ b/test/seed.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The official "OpenVibe" seed: idempotent, every page published, cited (GitHub permalinks at a
++ * pinned commit, with a retrieval time), indexable under the production gate policy, internally
++ * linked without red links, labelled with its real authorship, and free of pricing copy.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const H = require('./helpers');
++const { seed } = require('../server/wiki/seed');
++const content = require('../server/wiki/content');
++
++const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));
++
++(async () => {
++ // Static checks on the seed file itself.
++ assert.strictEqual(data.space.slug, 'openvibe');
++ assert.strictEqual(data.space.kind, 'official');
++ assert.ok(data.pages.length >= 5);
++ const titles = new Set(data.pages.map((p) => p.title));
++ for (const p of data.pages) {
++ const text = [p.title, p.summary || '', p.body, JSON.stringify(p.infobox || [])].join('\n');
++ assert.ok(!/\bfree\b|\$0|no cost|pricing/i.test(text), `${p.title}: no pricing copy`);
++ assert.ok(p.citations && p.citations.length, `${p.title}: cited`);
++ for (const c of p.citations) {
++ assert.ok(/^https:\/\/github\.com\/OpenVibers\/[A-Za-z.]+\/blob\/[0-9a-f]{40}\/(README\.md|STATUS\.json)$/.test(c.url), `${p.title}: permalink ${c.url}`);
++ assert.ok(!Number.isNaN(Date.parse(c.retrievedAt)), `${p.title}: retrieval time`);
++ }
++ for (const l of content.extractLinks(p.body, 'openvibe')) assert.ok([...titles].some((t) => content.pageSlug(t) === l.slug), `${p.title}: [[${l.title}]] has a page`);
++ content.normalizeInfobox(p.infobox);
++ }
++
++ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
++ try {
++ const first = seed(h.svc, data, { log: H.quiet });
++ assert.strictEqual(first.created.length, data.pages.length);
++ const again = seed(h.svc, data, { log: H.quiet });
++ assert.strictEqual(again.created.length, 0, 'idempotent');
++ assert.strictEqual(again.skipped.length, data.pages.length);
++ const space = h.svc.findSpace('openvibe');
++ assert.strictEqual(space.kind, 'official');
++
++ const published = h.svc.publishedPublic();
++ assert.strictEqual(published.length, data.pages.length);
++ for (const { page, decision } of published) {
++ assert.ok(decision.indexable, `${page.title}: indexable (${decision.codes.join(', ')})`);
++ }
++ // The pages as a reader sees them: no red links, imported authorship disclosed, sitemap lists them all.
++ for (const p of data.pages) {
++ const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);
++ assert.strictEqual(r.status, 200, p.title);
++ assert.ok(!r.text.includes('ov-redlink'), `${p.title}: no red links`);
++ assert.ok(r.text.includes('summarised with AI assistance'), `${p.title}: authorship disclosed`);
++ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
++ }
++ const sm = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
++ assert.strictEqual((sm.text.match(/<loc>/g) || []).length, data.pages.length);
++ // The official space is not editable by an ordinary signed-in person, but is by staff.
++ const person = { kind: 'user', subject: H.subject(), staff: false };
++ const staff = { kind: 'user', subject: H.subject(), staff: true };
++ assert.throws(() => h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
++ assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);
++ // Search index documents record the imported authorship.
++ const doc = H.outbox(h).find((e) => e.event_type === 'wiki.index_document.upserted').payload;
++ assert.strictEqual(doc.authorship, 'imported');
++ console.log('seed ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/visibility.test.js b/test/visibility.test.js
nnew file mode 100644
iindex 0000000..807260e
---- /dev/null
++++ b/test/visibility.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++/**
++ * A private, members-only or deleted page leaves sitemaps, feeds and the Search index (tombstone
++ * event) and is never publicly cacheable; renames answer 301 from every old address, deletions 410.
++ * Every event is a valid events.event-envelope@1 and every Search document a valid
++ * search.index-document@1 (openvibe-contracts).
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const H = require('./helpers');
++
++function lastIndexEvent(h, pageId) {
++ return H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();
++}
++
++(async () => {
++ const h = await H.boot();
++ const owner = { kind: 'user', subject: H.subject(), staff: false };
++ try {
++ const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const { page } = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);
++ const other = h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner).page;
++
++ // Draft: not in sitemap, feed or index; direct URL is 404 for anonymous visitors.
++ let r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
++ assert.ok(!r.text.includes('/w/notes/open-page'));
++ assert.strictEqual((await H.req(h, 'GET', '/w/notes/open-page')).status, 404);
++ assert.strictEqual(lastIndexEvent(h, page.id), undefined, 'a draft never reaches Search');
++
++ // Published: sitemap, feeds, index upsert, public cache.
++ h.svc.publish(page.id, {}, owner);
++ h.svc.publish(other.id, {}, owner);
++ r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
++ assert.ok(r.text.includes('http://wiki.test/w/notes/open-page'));
++ assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/notes/open-page'));
++ assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/notes/open-page'));
++ let ev = lastIndexEvent(h, page.id);
++ assert.strictEqual(ev.event_type, 'wiki.index_document.upserted');
++ assert.strictEqual(ev.payload.visibility, 'public');
++ assert.strictEqual(ev.payload.indexability.decision, 'index');
++ r = await H.req(h, 'GET', '/w/notes/open-page');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
++ // A signed-in view is personalised: never publicly cacheable.
++ r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++
++ // Private page: leaves sitemap, feeds and Search (tombstone at a higher revision), 404 + private for anonymous.
++ const before = lastIndexEvent(h, page.id).payload.revision;
++ h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
++ ev = lastIndexEvent(h, page.id);
++ assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
++ assert.ok(ev.payload.revision > before, 'the tombstone outranks the last document');
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('open-page'));
++ assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('open-page'));
++ assert.ok(!(await H.req(h, 'GET', '/feed.json')).text.includes('open-page'));
++ assert.ok(!(await H.req(h, 'GET', '/llms.txt')).text.includes('open-page'));
++ r = await H.req(h, 'GET', '/w/notes/open-page');
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ r = await H.req(h, 'GET', '/w/notes/open-page.json');
++ assert.strictEqual(r.status, 404);
++ // The owner still reads it, privately.
++ r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.ok(r.text.includes('noindex'));
++ // Search (the wiki's own) does not return it to anonymous callers.
++ assert.ok(!(await H.req(h, 'GET', '/api/v1/search?q=Open')).json.results.some((x) => x.id === page.id));
++ // The product event for a non-public change is internal.
++ const upd = H.outbox(h).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();
++ assert.strictEqual(upd.visibility, 'internal');
++
++ // Space goes members-only: its public pages leave the index too.
++ h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
++ assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
++ assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 404, 'anonymous cannot read a members page');
++ r = await H.req(h, 'GET', '/w/notes/second-page', { cookie: H.cookieFor(H.userToken()) });
++ assert.strictEqual(r.status, 200, 'any signed-in account reads a members page');
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/spaces.xml')).text.includes('/s/notes'));
++ h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
++ assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.upserted', 'back in the index when public again');
++
++ // Rename: every old address 301s to the current one (chains collapse).
++ h.svc.movePage(other.id, { slug: 'second' }, owner);
++ h.svc.movePage(other.id, { slug: 'third' }, owner);
++ r = await H.req(h, 'GET', '/w/notes/second-page/history');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/w/notes/third/history');
++ r = await H.req(h, 'GET', '/w/notes/second');
++ assert.strictEqual(r.headers.get('location'), '/w/notes/third');
++ ev = lastIndexEvent(h, other.id);
++ assert.strictEqual(ev.payload.canonical_url, 'http://wiki.test/w/notes/third');
++ // A renamed space redirects its pages as well.
++ h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);
++ r = await H.req(h, 'GET', '/w/notes/third');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/w/jottings/third');
++ assert.strictEqual((await H.req(h, 'GET', '/s/notes')).headers.get('location'), '/s/jottings');
++
++ // Delete: 410 at the current and the old addresses, tombstone, gone from sitemap.
++ h.svc.deletePage(other.id, owner);
++ assert.strictEqual((await H.req(h, 'GET', '/w/jottings/third')).status, 410);
++ assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 410);
++ assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
++ assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('third'));
++
++ // Contracts: every envelope and every Search document validates.
++ const all = H.outbox(h);
++ for (const env of all) {
++ const v = contracts.validate('events.event-envelope@1', env);
++ assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(env.source, 'wiki');
++ assert.ok(env.event_type.startsWith('wiki.'));
++ if (env.event_type === 'wiki.index_document.upserted') {
++ const d = contracts.validate('search.index-document@1', env.payload);
++ assert.ok(d.valid, JSON.stringify(d.errors));
++ }
++ }
++ const types = new Set(all.map((e) => e.event_type));
++ for (const t of ['wiki.space.updated', 'wiki.revision.created', 'wiki.page.published', 'wiki.page.updated', 'wiki.page.deleted', 'wiki.index_document.upserted', 'wiki.index_document.deleted']) assert.ok(types.has(t), `emits ${t}`);
++ // Index revisions only grow per page.
++ for (const id of [page.id, other.id]) {
++ const revs = all.filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === id).map((e) => e.subject.revision);
++ assert.deepStrictEqual(revs, [...revs].sort((a, b) => a - b));
++ assert.strictEqual(new Set(revs).size, revs.length);
++ }
++
++ // robots.txt names the sitemap and states the automated-consumer policy.
++ r = await H.req(h, 'GET', '/robots.txt');
++ assert.ok(r.text.includes('Sitemap: http://wiki.test/sitemap.xml'));
++ assert.ok(r.text.includes('Disallow: /api/'));
++ console.log('visibility ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });