openvibers/openvibe.community Commits

Wave 5: typed comment threads, forum (spaces/threads/posts), Discord relay, Pulse

OP OpenVibers committed 23 Sept 2026 · 139aedd004 · 45 files, +4369 −54

- /api/v1/comments: threads resolved per EntityRef (get-or-create, unique on
  service+type+id; browsers limited to an allowlist of types, services with
  community.comment.write resolve any), cursor pagination with one level of
  replies, anonymous comments under the shared anonymous-write budget,
  per-person limits, race-safe votes (one UPSERT + score recomputed in the same
  IMMEDIATE transaction), author/moderator deletes, public|hidden|locked
  visibility for moderators (community.comment.moderate), CORS for OpenVibe
  origins with Bearer JWTs.
- Forum: spaces (seeded general, feedback, showcase; public|members|staff),
  threads, posts, post_versions, thread_votes. No-JS SSR pages /s, /s/:space
  (hot|new|top, server pagination), /s/:space/t/:slug with reply/vote/moderation
  forms, /s/:space/new; canonical, OG, DiscussionForumPosting JSON-LD, sitemap,
  /s/feed.xml and per-space feeds, robots. Safe Markdown subset renderer.
  Deterministic hot rank ov_hot = (score + 1) / (hours + 2)^1.5. JSON API under
  /api/v1/spaces and /api/v1/posts (community.post.create for services).
- Discord relay (DISCORD_RELAY_ENABLED, off by default): outbound per mapped
  public space, webhook URL read from the env var a mapping names, one delivery
  per (thread, mapping), exponential backoff, loop prevention for
  Discord-origin threads, staff admin endpoint /api/v1/relay.
- Pulse: pulse_items from Community's own public activity (hooks + read-time
  re-check) and POST /api/v1/pulse/items for services with
  community.pulse.write (own refs, public only, provenance kept); /pulse page
  and /api/v1/pulse with cursor pagination and ?origin=; AI items labelled,
  never attributed.
- Capabilities: proposed manifests in docs/capabilities-proposal/; ids the
  contracts library does not know yet are matched locally (exact or prefix.*).
  X-OV-Staff is also accepted with community.comment.moderate.
- Comments, forum, Pulse and relay live in Community's database in every
  PASTES_AUTHORITY mode.

Changes

ddiff --git a/.env.example b/.env.example
iindex 06a4456..23c6b41 100644
---- a/.env.example
++++ b/.env.example
@@@ -42,7 +42,20 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
# community this site's own database is the authority (native API, pages, raw text)
# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
PASTES_AUTHORITY=live
--# SQLite database (production unit: /var/lib/openvibe-community/community.db)
++# SQLite database (production unit: /var/lib/openvibe-community/community.db). Comments, the
++# forum, Pulse and the relay's bookkeeping live here in every mode; pastes too with 'community'.
COMMUNITY_DB_PATH=./data/community.db
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
#VIEW_HASH_SECRET=
++
++# Browser origins allowed to call /api/v1/comments and /api/v1/pulse with a Bearer Network JWT
++#API_CORS_ORIGINS=https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games
++
++# Discord relay (outbound: new threads in mapped spaces → a Discord webhook). Off by default.
++# Mappings (POST /api/v1/relay/mappings, staff) name an ENVIRONMENT VARIABLE that holds the
++# webhook URL — the URL itself never goes into the database.
++#DISCORD_RELAY_ENABLED=true
++#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>
++#DISCORD_RELAY_POLL_MS=30000
++#DISCORD_RELAY_BACKOFF_MS=30000
++#DISCORD_RELAY_MAX_ATTEMPTS=6
ddiff --git a/README.md b/README.md
iindex 44251dc..18790ee 100644
---- a/README.md
++++ b/README.md
@@@ -3,8 +3,9 @@
**https://openvibe.community — the people of OpenVibe.**
The community hub of the OpenVibe network: community-run, open source, free speech within
--the rules. Today it is the home of **pastes** (code, text and screenshots with a link);
--spaces, threads and submissions follow.
++the rules. It is the home of **pastes** (code, text and screenshots with a link), the
++**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
++embeds, and **Pulse**, the network's public activity. Submissions follow.
It is a small Node/Express app (CommonJS, no framework, one SQLite database) that
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
@@@ -92,12 +93,22 @@ Pages (server-rendered HTML):
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
++| `GET /s` | Spaces |
++| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` |
++| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms |
++| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
++| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete) |
++| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
API and machine endpoints:
| Route | What |
| --- | --- |
| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) |
++| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
++| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
++| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
++| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
@@@ -105,18 +116,198 @@ API and machine endpoints:
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `POST /auth/refresh` | Rotate via refresh token |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
++| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so
the shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`
(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
`account`).
++## Comments API
++
++`/api/v1/comments` is the comment system every OpenVibe product embeds instead of owning
++comment tables. A thread belongs to one entity, named by an EntityRef
++(`common.entity-ref@1`: `{ service, type, id, label? }`), and is created the first time anyone
++resolves it. Errors are `application/problem+json` (`errors.problem@1`, with the legacy
++`error` field).
++
++| Route | What |
++| --- | --- |
++| `POST /threads/resolve` `{ ref }` | Get-or-create the entity's thread (201 created, 200 existing; idempotent, unique on service+type+id) |
++| `GET /threads/:id` | The thread + first page of top-level comments, replies nested one level. `?after=<last id>`, `?sort=old\|new`, `?limit=`; `?parent=<id>` pages one comment's replies |
++| `POST /threads/:id/comments` `{ message, parent_id?, anon_name? }` | Comment. A reply to a reply joins the top-level comment's replies |
++| `DELETE /:commentId` | The author or a moderator (soft; a top-level comment with replies stays as a tombstone) |
++| `POST /:commentId/votes` `{ value: 1\|-1\|0 }` | Add, change or remove your vote (people only) |
++| `PUT /threads/:id/visibility` `{ visibility: public\|hidden\|locked }` | Moderators — e.g. the owner service hides the thread of an entity it took down |
++
++Who may do what:
++
++- **Browsers** (the Network JWT as `ov_token` cookie here, or `Authorization: Bearer` from
++ another OpenVibe site through CORS — `API_CORS_ORIGINS`, never cookies) resolve threads only
++ for these types: `live` vod, clip, stream, channel · `media` object · `community` paste, post
++ · `wiki` page · `blog` post · `reviews` entity. Community's own refs must exist and be
++ visible. Labels are only taken from services.
++- **Anonymous** visitors comment with an `anon_name`, under the same 20-writes-per-10-minutes
++ budget per address as anonymous pastes. People are limited per subject (10 s cooldown,
++ 5 per minute, no duplicate in a row; 60 votes a minute), whichever way they write.
++- **Services** (Network service token, audience `openvibe.community`): `community.comment.write`
++ resolves any ref and comments/votes/deletes as `X-OV-Subject`, or as AI with
++ `X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").
++ `community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person it
++ vouches is staff with `X-OV-Staff: 1`.
++- **Locked** threads can be read but take no comments or votes (moderators still may);
++ **hidden** threads are 404 for everyone but moderators.
++- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.
++
++Embedding it — server-side, from another product's backend (the usual way; the person is the
++one your own session says it is):
++
++```js
++const COMMUNITY = 'http://127.0.0.1:4200/api/v1/comments';
++const token = await tokens.authHeaders(); // openvibe-contracts serviceAuth token client,
++ // audience openvibe.community, community.comment.write
++const ref = { service: 'live', type: 'vod', id: String(vod.id), label: vod.title };
++const { thread } = await (await fetch(`${COMMUNITY}/threads/resolve`, {
++ method: 'POST', headers: { ...token, 'Content-Type': 'application/json' }, body: JSON.stringify({ ref }),
++})).json();
++const page = await (await fetch(`${COMMUNITY}/threads/${thread.id}`, { headers: token })).json();
++// page.comments[i] = { id, author: { subject, username, display_name, avatar_url } | null, anon_name,
++// display_name, origin, message, score, my_vote, reply_count, replies: [...], ... }
++await fetch(`${COMMUNITY}/threads/${thread.id}/comments`, {
++ method: 'POST',
++ headers: { ...token, 'X-OV-Subject': viewer.subjectId, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ message: 'Great VOD!' }),
++});
++```
++
++In the browser, from a page on an allowed origin that holds the visitor's Network JWT:
++
++```js
++const api = (path, opts = {}) => fetch(`https://openvibe.community/api/v1/comments${path}`, {
++ ...opts, headers: { Authorization: `Bearer ${networkJwt}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
++}).then((r) => r.json());
++const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: clipId } }) });
++let { comments, next_cursor } = await api(`/threads/${thread.id}`);
++if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`));
++await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) });
++```
++
++Render `message` as text (it is plain text, never HTML).
++
++## Forum: spaces, threads, posts
++
++Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces:
++`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone
++reads, people post), `members` (signed-in people only; every Network account counts as a
++member for now), `staff` (moderators only; looks missing to everyone else).
++
++- **Pages work without JavaScript** — sorting and pagination are links, replying, voting,
++ pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` is
++ refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
++ JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;
++ members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap
++ lists `/s`, `/pulse`, public spaces and the latest 1000 threads; `/s/*/new` is disallowed in
++ robots.txt.
++- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is the
++ deterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`
++ (`server/db.js`), computed against one `now` per page.
++- **Markdown** (`server/render/markdown.js`) is a safe subset: everything is escaped first and
++ only fixed tags are written — paragraphs, line breaks, headings (demoted to h3–h6), quotes,
++ lists, `---`, fenced code (highlighted like pastes), inline code, bold/italic/strike, links
++ (http(s), mailto, same-site paths, fragments; `rel="nofollow ugc noopener"`). Raw HTML is
++ shown as text.
++- **Writers**: people (browser JWT, or a service with `community.post.create` naming them in
++ `X-OV-Subject`) and AI output (`X-OV-Origin: ai` — no author, labelled as AI). Anonymous
++ visitors read. Limits per person: a thread every 30 s, 3 a minute, 20 a day; a post every
++ 10 s, 6 a minute, no duplicate in a row. Edits keep every revision in `post_versions`;
++ deletes are soft (tombstones keep the numbering; deleting the opening post deletes the
++ thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
++ pin, lock and delete; locked threads take no replies or votes.
++
++API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
++
++| Route | What |
++| --- | --- |
++| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
++| `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |
++| `POST /spaces/:space/threads` `{ title, body }` | New thread (`community.post.create` for services) |
++| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
++| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
++| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
++| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
++| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
++| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
++
++Votes (comments and threads) are one UPSERT per person plus a score recomputed from the vote
++rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift
++a score.
++
++## Discord relay
++
++Outbound only, and off unless `DISCORD_RELAY_ENABLED=true`. When a thread is created in a
++public space that has an enabled mapping, Community posts "New thread in s/<space> by <name>"
++with the title, an excerpt and a link to the thread to that mapping's Discord webhook.
++
++- A mapping (`relay_mappings`) names the **environment variable** that holds the webhook URL
++ (`webhook_url_ref`, e.g. `DISCORD_WEBHOOK_FEEDBACK`); the URL itself never enters the
++ database or any API response. Put the variable in `/etc/openvibe/community.env`.
++- One delivery per (thread, mapping) — the dedupe key in `relay_deliveries`. Network errors,
++ timeouts, 5xx, 429 (its `retry_after` honoured) and an unset variable are retried with
++ exponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour) up to
++ `DISCORD_RELAY_MAX_ATTEMPTS`; other 4xx fail at once. Mentions are disabled.
++- Loop prevention: a thread whose origin is `discord` is never relayed out (checked when
++ queueing and when sending). Members/staff spaces are never relayed.
++- Staff (admin/global_mod browsers, or services with `community.comment.moderate`):
++ `GET /api/v1/relay/deliveries?status=failed` shows what failed and why,
++ `POST /api/v1/relay/deliveries/:id/retry` queues one again,
++ `GET|POST /api/v1/relay/mappings` and `PUT /api/v1/relay/mappings/:id { enabled }` manage
++ mappings (`POST { space: 'feedback', webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' }`).
++
++## Pulse
++
++A read model of public activity across the network, with provenance (`pulse_items`, unique
++per source service/type/id).
++
++- **Community's own**: new public pastes written by a person (not burn-after-read, not NSFW),
++ new threads and replies in public spaces — recorded at write time, removed when deleted or
++ made non-public, and re-checked against their source on every read, so private or unlisted
++ things never show.
++- **Other services** publish with `community.pulse.write`:
++ `POST /api/v1/pulse/items { ref, title, url, origin?, occurred_at?, visibility? }` — the
++ ref's `service` must be the caller's own (`svc:live` → `live`), `visibility` other than
++ `public` is refused, `X-OV-Subject` names the person for origin `user`. Re-posting a source
++ updates its title and link; origin, actor and time stay the first record's.
++ `DELETE /api/v1/pulse/items/:service/:type/:id` retracts one.
++- **Reading**: `GET /api/v1/pulse?origin=user|ai|system&after=<cursor>&limit=` (newest first,
++ keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
++ they are never attributed to a person (roadmap §33); system items name no one either.
++
++## Capabilities
++
++Community checks service tokens against these capabilities (manifests in
++`docs/capabilities-proposal/`, same shape as OpenVibe.Contracts' `manifests/capabilities`):
++
++| Id | Status | Used for |
++| --- | --- | --- |
++| `community.paste.create` / `.write` / `.moderate` | in contracts | pastes |
++| `community.comment.write` | **proposed** | comment threads as a person or AI |
++| `community.comment.moderate` | **proposed** | thread visibility, comment/post/thread moderation, relay admin |
++| `community.pulse.write` | **proposed** | publishing to Pulse |
++| `community.post.create` | planned in contracts → **active** proposed | forum writes |
++
++Until a contracts release carries the proposed ids, `openvibe-contracts`' `capabilities.check`
++answers `capability.unknown` for them, so `server/identity/capabilities.js` decides those ids
++locally with the library's own matching rule (the exact id or a `prefix.*` grant). Ids the
++library knows always go through the library.
++
## SEO
Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
JSON-LD (`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished`
--on paste pages, `BreadcrumbList` on both). Unlisted/private pastes and search result pages
--are `noindex`. The sitemap lists home, `/pastes` and the latest public pastes (cached 1 h).
++on paste pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere).
++Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The
++sitemap lists home, `/pastes`, the latest public pastes, `/s`, `/pulse`, public spaces and
++their latest threads (cached 1 h).
## Configuration
@@@ -138,6 +329,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |
++| `API_CORS_ORIGINS` | Live, Media, Network, Tools, Games origins | Browser origins that may call `/api/v1/comments` and `/api/v1/pulse` with a Bearer JWT |
++| `DISCORD_RELAY_ENABLED` | off | `true` turns the outbound Discord relay on |
++| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Relay worker cadence, first retry delay, attempts before `failed` |
++| *(any name)* e.g. `DISCORD_WEBHOOK_FEEDBACK` | — | A Discord webhook URL, named by a relay mapping's `webhook_url_ref` |
| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
@@@ -160,9 +355,10 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
```
Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. The schema
--is created idempotently at boot (only when `PASTES_AUTHORITY=community`; in `live` mode the
--app writes nothing to disk). The database lives in the unit's `StateDirectory`
--(`/var/lib/openvibe-community`), so the code tree stays read-only.
++is created idempotently at boot in every mode (comments, the forum, Pulse and the relay live
++in Community's database whichever service owns pastes; the three seed spaces are inserted
++once). The database lives in the unit's `StateDirectory` (`/var/lib/openvibe-community`), so
++the code tree stays read-only.
Before flipping to `community`, the Network's `community` OAuth client needs the
`identity.subject.resolve` capability (audience `openvibe.network`) and
@@@ -195,8 +391,20 @@ server/
pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
pastes/source.js where pages read pastes from (Live or the store)
pastes/catalog.js recent public pastes: trending, related, language filter
++ comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments)
++ forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
++ pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
++ relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)
++ http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
++ identity/capabilities.js capability checks incl. the proposed ids; discussion staff/moderators
++ identity/authors.js author display from subject_projection; the AI label
++ votes.js race-safe up/down votes (comments, threads)
++ limits.js per-person write limits
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/pages.js home / browse / paste / new / my / error templates
++ render/forum.js spaces / threads / thread / new-thread templates
++ render/pulse.js the /pulse page
++ render/markdown.js the safe Markdown subset for posts
render/highlight.js highlight.js wrapper, language list, download extensions
seo.js robots, sitemap, RSS, JSON-LD builders
public/ css/community.css, js/community.js, favicon.svg, og-default.png
@@@ -204,14 +412,15 @@ vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-sh
deploy/ systemd unit, nginx vhost
scripts/import-pastes.js Media paste bundle importer
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
++docs/capabilities-proposal/ capability manifests for the next contracts release
```
--## What is next (phase 2)
++## What is next
--Spaces (per-streamer / per-topic communities), threads (long-form forum discussion) and
--submissions (clips, art, ideas, reports for community review). These need storage of their
--own — the paste database is the first — and moderation tooling; the paste pages, auth layer and
--chrome are built to be reused by them.
++Spaces per streamer, game and project (with membership), inbound Discord relay (threads from
++Discord arrive with origin `discord` and are never relayed back), visibility changes for
++comment threads arriving through Events, moving paste comments onto the typed comment
++threads, and submissions (clips, art, ideas, reports for community review).
## Related services
ddiff --git a/docs/capabilities-proposal/community.comment.moderate.json b/docs/capabilities-proposal/community.comment.moderate.json
nnew file mode 100644
iindex 0000000..0ce4ed6
---- /dev/null
++++ b/docs/capabilities-proposal/community.comment.moderate.json
@@@ -0,0 +1,27 @@
++{
++ "id": "community.comment.moderate",
++ "version": "1.0",
++ "owner": "community",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Moderation of discussions: set a comment thread's visibility (public, hidden, locked — e.g. when the owner service hides the entity), delete any comment or forum post, pin and lock forum threads, read hidden threads, administer the Discord relay. Acting as itself (no X-OV-Subject) the service is the moderator; acting for a person it must vouch with X-OV-Staff: 1 that the person is staff.",
++ "permissions": [
++ "community:moderate"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "community-moderate",
++ "events": [
++ "community.comment.moderated",
++ "community.thread.moderated"
++ ],
++ "implementedBy": [
++ "PUT /api/v1/comments/threads/:id/visibility",
++ "DELETE /api/v1/comments/:commentId",
++ "PUT /api/v1/spaces/:space/threads/:slug/state",
++ "DELETE /api/v1/spaces/:space/threads/:slug",
++ "PUT|DELETE /api/v1/posts/:id",
++ "GET|POST|PUT /api/v1/relay/*"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/community.comment.write.json b/docs/capabilities-proposal/community.comment.write.json
nnew file mode 100644
iindex 0000000..8e00c53
---- /dev/null
++++ b/docs/capabilities-proposal/community.comment.write.json
@@@ -0,0 +1,29 @@
++{
++ "id": "community.comment.write",
++ "version": "1.0",
++ "owner": "community",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Typed comment threads as a service: resolve the thread of any EntityRef (browsers are limited to an allowlist of types), and comment, vote and delete one's own comments as the acting subject (X-OV-Subject), or comment as origin \"ai\" (never attributed to a person, roadmap 33). Ownership and limits are checked by Community.",
++ "inputSchema": "common.entity-ref@1",
++ "outputSchema": "common.entity-ref@1",
++ "permissions": [
++ "community:write"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "community-write",
++ "events": [
++ "community.comment.created",
++ "community.comment.deleted",
++ "community.comment.voted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/comments/threads/resolve",
++ "GET /api/v1/comments/threads/:id",
++ "POST /api/v1/comments/threads/:id/comments",
++ "DELETE /api/v1/comments/:commentId",
++ "POST /api/v1/comments/:commentId/votes"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/community.post.create.json b/docs/capabilities-proposal/community.post.create.json
nnew file mode 100644
iindex 0000000..2357e3c
---- /dev/null
++++ b/docs/capabilities-proposal/community.post.create.json
@@@ -0,0 +1,27 @@
++{
++ "id": "community.post.create",
++ "version": "1.1",
++ "owner": "community",
++ "status": "active",
++ "visibility": "public",
++ "description": "Forum writes as a service: create threads and replies in a space, vote on threads, and edit or delete one's own posts, as the acting subject (X-OV-Subject) or as origin \"ai\" (no author, labelled as AI). Space visibility (public, members, staff), locks and limits are checked by Community.",
++ "outputSchema": "common.entity-ref@1",
++ "permissions": [
++ "community:write"
++ ],
++ "resourceConstraints": [
++ "space_member"
++ ],
++ "quotaClass": "community-write",
++ "events": [
++ "community.thread.created",
++ "community.post.created"
++ ],
++ "implementedBy": [
++ "POST /api/v1/spaces/:space/threads",
++ "POST /api/v1/spaces/:space/threads/:slug/posts",
++ "POST /api/v1/spaces/:space/threads/:slug/votes",
++ "PUT|DELETE /api/v1/posts/:id",
++ "DELETE /api/v1/spaces/:space/threads/:slug"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/community.pulse.write.json b/docs/capabilities-proposal/community.pulse.write.json
nnew file mode 100644
iindex 0000000..ecebcfa
---- /dev/null
++++ b/docs/capabilities-proposal/community.pulse.write.json
@@@ -0,0 +1,23 @@
++{
++ "id": "community.pulse.write",
++ "version": "1.0",
++ "owner": "community",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Publish public activity of the calling service's own entities to Pulse (EntityRef + title + url + origin user|ai|system + occurred_at), or retract it. Only visibility public is accepted; the ref's service must be the caller's. Provenance is kept: the first record's origin, actor (X-OV-Subject, never for ai/system) and time stand.",
++ "inputSchema": "common.entity-ref@1",
++ "permissions": [
++ "community:write"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "community-write",
++ "events": [
++ "community.pulse.item_recorded"
++ ],
++ "implementedBy": [
++ "POST /api/v1/pulse/items",
++ "DELETE /api/v1/pulse/items/:service/:type/:id"
++ ]
++}
ddiff --git a/package.json b/package.json
iindex 76ac70a..5b1ad77 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-community",
"version": "1.0.0",
"private": true,
-- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
++ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 2f57aa5..3129ba9 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -150,6 +150,48 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
.alert-error { border-color: var(--danger); color: var(--danger); }
++/* Spaces, threads, posts, Pulse */
++.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
++.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
++.space-item h2 { margin: 0 0 4px; font-size: 1.15rem; }
++.space-item p { margin: 4px 0 0; }
++.space-link:hover { text-decoration: none; }
++.sort-tabs { margin: 8px 0 14px; }
++.sort-tabs .tab { text-decoration: none; }
++.thread-row, .pulse-item { display: flex; gap: 14px; align-items: flex-start; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; }
++.thread-score { min-width: 2.6em; text-align: center; font-weight: 700; color: var(--text-secondary); padding-top: 2px; }
++.thread-main, .pulse-main { min-width: 0; flex: 1; }
++.thread-title { font-weight: 600; color: var(--text-primary); word-break: break-word; }
++.thread-title i { color: var(--accent-light); font-size: .85em; }
++.thread-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin: 4px 0 0; font-size: .82rem; color: var(--text-secondary); }
++.pulse-source { flex: none; margin-top: 2px; }
++.badge-ai { border-color: var(--info); color: var(--info); }
++.badge-vis { font-size: .7rem; vertical-align: middle; }
++.thread-head { display: flex; gap: 16px; align-items: flex-start; }
++.thread-head h1 { word-break: break-word; }
++.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }
++.vote-score { font-weight: 700; }
++.vote-btn { background: none; border: 1px solid var(--border-light); border-radius: 7px; color: var(--text-secondary); width: 2.2em; height: 2em; cursor: pointer; font: inherit; }
++.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }
++.vote-btn.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
++.mod-actions form { margin: 0; }
++.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }
++.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
++.post-opening { border-color: var(--border-light); }
++.post-deleted { background: transparent; border-style: dashed; padding: 8px 18px; }
++.post-deleted p { margin: 0; }
++.post-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: .88rem; color: var(--text-secondary); }
++.post-num { margin-left: auto; }
++.md { overflow-wrap: anywhere; }
++.md > :first-child { margin-top: .6em; }
++.md > :last-child { margin-bottom: 0; }
++.md blockquote { margin: .6em 0; padding: 4px 14px; border-left: 3px solid var(--border-light); color: var(--text-secondary); }
++.md code { font: .9em var(--mono); background: var(--bg-input); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; }
++.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }
++.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }
++.md hr { border: 0; border-top: 1px solid var(--border); }
++.reply-form { margin-top: 18px; }
++
/* Errors + footer host */
.error { text-align: center; padding: 60px 0; }
.error .btn { margin: 4px; }
@@@ -161,4 +203,6 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.code-wrap { max-height: none; }
.gutter { display: none; }
.code-wrap { grid-template-columns: 1fr; }
++ .thread-row, .pulse-item { flex-wrap: wrap; }
++ .thread-head { gap: 10px; }
}
ddiff --git a/server/app.js b/server/app.js
iindex 15e38b4..a891a2d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -7,13 +7,17 @@
*
* Pages (server-rendered) API / machine
* GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below
-- * GET /pastes browse GET /api/health, /api/ready
-- * GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml
-- * GET /p/:slug/raw raw text /auth/login|callback|logout|me|refresh
-- * GET /p/:slug/screenshot → image
-- * GET /p/:slug/download
-- * GET|POST /new create
-- * GET /my signed-in user's pastes
++ * GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)
++ * GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
++ * GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
++ * GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
++ * GET /p/:slug/download GET /api/health, /api/ready
++ * GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml
++ * GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
++ * GET /s … spaces, threads, posts (forum/routes.js)
++ * GET /pulse the network's public activity
++ *
++ * Comments, the forum, Pulse and the relay live in Community's database in every mode.
*
* PASTES_AUTHORITY (config.pastesAuthority):
* 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
@@@ -36,6 +40,20 @@ const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
const { createPastesProxy } = require('./pastes/proxy');
++const { openDb, getDb } = require('./db');
++const { createNetworkIdentity } = require('./identity/network');
++const { createViewerResolver } = require('./identity/viewer');
++const v1 = require('./http/v1');
++const { createCommentService } = require('./comments/service');
++const { createCommentsApi } = require('./comments/api');
++const { createForumService } = require('./forum/service');
++const { createSpacesApi, createPostsApi } = require('./forum/api');
++const { createForumRoutes } = require('./forum/routes');
++const { createPulse } = require('./pulse/service');
++const { createPulseApi } = require('./pulse/api');
++const { createDiscordRelay } = require('./relay/discord');
++const { createRelayApi } = require('./relay/api');
++const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@@ -81,22 +99,30 @@ function createApp(opts = {}) {
app.use('/auth', createAuthRoutes(config, auth));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
-- // ── Paste authority ──────────────────────────────────────
++ // ── Community's database, identity, and what lives in it in every mode ──
++ const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
++ const network = opts.network || createNetworkIdentity({ config, db });
++ const viewers = createViewerResolver({ auth, config, network });
++ const pulse = createPulse({ db, network, config });
++ const relay = opts.relay || createDiscordRelay({
++ db, config, enabled: config.discordRelay.enabled,
++ pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
++ ...(opts.relayOptions || {}),
++ });
++ const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });
const community = config.pastesAuthority === 'community';
-- let viewers = null;
++ const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
++ seo.useForum(forum);
++ Object.assign(app.locals, { db, network, pulse, relay, forum, comments });
++ if (opts.startRelay !== false) relay.start();
++
++ // ── Paste authority ──────────────────────────────────────
if (community) {
-- const { openDb, getDb } = require('./db');
-- const { createNetworkIdentity } = require('./identity/network');
-- const { createViewerResolver } = require('./identity/viewer');
const { createMediaFiles } = require('./media/files');
const { createPasteService } = require('./pastes/service');
-- const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
-- const network = opts.network || createNetworkIdentity({ config, db });
const media = opts.media || createMediaFiles({ config });
-- const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits });
-- viewers = createViewerResolver({ auth, config, network });
++ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse });
source.use(service);
-- app.locals.db = db;
app.locals.pastes = service;
} else {
source.use(null);
@@@ -117,6 +143,20 @@ function createApp(opts = {}) {
if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));
else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
++ // ── /api/v1: comments, forum, Pulse, relay admin ─────────
++ // Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.
++ const resolveLimiter = rateLimit({
++ windowMs: 10 * 60 * 1000, max: 300, standardHeaders: true, legacyHeaders: false,
++ skip: (req) => !!(req.viewer && req.viewer.kind === 'service'),
++ message: { error: 'Too many requests — try again later' },
++ });
++ const cors = v1.cors(config.apiCorsOrigins);
++ app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
++ app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
++ app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
++ app.use('/api/v1/posts', createPostsApi({ forum, viewers }));
++ app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
++
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
@@@ -249,6 +289,16 @@ function createApp(opts = {}) {
}
}));
++ // ── Forum and Pulse pages ────────────────────────────────
++ app.use(createForumRoutes({ forum, viewers, config }));
++ app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
++ const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
++ let out;
++ try { out = await pulse.list({ origin, after: req.query.after }); }
++ catch (err) { if (err instanceof v1.ApiError) return res.redirect(302, origin ? `/pulse?origin=${origin}` : '/pulse'); throw err; }
++ html(res, pulsePage({ items: out.items, origin, after: req.query.after || null, nextCursor: out.next_cursor }));
++ }));
++
app.get('/my', withUser, wrap(async (req, res) => {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
ddiff --git a/server/comments/api.js b/server/comments/api.js
nnew file mode 100644
iindex 0000000..bf71fe2
---- /dev/null
++++ b/server/comments/api.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * /api/v1/comments — typed comment threads other products embed (service.js has the rules).
++ *
++ * POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)
++ * GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)
++ * POST /threads/:id/comments { message, parent_id?, anon_name? }
++ * PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
++ * DELETE /:commentId author or moderator
++ * POST /:commentId/votes { value: 1|-1|0 }
++ *
++ * Callers: browsers (the Network user JWT as ov_token cookie or Bearer — other OpenVibe sites use
++ * Bearer through CORS), anonymous browsers (comments with an anon_name, under the shared
++ * anonymous-write budget), and services with a Network service token: community.comment.write
++ * to resolve/comment/vote/delete as X-OV-Subject (or as AI with X-OV-Origin: ai),
++ * community.comment.moderate for visibility and moderation. Errors are problem+json.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
++
++const WRITE = 'community.comment.write';
++const MOD = 'community.comment.moderate';
++
++function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++
++ const read = serviceAnyCap([WRITE, MOD]);
++ const passAnon = (_req, _res, next) => next();
++
++ router.post('/threads/resolve', serviceCap(WRITE), resolveLimiter || passAnon, jsonBody,
++ run((req) => service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
++ router.get('/threads/:id', read, run((req) => service.get(req.viewer, req.params.id, req.query)));
++ router.post('/threads/:id/comments', serviceCap(WRITE), anonWriteLimiter || passAnon, jsonBody,
++ run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));
++ router.put('/threads/:id/visibility', serviceCap(MOD), jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
++ router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), run((req) => service.remove(req.viewer, req.params.commentId)));
++ router.post('/:commentId/votes', serviceCap(WRITE), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createCommentsApi };
ddiff --git a/server/comments/service.js b/server/comments/service.js
nnew file mode 100644
iindex 0000000..8eeeb45
---- /dev/null
++++ b/server/comments/service.js
@@@ -0,0 +1,244 @@
++'use strict';
++
++/**
++ * Typed, reusable comment threads — the comment system every OpenVibe product embeds instead of
++ * owning comment tables. A thread belongs to one entity, named by an EntityRef
++ * (common.entity-ref@1: { service, type, id, label? }).
++ *
++ * Who may do what (the viewer comes from identity/viewer.js; api.js has already checked a
++ * service's capability for the route):
++ * - resolve: services with community.comment.write may resolve any ref; browsers (signed in or
++ * not) only refs of the types in BROWSER_REF_TYPES. Community's own refs must exist.
++ * - read: anyone, except hidden threads (moderators only; everyone else gets 404).
++ * - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
++ * from a service (origin ai, never attributed). Not on locked threads (moderators still may).
++ * - delete: the comment's author, or a moderator.
++ * - vote: people only; 1, -1 or 0 (remove).
++ * - visibility public|hidden|locked: moderators.
++ * A moderator is discussion staff (an admin/global_mod browser, or a service vouching with
++ * X-OV-Staff and holding community.comment.moderate) or a service holding
++ * community.comment.moderate acting as itself (no X-OV-Subject), e.g. an owner service hiding
++ * the thread of an entity it took down.
++ *
++ * Every method returns a plain object for the JSON response or throws ApiError.
++ */
++const contracts = require('openvibe-contracts');
++const store = require('./store');
++const pasteStore = require('../pastes/store');
++const { applyVote, myVotes, parseVote } = require('../votes');
++const { fail, isoTime } = require('../http/v1');
++const { createAuthors } = require('../identity/authors');
++const { createPersonLimiter } = require('../limits');
++const { discussionModerator: moderator } = require('../identity/capabilities');
++
++/** The entity types a browser may open a thread for (services with community.comment.write: any). */
++const BROWSER_REF_TYPES = {
++ live: ['vod', 'clip', 'stream', 'channel'],
++ media: ['object'],
++ community: ['paste', 'post'],
++ wiki: ['page'],
++ blog: ['post'],
++ reviews: ['entity'],
++};
++const VISIBILITIES = ['public', 'hidden', 'locked'];
++const MAX_MESSAGE = 5000;
++const PAGE = 30;
++const REPLY_PAGE = 20;
++
++function createCommentService({ db, network = null, pastesLocal = false, limits = {} } = {}) {
++ const authors = createAuthors({ db, network });
++ const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
++ const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
++
++ function refOf(t) {
++ const ref = { service: t.ref_service, type: t.ref_type, id: t.ref_id };
++ if (t.ref_label) ref.label = t.ref_label;
++ return ref;
++ }
++
++ function shapeThread(t, v) {
++ if (t.visibility === 'hidden' && !moderator(v)) return { id: t.id, ref: refOf(t), visibility: 'hidden', comment_count: null };
++ return { id: t.id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
++ }
++
++ function shapeComment(c, v, projections, votes) {
++ const deleted = !!c.deleted_at;
++ const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
++ const out = {
++ id: c.id,
++ thread_id: c.thread_id,
++ parent_id: c.parent_id || null,
++ origin: c.origin,
++ author: a,
++ anon_name: deleted || c.author_subject || c.origin === 'ai' ? null : (c.anon_name || 'Anonymous'),
++ display_name: deleted ? null : (a ? a.display_name || a.username : (c.anon_name || 'Anonymous')),
++ message: deleted ? null : c.message,
++ deleted,
++ score: c.score, upvotes: c.upvotes, downvotes: c.downvotes,
++ my_vote: votes.get(c.id) || 0,
++ reply_count: c.reply_count,
++ created_at: isoTime(c.created_at),
++ updated_at: isoTime(c.updated_at),
++ can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
++ };
++ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes));
++ return out;
++ }
++
++ async function shapeComments(rows, v) {
++ const all = [];
++ for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
++ const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
++ const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
++ return rows.map((r) => shapeComment(r, v, projections, votes));
++ }
++
++ /** The thread, or 404 — hidden threads look missing to everyone but moderators. */
++ function visibleThread(v, id) {
++ const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
++ if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
++ return t;
++ }
++
++ function visibleComment(v, id) {
++ const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;
++ if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
++ return { comment: c, thread: visibleThread(v, c.thread_id) };
++ }
++
++ /** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
++ function checkCommunityRef(ref) {
++ if (ref.service !== 'community') return;
++ if (ref.type === 'paste') {
++ if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here
++ const p = pasteStore.getBySlug(db, ref.id);
++ if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
++ } else if (ref.type === 'post') {
++ const ok = /^\d{1,15}$/.test(ref.id) && db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
++ WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));
++ if (!ok) fail(404, 'ref.not_found', 'No such post');
++ }
++ }
++
++ const cursorId = (after) => {
++ if (after == null || after === '') return null;
++ if (!/^\d{1,15}$/.test(String(after))) fail(400, 'request.invalid_cursor', '`after` is the id of the last comment you have');
++ return Number(after);
++ };
++
++ return {
++ BROWSER_REF_TYPES,
++
++ /** POST /threads/resolve { ref } → { thread, created } */
++ resolve(v, body = {}) {
++ const ref = body && body.ref;
++ const check = ref && typeof ref === 'object' ? contracts.validate('common.entity-ref@1', ref) : { valid: false };
++ if (!check.valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
++ if (v.kind !== 'service') {
++ const types = BROWSER_REF_TYPES[ref.service];
++ if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);
++ }
++ checkCommunityRef(ref);
++ // Labels are display caches; only a service's word is taken for them.
++ const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;
++ const { thread, created } = store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });
++ return { thread: shapeThread(thread, v), created };
++ },
++
++ /**
++ * GET /threads/:id — the thread and one page of top-level comments (replies nested, up to
++ * REPLY_PAGE each). ?after=<last comment id>&sort=old|new&limit=; ?parent=<id> pages one
++ * comment's replies instead.
++ */
++ async get(v, id, q = {}) {
++ const t = visibleThread(v, id);
++ const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
++ const after = cursorId(q.after);
++ let rows, hasMore;
++ if (q.parent != null && q.parent !== '') {
++ const parent = cursorId(q.parent);
++ const p = parent != null ? store.getComment(db, parent) : null;
++ if (!p || p.thread_id !== t.id || p.parent_id) fail(404, 'comment.not_found', 'Comment not found');
++ ({ rows, hasMore } = store.listReplies(db, parent, { after, limit }));
++ } else {
++ ({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
++ }
++ const comments = await shapeComments(rows, v);
++ return {
++ thread: shapeThread(t, v),
++ comments,
++ next_cursor: hasMore && rows.length ? String(rows[rows.length - 1].id) : null,
++ viewer: {
++ signed_in: !!(v && v.subject),
++ can_comment: t.visibility === 'public' || moderator(v),
++ can_vote: !!(v && v.subject) && t.visibility === 'public',
++ can_moderate: moderator(v),
++ },
++ };
++ },
++
++ /** POST /threads/:id/comments { message, parent_id?, anon_name? } */
++ async add(v, id, body = {}) {
++ const t = visibleThread(v, id);
++ if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
++ const message = String(body.message == null ? '' : body.message).replace(/\u0000/g, '').trim();
++ if (!message) fail(400, 'comment.empty', 'Comment cannot be empty');
++ if (message.length > MAX_MESSAGE) fail(400, 'comment.too_long', `Comment must be under ${MAX_MESSAGE} characters`);
++
++ let parentId = null;
++ if (body.parent_id != null && body.parent_id !== '') {
++ const parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;
++ if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');
++ // One level of nesting: a reply to a reply joins the top-level comment's replies.
++ parentId = parent.parent_id || parent.id;
++ }
++
++ const origin = v.origin === 'ai' ? 'ai' : 'user';
++ const author = origin === 'ai' ? null : (v.subject || null);
++ let anonName = null;
++ if (!author && origin !== 'ai') {
++ anonName = String(body.anon_name || '').trim().slice(0, 32).replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
++ }
++ const key = author ? `s:${author}` : null;
++ commentLimiter.check(key, message);
++ const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
++ commentLimiter.record(key, message);
++ return { comment: (await shapeComments([c], v))[0] };
++ },
++
++ /** DELETE /comments/:id — the author or a moderator. */
++ remove(v, commentId) {
++ const { comment: c } = visibleComment(v, commentId);
++ if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');
++ const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
++ if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
++ store.softDeleteComment(db, c.id, v.subject || v.service || null);
++ return { ok: true, id: c.id };
++ },
++
++ /** POST /comments/:id/votes { value: 1 | -1 | 0 } */
++ vote(v, commentId, body = {}) {
++ const value = parseVote(body.value);
++ if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
++ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to vote');
++ const { comment: c, thread: t } = visibleComment(v, commentId);
++ if (t.visibility !== 'public') fail(403, 'thread.locked', 'This comment thread is locked');
++ voteLimiter.check(`s:${v.subject}`);
++ const out = applyVote(db, 'comment', c.id, v.subject, value);
++ voteLimiter.record(`s:${v.subject}`);
++ return { comment_id: c.id, ...out };
++ },
++
++ /** PUT /threads/:id/visibility { visibility } — moderators. */
++ setVisibility(v, id, body = {}) {
++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
++ const visibility = body.visibility;
++ if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
++ const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
++ if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
++ return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };
++ },
++ };
++}
++
++module.exports = { createCommentService, BROWSER_REF_TYPES, VISIBILITIES };
ddiff --git a/server/comments/store.js b/server/comments/store.js
nnew file mode 100644
iindex 0000000..1ac124b
---- /dev/null
++++ b/server/comments/store.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Comment store — pure SQL over comment_threads / comments (server/db.js). No HTTP, identity or
++ * policy: service.js decides who may do what. Votes live in server/votes.js.
++ *
++ * A thread belongs to one foreign entity (EntityRef service/type/id) and is created on first
++ * resolve. Comments nest one level: a reply's parent is always a top-level comment. Deletes are
++ * soft (message scrubbed); a deleted top-level comment that still has replies stays as a
++ * tombstone so the replies keep their context.
++ */
++
++function getThread(db, id) {
++ return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
++}
++
++function getThreadByRef(db, ref) {
++ return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
++ .get(ref.service, ref.type, String(ref.id)) || null;
++}
++
++/**
++ * Get-or-create the thread of an entity. Idempotent and race-safe: the UNIQUE(service, type, id)
++ * constraint decides, INSERT … ON CONFLICT DO NOTHING never fails, and the row is read back.
++ * A label (from a service) refreshes the cached one. → { thread, created }
++ */
++function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
++ return db.transaction(() => {
++ const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)
++ .run(ref.service, ref.type, String(ref.id), label, createdBy);
++ if (!info.changes && label) {
++ db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
++ .run(label, ref.service, ref.type, String(ref.id), label);
++ }
++ return { thread: getThreadByRef(db, ref), created: info.changes > 0 };
++ })();
++}
++
++function setThreadVisibility(db, id, visibility) {
++ db.prepare('UPDATE comment_threads SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(visibility, id);
++ return getThread(db, id);
++}
++
++function getComment(db, id) {
++ return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
++}
++
++/** Insert a comment and keep the thread's and parent's counters in step. */
++function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
++ return db.transaction(() => {
++ const info = db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(thread_id, parent_id, author_subject, anon_name, origin, message);
++ db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
++ if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
++ return getComment(db, info.lastInsertRowid);
++ })();
++}
++
++/** Soft delete; counters recomputed from the rows. → changes */
++function softDeleteComment(db, id, deletedBy = null) {
++ return db.transaction(() => {
++ const c = getComment(db, id);
++ if (!c || c.deleted_at) return 0;
++ db.prepare("UPDATE comments SET deleted_at = CURRENT_TIMESTAMP, deleted_by = ?, message = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(deletedBy, id);
++ db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(c.thread_id, c.thread_id);
++ if (c.parent_id) db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
++ return 1;
++ })();
++}
++
++/**
++ * One page of a thread's top-level comments with up to `replyLimit` replies each.
++ * sort 'old' (default): oldest first, cursor = the last id seen (next page: id > after)
++ * sort 'new': newest first (next page: id < after)
++ * Deleted top-level comments appear only while they still have replies (tombstones).
++ * → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
++ */
++function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
++ const newest = sort === 'new';
++ const params = [threadId];
++ let cursor = '';
++ if (after != null) { cursor = newest ? 'AND id < ?' : 'AND id > ?'; params.push(after); }
++ const rows = db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}
++ ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...params, limit + 1);
++ const hasMore = rows.length > limit;
++ if (hasMore) rows.pop();
++ const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
++ for (const r of rows) r.replies = replies.all(r.id, replyLimit);
++ return { rows, hasMore };
++}
++
++/** Replies of one comment after a cursor (for comments with more replies than the first page shows). */
++function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
++ const rows = db.prepare(`SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ${after != null ? 'AND id > ?' : ''} ORDER BY id ASC LIMIT ?`)
++ .all(...(after != null ? [parentId, after] : [parentId]), limit + 1);
++ const hasMore = rows.length > limit;
++ if (hasMore) rows.pop();
++ return { rows, hasMore };
++}
++
++module.exports = { getThread, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
ddiff --git a/server/config.js b/server/config.js
iindex bf8ab33..05274ac 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -51,6 +51,21 @@ module.exports = {
// 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
// 'community' — this site's own database is the authority (server/pastes/api.js).
pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
-- // SQLite file for Community's own data (pastes once authority=community).
++ // SQLite file for Community's own data: comments, the forum, Pulse, the relay's bookkeeping,
++ // and pastes once authority=community.
dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',
++
++ // Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
++ // with a Bearer Network JWT. No cookies cross origins.
++ apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')
++ .split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
++
++ // Discord relay (outbound: new threads → a Discord webhook per mapped space). Off by default.
++ // Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.
++ discordRelay: {
++ enabled: /^(1|true|yes|on)$/i.test(process.env.DISCORD_RELAY_ENABLED || ''),
++ pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,
++ backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,
++ maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,
++ },
};
ddiff --git a/server/db.js b/server/db.js
iindex a08edcc..3912e39 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -4,7 +4,8 @@
* Community's own database (SQLite, better-sqlite3).
*
* Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
-- * history, likes and comments live here. People are referenced by Network subject ids
++ * history, likes and comments live here. Typed comment threads, the forum (spaces, threads,
++ * posts), the Discord relay's bookkeeping and Pulse live here in every mode. People are referenced by Network subject ids
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* of what the Network says about them.
*
@@@ -122,6 +123,172 @@ CREATE TABLE IF NOT EXISTS legacy_id_map (
PRIMARY KEY (source_system, source_type, source_id)
);
++-- ── Typed comment threads (server/comments) ──────────────────
++-- One thread per foreign entity (an EntityRef: service + type + id). Other products embed these
++-- instead of owning comment tables. Paste comments stay in paste_comments.
++CREATE TABLE IF NOT EXISTS comment_threads (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ ref_service TEXT NOT NULL,
++ ref_type TEXT NOT NULL,
++ ref_id TEXT NOT NULL,
++ ref_label TEXT, -- cached display label from a service; never authoritative
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),
++ comment_count INTEGER NOT NULL DEFAULT 0,
++ created_by TEXT, -- subject or service principal that first resolved it
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE (ref_service, ref_type, ref_id)
++);
++
++CREATE TABLE IF NOT EXISTS comments (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ thread_id INTEGER NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
++ parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,
++ author_subject TEXT,
++ anon_name TEXT,
++ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),
++ message TEXT NOT NULL,
++ score INTEGER NOT NULL DEFAULT 0,
++ upvotes INTEGER NOT NULL DEFAULT 0,
++ downvotes INTEGER NOT NULL DEFAULT 0,
++ reply_count INTEGER NOT NULL DEFAULT 0,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ deleted_at DATETIME,
++ deleted_by TEXT
++);
++CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);
++CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);
++
++CREATE TABLE IF NOT EXISTS comment_votes (
++ comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
++ subject_id TEXT NOT NULL,
++ value INTEGER NOT NULL CHECK(value IN (-1, 1)),
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (comment_id, subject_id)
++);
++
++-- ── Forum: spaces / threads / posts (server/forum) ───────────
++-- visibility: public (anyone reads, people post), members (signed-in people only), staff.
++CREATE TABLE IF NOT EXISTS spaces (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ slug TEXT UNIQUE NOT NULL,
++ name TEXT NOT NULL,
++ description TEXT,
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),
++ created_by TEXT,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
++);
++INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by) VALUES
++ ('general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system'),
++ ('feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site.', 'public', 'system'),
++ ('showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system');
++
++-- origin: who wrote it — a person (user), AI output (never attributed to a person), a relay
++-- from another platform (discord: never relayed back out), or the site itself (system).
++CREATE TABLE IF NOT EXISTS threads (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ slug TEXT NOT NULL,
++ title TEXT NOT NULL,
++ author_subject TEXT,
++ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
++ pinned INTEGER NOT NULL DEFAULT 0,
++ locked INTEGER NOT NULL DEFAULT 0,
++ score INTEGER NOT NULL DEFAULT 0,
++ reply_count INTEGER NOT NULL DEFAULT 0,
++ last_activity_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ deleted_at DATETIME,
++ UNIQUE (space_id, slug)
++);
++CREATE INDEX IF NOT EXISTS idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;
++CREATE INDEX IF NOT EXISTS idx_threads_author ON threads(author_subject, created_at DESC);
++
++CREATE TABLE IF NOT EXISTS posts (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ author_subject TEXT,
++ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
++ is_opening INTEGER NOT NULL DEFAULT 0,
++ body_markdown TEXT NOT NULL,
++ revision INTEGER NOT NULL DEFAULT 1,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ deleted_at DATETIME
++);
++CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, id);
++CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_subject, created_at DESC);
++
++CREATE TABLE IF NOT EXISTS post_versions (
++ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
++ revision INTEGER NOT NULL,
++ body_markdown TEXT NOT NULL,
++ edited_by TEXT,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (post_id, revision)
++);
++
++CREATE TABLE IF NOT EXISTS thread_votes (
++ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ subject_id TEXT NOT NULL,
++ value INTEGER NOT NULL CHECK(value IN (-1, 1)),
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (thread_id, subject_id)
++);
++
++-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
++-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
++-- live in the database.
++CREATE TABLE IF NOT EXISTS relay_mappings (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ direction TEXT NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),
++ webhook_url_ref TEXT NOT NULL,
++ enabled INTEGER NOT NULL DEFAULT 1,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE (space_id, direction, webhook_url_ref)
++);
++
++-- One delivery per (thread, mapping): the dedupe key. status pending → delivered | failed.
++CREATE TABLE IF NOT EXISTS relay_deliveries (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
++ dedupe_key TEXT UNIQUE NOT NULL,
++ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')),
++ attempts INTEGER NOT NULL DEFAULT 0,
++ next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ last_status INTEGER,
++ last_error TEXT,
++ delivered_at DATETIME,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE (thread_id, mapping_id)
++);
++CREATE INDEX IF NOT EXISTS idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);
++
++-- ── Pulse (server/pulse): public activity across the network, with provenance ──
++-- Only public things enter; AI items carry origin 'ai' and never an actor.
++CREATE TABLE IF NOT EXISTS pulse_items (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ source_service TEXT NOT NULL,
++ source_type TEXT NOT NULL,
++ source_id TEXT NOT NULL,
++ title TEXT NOT NULL,
++ url TEXT NOT NULL,
++ actor_subject TEXT,
++ origin TEXT NOT NULL CHECK(origin IN ('user', 'ai', 'system')),
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),
++ occurred_at DATETIME NOT NULL,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE (source_service, source_type, source_id)
++);
++CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);
++
CREATE TABLE IF NOT EXISTS import_hold (
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
@@@ -132,6 +299,22 @@ CREATE TABLE IF NOT EXISTS import_hold (
);
`;
++/**
++ * SQL functions every connection needs (worker connections in tests too).
++ *
++ * ov_hot(score, age_hours) — the forum's "hot" rank. Deterministic: the same score and age
++ * always give the same number, and callers pass one `now` for a whole page, so a page is
++ * ranked against a single instant:
++ *
++ * hot = (score + 1) / (age_hours + 2) ^ 1.5
++ *
++ * +1 lets a thread nobody voted on yet still rank by age (instead of all tying at 0); +2 hours
++ * keeps a brand-new thread from dividing by ~0; the 1.5 exponent is the Hacker News gravity.
++ */
++function registerFunctions(db) {
++ db.function('ov_hot', { deterministic: true }, (score, ageHours) => (Number(score || 0) + 1) / Math.pow(Math.max(Number(ageHours) || 0, 0) + 2, 1.5));
++}
++
/** Open (creating the directory and schema if needed). ':memory:' works for tests. */
function openDb(file) {
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
@@@ -140,6 +323,7 @@ function openDb(file) {
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
++ registerFunctions(db);
return db;
}
@@@ -150,4 +334,4 @@ function getDb() {
return _shared;
}
--module.exports = { openDb, getDb, SCHEMA };
++module.exports = { openDb, getDb, registerFunctions, SCHEMA };
ddiff --git a/server/forum/api.js b/server/forum/api.js
nnew file mode 100644
iindex 0000000..6177874
---- /dev/null
++++ b/server/forum/api.js
@@@ -0,0 +1,64 @@
++'use strict';
++
++/**
++ * /api/v1/spaces and /api/v1/posts — the forum's JSON API (the pages in render/forum.js are the
++ * no-JS equivalent; service.js holds the rules for both).
++ *
++ * GET /spaces spaces the caller can open
++ * GET /spaces/:space one space
++ * GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)
++ * POST /spaces/:space/threads { title, body } new thread (body is Markdown)
++ * GET /spaces/:space/threads/:slug?page= thread + posts
++ * DELETE /spaces/:space/threads/:slug author or moderator
++ * POST /spaces/:space/threads/:slug/posts { body } reply
++ * POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
++ * PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators
++ * PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
++ *
++ * Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
++ * moderate with community.comment.moderate. Errors are problem+json.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
++
++const POST = 'community.post.create';
++const MOD = 'community.comment.moderate';
++
++function createSpacesApi({ forum, viewers }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++ const write = serviceCap(POST);
++ const writeOrMod = serviceAnyCap([POST, MOD]);
++ const p = (req) => req.params;
++
++ router.get('/', run((req) => forum.listSpaces(req.viewer)));
++ router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
++ router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
++ router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
++ router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
++ router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
++ router.post('/:space/threads/:slug/posts', write, jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
++ router.post('/:space/threads/:slug/votes', write, jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++function createPostsApi({ forum, viewers }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++ const writeOrMod = serviceAnyCap([POST, MOD]);
++
++ router.put('/:id', writeOrMod, jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
++ router.delete('/:id', writeOrMod, run((req) => forum.deletePost(req.viewer, req.params.id)));
++ router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createSpacesApi, createPostsApi };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
nnew file mode 100644
iindex 0000000..2f578d2
---- /dev/null
++++ b/server/forum/routes.js
@@@ -0,0 +1,146 @@
++'use strict';
++
++/**
++ * Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
++ *
++ * GET /s spaces
++ * GET /s/feed.xml RSS: latest threads in public spaces
++ * GET /s/:space threads (?sort=hot|new|top&page=)
++ * GET /s/:space/feed.xml RSS: latest threads of one public space
++ * GET /s/:space/new POST /s/:space/new start a thread
++ * GET /s/:space/t/:slug a thread (?page=)
++ * POST /s/:space/t/:slug/reply | /vote | /state | /delete no-JS forms
++ *
++ * Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the
++ * visitor); an Origin header from somewhere else is refused as well.
++ */
++const express = require('express');
++const seo = require('../seo');
++const pages = require('../render/pages');
++const forumPages = require('../render/forum');
++const { ApiError } = require('../http/v1');
++
++function createForumRoutes({ forum, viewers, config }) {
++ const router = express.Router();
++ const withViewer = viewers.middleware({ services: false });
++ const form = express.urlencoded({ extended: false, limit: '256kb' });
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++ const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
++
++ /** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
++ function failPage(req, res, err, next) {
++ if (!(err instanceof ApiError)) return next(err);
++ if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete)$/, ''));
++ const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };
++ return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);
++ }
++
++ function sameOrigin(req, res, next) {
++ const origin = req.get('origin');
++ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
++ next();
++ }
++
++ router.get('/s', withViewer, wrap(async (req, res, next) => {
++ try { html(res, forumPages.spacesPage({ ...forum.listSpaces(req.viewer), user: req.user })); } catch (err) { failPage(req, res, err, next); }
++ }));
++
++ router.get('/s/feed.xml', (_req, res) => {
++ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: forum.recentPublic({ limit: 30 }) }));
++ });
++
++ router.get('/s/:space/feed.xml', (req, res) => {
++ const space = forum.publicSpaces().find((s) => s.slug === req.params.space);
++ if (!space) return res.status(404).type('text/plain').send('Not found');
++ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: forum.recentPublic({ limit: 30, space: space.slug }) }));
++ });
++
++ router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
++ try {
++ const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page });
++ if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
++ html(res, forumPages.spacePage({ ...out, user: req.user }));
++ } catch (err) { failPage(req, res, err, next); }
++ }));
++
++ router.get('/s/:space/new', withViewer, (req, res, next) => {
++ try {
++ const { space } = forum.space(req.viewer, req.params.space);
++ html(res, forumPages.newThreadPage({ space, user: req.user }));
++ } catch (err) { failPage(req, res, err, next); }
++ });
++
++ router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
++ try {
++ const out = await forum.createThread(req.viewer, req.params.space, values);
++ seo.resetCaches();
++ res.redirect(303, out.thread.url);
++ } catch (err) {
++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
++ try {
++ const { space } = forum.space(req.viewer, req.params.space);
++ html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);
++ } catch (e) { failPage(req, res, e, next); }
++ }
++ }));
++
++ async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
++ const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
++ if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
++ html(res, forumPages.threadPage({ ...out, perPage: out.per_page, user: req.user, error, draft }), status);
++ }
++
++ router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
++ try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
++ }));
++
++ router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ const draft = String((req.body || {}).body || '').slice(0, 40_000);
++ try {
++ const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft });
++ res.redirect(303, out.url);
++ } catch (err) {
++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
++ try {
++ // The reply form lives on the thread's last page; show it there with the error and the draft.
++ const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});
++ req.query.page = String(probe.pages);
++ await renderThread(req, res, { status: err.status, error: err.message, draft });
++ } catch (e) { failPage(req, res, e, next); }
++ }
++ }));
++
++ const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
++
++ router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, (req, res, next) => {
++ try {
++ forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
++ res.redirect(303, back(req));
++ } catch (err) { failPage(req, res, err, next); }
++ });
++
++ router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ const b = req.body || {};
++ const flags = {};
++ if (b.pinned !== undefined) flags.pinned = b.pinned === '1';
++ if (b.locked !== undefined) flags.locked = b.locked === '1';
++ try {
++ await forum.moderateThread(req.viewer, req.params.space, req.params.slug, flags);
++ res.redirect(303, back(req));
++ } catch (err) { failPage(req, res, err, next); }
++ }));
++
++ router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {
++ try {
++ forum.deleteThread(req.viewer, req.params.space, req.params.slug);
++ seo.resetCaches();
++ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
++ } catch (err) { failPage(req, res, err, next); }
++ });
++
++ return router;
++}
++
++module.exports = { createForumRoutes };
ddiff --git a/server/forum/service.js b/server/forum/service.js
nnew file mode 100644
iindex 0000000..365ad10
---- /dev/null
++++ b/server/forum/service.js
@@@ -0,0 +1,306 @@
++'use strict';
++
++/**
++ * Forum rules — spaces, threads and posts on top of store.js. Used by the JSON API (api.js) and
++ * the server-rendered pages (render/forum.js) alike, so both enforce exactly the same things.
++ *
++ * Spaces: public — anyone reads; people post
++ * members — signed-in people read and post (every Network account is a member for now)
++ * staff — discussion moderators only; looks missing to everyone else
++ * Writers: a person (browser JWT subject, or a service with community.post.create naming them in
++ * X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'
++ * and no author, labelled as AI). Anonymous visitors read; they do not post.
++ * Locked threads take no replies or votes except from moderators. Authors edit and delete their
++ * own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
++ * Edits keep every revision in post_versions.
++ *
++ * Side effects of a new thread/post: a Pulse item (public spaces) and, for threads, a Discord
++ * relay delivery per mapping (relay/discord.js; off unless DISCORD_RELAY_ENABLED).
++ */
++const store = require('./store');
++const { applyVote, myVotes, parseVote } = require('../votes');
++const { fail, isoTime } = require('../http/v1');
++const { createAuthors } = require('../identity/authors');
++const { createPersonLimiter } = require('../limits');
++const { discussionModerator } = require('../identity/capabilities');
++const { renderMarkdown } = require('../render/markdown');
++
++const THREADS_PER_PAGE = 25;
++const POSTS_PER_PAGE = 50;
++const TITLE_MIN = 3, TITLE_MAX = 200;
++const BODY_MAX = 40_000;
++const THREADS_PER_DAY = 20;
++
++function createForumService({ db, network = null, pulse = null, relay = null, limits = {} } = {}) {
++ const authors = createAuthors({ db, network });
++ const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
++ const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
++ const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
++ const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
++ const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
++
++ const moderator = (v) => discussionModerator(v);
++ const person = (v) => !!(v && v.subject);
++
++ // ── access ───────────────────────────────────────────────
++ function canRead(v, space) {
++ if (space.visibility === 'public') return true;
++ if (space.visibility === 'members') return person(v) || moderator(v);
++ return moderator(v);
++ }
++
++ /** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
++ function spaceFor(v, slug) {
++ const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;
++ if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');
++ if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');
++ return space;
++ }
++
++ function threadFor(v, spaceSlug, threadSlug) {
++ const space = spaceFor(v, spaceSlug);
++ const thread = store.getThreadBySlug(db, space.id, threadSlug);
++ if (!thread) fail(404, 'thread.not_found', 'No such thread');
++ return { space, thread };
++ }
++
++ function postFor(v, postId) {
++ const post = /^\d{1,15}$/.test(String(postId)) ? store.getPost(db, Number(postId)) : null;
++ const thread = post ? store.getThread(db, post.thread_id) : null;
++ const space = thread ? store.getSpaceById(db, thread.space_id) : null;
++ if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
++ spaceFor(v, space.slug);
++ return { post, thread, space };
++ }
++
++ /** Who a write is attributed to. → { author, origin, key } (key: the rate-limit bucket) */
++ function writer(v) {
++ if (v && v.kind === 'service' && v.origin === 'ai') return { author: null, origin: 'ai', key: null };
++ if (person(v)) return { author: v.subject, origin: 'user', key: `s:${v.subject}` };
++ return fail(401, 'auth.required', 'Sign in with your OpenVibe account to post');
++ }
++
++ function mayPostIn(v, space) {
++ if (space.visibility === 'staff' && !moderator(v)) fail(403, 'space.staff_only', 'Only staff post here');
++ }
++
++ const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();
++ function cleanBody(b) {
++ const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');
++ if (!body.trim()) fail(400, 'post.empty', 'Write something first');
++ if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);
++ return body;
++ }
++
++ // ── shapes ───────────────────────────────────────────────
++ const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
++
++ function shapeThread(t, space, v, projections, votes) {
++ return {
++ id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
++ author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
++ pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
++ last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
++ my_vote: (votes && votes.get(t.id)) || 0,
++ };
++ }
++
++ function shapePost(p, v, projections) {
++ const deleted = !!p.deleted_at;
++ const mine = person(v) && p.author_subject === v.subject;
++ return {
++ id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
++ author: deleted ? null : authors.author(p.author_subject, p.origin, projections),
++ body_markdown: deleted ? null : p.body_markdown,
++ body_html: deleted ? null : renderMarkdown(p.body_markdown),
++ revision: p.revision, deleted,
++ created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),
++ can_edit: !deleted && (mine || moderator(v)),
++ };
++ }
++
++ async function shapeThreads(rows, spaceOf, v) {
++ const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));
++ const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
++ return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
++ }
++
++ function shapeSpace(s) {
++ return {
++ slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
++ thread_count: s.thread_count != null ? s.thread_count : undefined,
++ last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
++ };
++ }
++
++ function removeThread(v, thread) {
++ if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
++ store.softDeleteThread(db, thread.id);
++ if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });
++ return { ok: true, id: thread.id, deleted: 'thread' };
++ }
++
++ // ═════════════════════════════════════════════════════════
++ return {
++ SORTS: store.SORTS,
++ THREADS_PER_PAGE,
++ POSTS_PER_PAGE,
++
++ isModerator: moderator,
++
++ /** Spaces this viewer can open, with thread counts. */
++ listSpaces(v) {
++ const vis = ['public'];
++ if (person(v) || moderator(v)) vis.push('members');
++ if (moderator(v)) vis.push('staff');
++ return { spaces: store.listSpaces(db, vis).map(shapeSpace) };
++ },
++
++ space(v, slug) { return { space: shapeSpace(spaceFor(v, slug)) }; },
++
++ /** A page of threads. ?sort=hot|new|top&page= */
++ async listThreads(v, spaceSlug, q = {}) {
++ const space = spaceFor(v, spaceSlug);
++ const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
++ const page = Math.max(parseInt(q.page, 10) || 1, 1);
++ const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
++ const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });
++ return {
++ space: shapeSpace(space), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
++ threads: await shapeThreads(rows, () => space, v),
++ };
++ },
++
++ /** A thread with a page of its posts. ?page= */
++ async getThread(v, spaceSlug, threadSlug, q = {}) {
++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
++ const page = Math.max(parseInt(q.page, 10) || 1, 1);
++ const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
++ const projections = await authors.projectionsFor([thread.author_subject, ...rows.map((p) => p.author_subject)]);
++ const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
++ return {
++ space: shapeSpace(space),
++ thread: shapeThread(thread, space, v, projections, votes),
++ posts: rows.map((p) => shapePost(p, v, projections)),
++ page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
++ viewer: {
++ signed_in: person(v),
++ can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),
++ can_vote: person(v) && !thread.locked,
++ can_moderate: moderator(v),
++ can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
++ },
++ };
++ },
++
++ /** New thread { title, body } → { thread, post } */
++ async createThread(v, spaceSlug, body = {}) {
++ const space = spaceFor(v, spaceSlug);
++ const w = writer(v);
++ mayPostIn(v, space);
++ const title = cleanTitle(body.title);
++ if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
++ const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ if (w.key) {
++ threadLimiter.check(w.key, title);
++ if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
++ }
++ const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text });
++ threadLimiter.record(w.key, title);
++ if (pulse) hook(() => pulse.threadCreated(thread, space));
++ if (relay) hook(() => relay.enqueueThread(thread, space));
++ const projections = await authors.projectionsFor([thread.author_subject]);
++ return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections) };
++ },
++
++ /** Reply { body } → { post } */
++ async reply(v, spaceSlug, threadSlug, body = {}) {
++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
++ const w = writer(v);
++ mayPostIn(v, space);
++ if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
++ const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ postLimiter.check(w.key, text);
++ const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
++ postLimiter.record(w.key, text);
++ if (pulse) hook(() => pulse.postCreated(post, thread, space));
++ const projections = await authors.projectionsFor([post.author_subject]);
++ // Where the new post lands: its page in the thread (posts are numbered in id order).
++ const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
++ const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
++ return { post: shapePost(post, v, projections), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
++ },
++
++ /** Edit { body } — the author (not on a locked thread) or a moderator. */
++ async editPost(v, postId, body = {}) {
++ const { post, thread } = postFor(v, postId);
++ const mine = person(v) && post.author_subject === v.subject;
++ if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
++ if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
++ const next = store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
++ const projections = await authors.projectionsFor([next.author_subject]);
++ return { post: shapePost(next, v, projections) };
++ },
++
++ /** Edit history — the author or a moderator. */
++ postVersions(v, postId) {
++ const { post } = postFor(v, postId);
++ if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
++ const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
++ return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
++ },
++
++ /** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
++ deletePost(v, postId) {
++ const { post, thread } = postFor(v, postId);
++ if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
++ if (post.is_opening) return removeThread(v, thread);
++ store.softDeletePost(db, post.id);
++ if (pulse) hook(() => pulse.postGone(post.id));
++ return { ok: true, id: post.id };
++ },
++
++ /** Delete a thread — its author or a moderator. */
++ deleteThread(v, spaceSlug, threadSlug) {
++ const { thread } = threadFor(v, spaceSlug, threadSlug);
++ return removeThread(v, thread);
++ },
++
++ /** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
++ voteThread(v, spaceSlug, threadSlug, body = {}) {
++ const value = parseVote(body.value);
++ if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
++ if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
++ const { thread } = threadFor(v, spaceSlug, threadSlug);
++ if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
++ voteLimiter.check(`s:${v.subject}`);
++ const out = applyVote(db, 'thread', thread.id, v.subject, value);
++ voteLimiter.record(`s:${v.subject}`);
++ return { thread_id: thread.id, ...out };
++ },
++
++ /** Pin / lock { pinned?, locked? } — moderators. */
++ async moderateThread(v, spaceSlug, threadSlug, body = {}) {
++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
++ const flags = {};
++ if (body.pinned !== undefined) flags.pinned = !!body.pinned;
++ if (body.locked !== undefined) flags.locked = !!body.locked;
++ if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
++ const next = store.setThreadFlags(db, thread.id, flags);
++ const projections = await authors.projectionsFor([next.author_subject]);
++ return { thread: shapeThread(next, space, v, projections, null) };
++ },
++
++ /** Latest threads in public spaces, with their opening post (sitemap, feeds). */
++ recentPublic({ limit = 50, space = null } = {}) {
++ const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
++ const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');
++ return rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));
++ },
++
++ publicSpaces() { return store.listSpaces(db, ['public']).map(shapeSpace); },
++ };
++}
++
++module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE };
ddiff --git a/server/forum/store.js b/server/forum/store.js
nnew file mode 100644
iindex 0000000..e06d714
---- /dev/null
++++ b/server/forum/store.js
@@@ -0,0 +1,182 @@
++'use strict';
++
++/**
++ * Forum store — pure SQL over spaces / threads / posts / post_versions (server/db.js). Policy
++ * lives in service.js, votes in server/votes.js.
++ *
++ * A thread's opening post is its first post (is_opening = 1); reply_count counts the other
++ * live posts. Deletes are soft: a deleted post keeps its place (a tombstone) so the posts
++ * after it keep their numbering; a deleted thread disappears from every listing.
++ */
++
++const SORTS = ['hot', 'new', 'top'];
++
++// ── Spaces ───────────────────────────────────────────────────
++
++function listSpaces(db, visibilities) {
++ return db.prepare(`SELECT s.*,
++ (SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
++ (SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at
++ FROM spaces s WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})
++ ORDER BY s.id ASC`).all(...visibilities);
++}
++
++function getSpace(db, slug) {
++ return db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;
++}
++
++function getSpaceById(db, id) {
++ return db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;
++}
++
++// ── Threads ──────────────────────────────────────────────────
++
++/** URL slug from a title: lowercase ascii words joined by '-', at most 60 characters. */
++function slugify(title) {
++ const s = String(title || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '');
++ return s || 'thread';
++}
++
++function uniqueSlug(db, spaceId, base) {
++ const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
++ if (!taken.get(spaceId, base)) return base;
++ for (let n = 2; n < 10_000; n++) {
++ const candidate = `${base.slice(0, 54)}-${n}`;
++ if (!taken.get(spaceId, candidate)) return candidate;
++ }
++ throw new Error('Could not find a free thread slug');
++}
++
++function getThread(db, id) {
++ return db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;
++}
++
++function getThreadBySlug(db, spaceId, slug) {
++ return db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;
++}
++
++/** New thread + its opening post, in one transaction. → { thread, post } */
++function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown }) {
++ return db.transaction(() => {
++ const slug = uniqueSlug(db, space_id, slugify(title));
++ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin) VALUES (?, ?, ?, ?, ?)')
++ .run(space_id, slug, title, author_subject, origin);
++ const threadId = info.lastInsertRowid;
++ const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
++ .run(threadId, author_subject, origin, body_markdown);
++ return { thread: getThread(db, threadId), post: getPost(db, p.lastInsertRowid) };
++ })();
++}
++
++/**
++ * One page of a space's threads. Pinned threads lead every sort.
++ * hot ov_hot(score, age in hours at `now`) — see server/db.js for the formula
++ * new newest first
++ * top highest score first
++ * → { rows, total }
++ */
++function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date() } = {}) {
++ const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
++ const order = {
++ hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
++ new: 'pinned DESC, created_at DESC, id DESC',
++ top: 'pinned DESC, score DESC, created_at DESC, id DESC',
++ }[SORTS.includes(sort) ? sort : 'hot'];
++ const rows = db.prepare(`SELECT * FROM threads WHERE space_id = @space AND deleted_at IS NULL ORDER BY ${order} LIMIT @limit OFFSET @offset`)
++ .all({ space: spaceId, now: nowSql, limit, offset });
++ const { total } = db.prepare('SELECT COUNT(*) AS total FROM threads WHERE space_id = ? AND deleted_at IS NULL').get(spaceId);
++ return { rows, total };
++}
++
++/** Latest threads across spaces of the given visibilities (sitemap, feeds). */
++function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
++ return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
++ WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)
++ ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
++}
++
++function setThreadFlags(db, id, { pinned, locked }) {
++ const sets = [], params = [];
++ if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
++ if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }
++ if (sets.length) db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
++ return getThread(db, id);
++}
++
++function softDeleteThread(db, id) {
++ return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;
++}
++
++/** Threads a subject opened since `sinceSql` (SQLite modifier, e.g. '-1 day'); deleted ones count. */
++function countThreadsSince(db, subject, sinceSql) {
++ return db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
++}
++
++// ── Posts ────────────────────────────────────────────────────
++
++function getPost(db, id) {
++ return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;
++}
++
++/** A reply; bumps the thread's reply_count and last activity. */
++function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown }) {
++ return db.transaction(() => {
++ const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown) VALUES (?, ?, ?, ?)')
++ .run(thread_id, author_subject, origin, body_markdown);
++ db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
++ return getPost(db, info.lastInsertRowid);
++ })();
++}
++
++/** Page of a thread's posts in order (tombstones included). → { rows, total } */
++function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
++ const rows = db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);
++ const { total } = db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);
++ return { rows, total };
++}
++
++/**
++ * Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original
++ * as revision 1, so the history is complete without copying posts nobody edits (as pastes do).
++ */
++function editPost(db, id, body, editedBy = null) {
++ return db.transaction(() => {
++ const cur = getPost(db, id);
++ if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;
++ if (!db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {
++ db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')
++ .run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);
++ }
++ db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(body, id);
++ const next = getPost(db, id);
++ db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);
++ return next;
++ })();
++}
++
++function listPostVersions(db, postId) {
++ return db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);
++}
++
++/** Soft delete (body scrubbed); the thread's reply_count is recounted. */
++function softDeletePost(db, id) {
++ return db.transaction(() => {
++ const p = getPost(db, id);
++ if (!p || p.deleted_at) return 0;
++ db.prepare("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP, body_markdown = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
++ db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);
++ return 1;
++ })();
++}
++
++function countPostsSince(db, subject, sinceSql) {
++ return db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
++}
++
++module.exports = {
++ SORTS, slugify,
++ listSpaces, getSpace, getSpaceById,
++ getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, softDeleteThread, countThreadsSince,
++ getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
++};
ddiff --git a/server/http/v1.js b/server/http/v1.js
nnew file mode 100644
iindex 0000000..824f334
---- /dev/null
++++ b/server/http/v1.js
@@@ -0,0 +1,111 @@
++'use strict';
++
++/**
++ * Shared pieces of the /api/v1/* routers (comments, spaces, pulse, relay): errors as RFC 9457
++ * problems (contracts errors.problem@1, which keeps the legacy { error } field), capability
++ * guards for service tokens, JSON bodies, cursors and CORS for the embeddable read/write APIs.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { checkCapability } = require('../identity/capabilities');
++
++/** A refusal with a stable problem code (e.g. 404 'thread.not_found'). */
++class ApiError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.name = 'ApiError';
++ this.status = status;
++ this.code = code;
++ this.extra = extra || null;
++ }
++}
++const fail = (status, code, detail, extra) => { throw new ApiError(status, code, detail, extra); };
++
++/** Wrap a handler: its return value is the JSON body; ApiErrors become problems. */
++function run(fn, status = 200) {
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (out === undefined || res.headersSent) return;
++ res.status(typeof status === 'function' ? status(out) : status).json(out);
++ } catch (err) {
++ if (res.headersSent) return;
++ if (err instanceof ApiError) {
++ if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
++ return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
++ }
++ console.error('[API]', err && err.stack ? err.stack : err);
++ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ }
++ };
++}
++
++/** A service token must hold `cap`; browsers and anonymous callers pass (judged by identity later). */
++function serviceCap(cap) {
++ return (req, res, next) => {
++ const v = req.viewer;
++ if (!v || v.kind !== 'service') return next();
++ const c = checkCapability(v.claims, cap);
++ if (c.allowed) return next();
++ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++}
++
++/** A service token must hold at least one of `caps` (reads: a token meant for something else is no pass). */
++function serviceAnyCap(caps) {
++ return (req, res, next) => {
++ const v = req.viewer;
++ if (!v || v.kind !== 'service' || caps.some((c) => checkCapability(v.claims, c).allowed)) return next();
++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `needs one of ${caps.join(', ')}`, ctx: req.ov });
++ };
++}
++
++const jsonParser = express.json({ limit: '256kb' });
++/** JSON body parser whose syntax errors are problems too. */
++function jsonBody(req, res, next) {
++ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
++}
++
++// ── Cursors: opaque base64url of a small JSON tuple ──────────
++function encodeCursor(values) { return Buffer.from(JSON.stringify(values)).toString('base64url'); }
++function decodeCursor(cursor, arity) {
++ if (cursor == null || cursor === '') return null;
++ try {
++ const v = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
++ if (Array.isArray(v) && v.length === arity) return v;
++ } catch { /* fall through */ }
++ return fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');
++}
++
++/**
++ * CORS for the embeddable APIs: other OpenVibe sites call them from the browser with the
++ * visitor's Network JWT as a Bearer token (never with cookies — no credentials mode).
++ */
++function cors(origins) {
++ const allowed = new Set(origins);
++ return (req, res, next) => {
++ const origin = req.get('origin');
++ if (origin && allowed.has(origin)) {
++ res.set('Access-Control-Allow-Origin', origin);
++ res.set('Vary', 'Origin');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
++ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
++ res.set('Access-Control-Max-Age', '600');
++ }
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ next();
++ };
++}
++
++const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
++
++/** SQLite 'YYYY-MM-DD HH:MM:SS' (UTC) ↔ ISO. */
++function sqlTime(date = new Date()) { return new Date(date).toISOString().replace('T', ' ').slice(0, 19); }
++function isoTime(v) {
++ if (!v) return null;
++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++module.exports = { ApiError, fail, run, serviceCap, serviceAnyCap, jsonBody, encodeCursor, decodeCursor, cors, intIn, sqlTime, isoTime };
ddiff --git a/server/identity/authors.js b/server/identity/authors.js
nnew file mode 100644
iindex 0000000..fce7614
---- /dev/null
++++ b/server/identity/authors.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++/**
++ * Author display for comments, threads, posts and Pulse items: Network subjects → names and
++ * pictures from subject_projection (refreshed through the Network's resolve-batch, see
++ * network.js). AI output is labelled as AI and never attributed to a person (roadmap §33).
++ */
++const pasteStore = require('../pastes/store');
++
++const AI_DISPLAY_NAME = 'OpenVibe AI';
++
++function createAuthors({ db, network = null }) {
++ /** Map subject → projection row (bounded wait for missing ones when the Network is there). */
++ async function projectionsFor(subjects) {
++ const list = (subjects || []).filter(Boolean);
++ if (!list.length) return new Map();
++ if (!network) return pasteStore.getProjections(db, list);
++ try { return await network.projections(list); } catch { return pasteStore.getProjections(db, list); }
++ }
++
++ /**
++ * { subject, username, display_name, avatar_url, profile_color } for a person, a fixed AI
++ * label for AI output, null for anonymous writes.
++ */
++ function author(subject, origin, projections) {
++ if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };
++ if (!subject) return null;
++ const p = projections.get(subject);
++ return {
++ subject,
++ username: p ? p.username : null,
++ display_name: p ? (p.display_name || p.username) : null,
++ avatar_url: p ? p.avatar_url : null,
++ profile_color: p ? p.profile_color : null,
++ };
++ }
++
++ return { projectionsFor, author };
++}
++
++module.exports = { createAuthors, AI_DISPLAY_NAME };
ddiff --git a/server/identity/capabilities.js b/server/identity/capabilities.js
nnew file mode 100644
iindex 0000000..b64d76e
---- /dev/null
++++ b/server/identity/capabilities.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * Capability checks for service tokens, including the ids Community introduces before the
++ * contracts library knows them.
++ *
++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in
++ * its manifests yet. The ids below are proposed in docs/capabilities-proposal/ and go into a
++ * contracts release; until then a grant is decided locally with the library's own matching rule
++ * (the exact id, or a `prefix.*` grant covering it). An id the library does know always goes
++ * through the library, so the day the release lands nothing changes here.
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const PROPOSED = new Set([
++ 'community.comment.write',
++ 'community.comment.moderate',
++ 'community.pulse.write',
++ 'community.post.create',
++]);
++
++/** → { 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);
++}
++
++/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */
++function serviceHas(viewer, capabilityId) {
++ return !!(viewer && viewer.kind === 'service' && checkCapability(viewer.claims, capabilityId).allowed);
++}
++
++/**
++ * Staff for discussions (comments, forum, relay admin): an admin/global_mod browser, or a
++ * service that vouches for a staff person with X-OV-Staff: 1 and holds community.comment.moderate.
++ */
++function discussionStaff(viewer) {
++ if (!viewer) return false;
++ if (viewer.kind === 'user') return !!viewer.staff;
++ return viewer.kind === 'service' && !!viewer.vouchesStaff && serviceHas(viewer, 'community.comment.moderate');
++}
++
++/**
++ * A discussion moderator: discussion staff, or a service holding community.comment.moderate that
++ * acts as itself (no X-OV-Subject) — e.g. an owner service hiding the thread of an entity it took
++ * down. A service acting for a person moderates only when it vouches that person is staff.
++ */
++function discussionModerator(viewer) {
++ return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'community.comment.moderate'));
++}
++
++module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator, PROPOSED };
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
iindex a481d86..514fcb3 100644
---- a/server/identity/viewer.js
++++ b/server/identity/viewer.js
@@@ -13,13 +13,15 @@
* openvibe.community. It names the person it acts for in X-OV-Subject; with no subject the
* write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a
* person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
-- * X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate.
++ * X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate
++ * (pastes) or community.comment.moderate (comments and the forum).
*
* Identity never comes from a request body or 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 { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
++const { checkCapability } = require('./capabilities');
const { ids, capabilities, serviceAuth, http } = contracts;
const STAFF_ROLES = new Set(['admin', 'global_mod']);
@@@ -70,13 +72,19 @@ function createViewerResolver({ auth, config, network }) {
}
}
-- let staff = false;
++ // `staff` is paste staff (community.paste.moderate). `vouchesStaff` records the header for
++ // discussions, where it counts together with community.comment.moderate
++ // (identity/capabilities.js discussionStaff). Either grant makes the header acceptable.
++ let staff = false, vouchesStaff = false;
if (req.get('x-ov-staff') === '1') {
const c = capabilities.check(claims, 'community.paste.moderate');
-- if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs community.paste.moderate');
-- staff = true;
++ if (!c.allowed && !checkCapability(claims, 'community.comment.moderate').allowed) {
++ throw new ViewerError(403, c.code, 'X-OV-Staff needs community.paste.moderate or community.comment.moderate');
++ }
++ staff = c.allowed;
++ vouchesStaff = true;
}
-- return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff };
++ return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff, vouchesStaff };
}
async function fromUserToken(token) {
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..960c3e9
---- /dev/null
++++ b/server/limits.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * Per-person write limits for comments and the forum (in memory, per process — the same model
++ * as the paste service's comment limits). Keyed by the acting subject, so a person is limited
++ * the same whether they write from a browser or through a service naming them in X-OV-Subject.
++ *
++ * cooldownSec minimum gap between two writes
++ * perMinute writes per rolling minute
++ * duplicate refuse the same text twice in a row
++ *
++ * Anonymous writes are limited per address by the app's shared anonymous-write limiter instead.
++ */
++const { ApiError } = require('./http/v1');
++
++function createPersonLimiter({ cooldownSec = 0, perMinute = 0, duplicate = true, noun = 'posts' } = {}) {
++ const recent = new Map(); // subject → [{ at, text }] newest first
++
++ function list(key, now) {
++ const l = (recent.get(key) || []).filter((e) => now - e.at < 60_000);
++ recent.set(key, l);
++ return l;
++ }
++
++ /** Throws a 429/400 ApiError when this write would go over; call record() after it succeeds. */
++ function check(key, text = null) {
++ if (!key) return;
++ const now = Date.now();
++ const l = list(key, now);
++ if (cooldownSec > 0 && l.length && now - l[0].at < cooldownSec * 1000) {
++ const wait = Math.ceil((cooldownSec * 1000 - (now - l[0].at)) / 1000);
++ throw new ApiError(429, 'request.rate_limited', `Please wait ${wait}s before writing again`, { retry_after: wait });
++ }
++ if (perMinute > 0 && l.length >= perMinute) {
++ throw new ApiError(429, 'request.rate_limited', `Too many ${noun}. Please slow down.`, { retry_after: Math.ceil((60_000 - (now - l[l.length - 1].at)) / 1000) });
++ }
++ if (duplicate && text != null && l.length && l[0].text === text) throw new ApiError(400, 'request.duplicate', 'Duplicate — you just posted that');
++ }
++
++ function record(key, text = null) {
++ if (!key) return;
++ const l = recent.get(key) || [];
++ l.unshift({ at: Date.now(), text });
++ recent.set(key, l.slice(0, Math.max(perMinute, 1) + 1));
++ }
++
++ const sweep = setInterval(() => {
++ const now = Date.now();
++ for (const [k, l] of recent) if (!l.length || now - l[0].at > 120_000) recent.delete(k);
++ }, 10 * 60_000);
++ if (sweep.unref) sweep.unref();
++
++ return { check, record };
++}
++
++module.exports = { createPersonLimiter };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex d299a74..ed67f82 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -55,8 +55,10 @@ const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, mi
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
--function createPasteService({ db, network = null, media = null, config = {}, limits = {} } = {}) {
++function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
++ // Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
++ const tell = (fn) => { if (!pulse) return; try { fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
const visitorSecret = process.env.VIEW_HASH_SECRET
|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
@@@ -269,6 +271,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
function created(row, extra = {}) {
++ tell((p) => p.pasteCreated(row));
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
}
@@@ -433,6 +436,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
const row = store.updatePaste(db, p.id, patch, v.subject || null);
++ tell((pl) => pl.pasteChanged(row));
return { paste: await shapeOne(row, v) };
},
@@@ -442,6 +446,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const p = visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
store.softDelete(db, p.id);
++ tell((pl) => pl.pasteGone(p.slug));
return { success: true };
},
@@@ -570,6 +575,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!p) { skipped++; continue; }
if (action === 'delete') store.softDelete(db, p.id);
else store.setVisibility(db, p.id, action);
++ if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
done++;
}
})();
ddiff --git a/server/pulse/api.js b/server/pulse/api.js
nnew file mode 100644
iindex 0000000..1063d03
---- /dev/null
++++ b/server/pulse/api.js
@@@ -0,0 +1,35 @@
++'use strict';
++
++/**
++ * /api/v1/pulse — the network's public activity (service.js has the rules).
++ *
++ * GET / ?origin=user|ai|system&after=<cursor>&limit= anyone
++ * POST /items { ref, title, url, origin?, occurred_at?, visibility? }
++ * DELETE /items/:service/:type/:id retract one of the calling service's items
++ *
++ * Writes need a service token with community.pulse.write; X-OV-Subject names the person an item
++ * is about (origin user), X-OV-Origin: ai marks AI output. Errors are problem+json.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run, serviceCap, jsonBody } = require('../http/v1');
++
++const WRITE = 'community.pulse.write';
++
++function createPulseApi({ pulse, viewers }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++
++ const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()
++ : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));
++
++ router.get('/', run((req) => pulse.list(req.query)));
++ router.post('/items', servicesOnly, serviceCap(WRITE), jsonBody, run((req) => pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
++ router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), run((req) => pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createPulseApi };
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
nnew file mode 100644
iindex 0000000..2583756
---- /dev/null
++++ b/server/pulse/service.js
@@@ -0,0 +1,143 @@
++'use strict';
++
++/**
++ * Pulse — a read model of public activity across the network, with provenance.
++ *
++ * Sources:
++ * - Community's own public activity, recorded at write time (the hooks below): new public
++ * pastes written by a person (not burn-after-read, not NSFW), new threads and replies in
++ * public spaces. Deletes and visibility changes take items out again, and listItems()
++ * re-checks Community's own items at read time as well.
++ * - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef
++ * of their own (ref.service must be the calling service), title, url, origin, occurred_at.
++ * Only visibility 'public' is accepted — private or unlisted things never enter Pulse.
++ *
++ * Provenance (roadmap §33): origin is user | ai | system. AI items are labelled as AI and never
++ * carry an actor; system items have none either; user items name the acting person (the JWT
++ * subject for Community's own writes, X-OV-Subject for services).
++ */
++const contracts = require('openvibe-contracts');
++const store = require('./store');
++const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/v1');
++const { createAuthors } = require('../identity/authors');
++
++const ORIGINS = ['user', 'ai', 'system'];
++const PAGE = 30;
++const FUTURE_SKEW_MS = 5 * 60_000;
++
++/** 'svc:live' → 'live' (the service a client-credentials token belongs to). */
++const serviceNameOf = (sub) => String(sub || '').replace(/^(svc|app|mod):/, '');
++
++function createPulse({ db, network = null, config = {} } = {}) {
++ const authors = createAuthors({ db, network });
++ const base = (config.baseUrl || '').replace(/\/$/, '');
++
++ /** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */
++ function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null }) {
++ try {
++ const o = ORIGINS.includes(origin) ? origin : 'user';
++ store.upsertItem(db, {
++ source_service: 'community', source_type: type, source_id: String(id),
++ title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
++ actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),
++ });
++ } catch (err) { console.warn('[Pulse] record failed:', err.message); }
++ }
++ function forgetLocal(type, id) {
++ try { store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }
++ }
++
++ function shape(i, projections) {
++ const ai = i.origin === 'ai';
++ const actor = ai || i.origin === 'system' ? null : authors.author(i.actor_subject, 'user', projections);
++ return {
++ id: i.id,
++ source: { service: i.source_service, type: i.source_type, id: i.source_id },
++ title: i.title,
++ url: i.url,
++ origin: i.origin,
++ label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
++ actor,
++ occurred_at: isoTime(i.occurred_at),
++ };
++ }
++
++ return {
++ ORIGINS,
++
++ // ── hooks for Community's own writes ─────────────────
++ pasteCreated(p) {
++ if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;
++ recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at });
++ },
++ pasteChanged(p) {
++ if (!p) return;
++ if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) forgetLocal('paste', p.slug);
++ },
++ pasteGone(slug) { forgetLocal('paste', slug); },
++ threadCreated(thread, space) {
++ if (!thread || !space || space.visibility !== 'public') return;
++ recordLocal('thread', thread.id, {
++ title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
++ actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
++ });
++ },
++ postCreated(post, thread, space) {
++ if (!post || !thread || !space || space.visibility !== 'public' || post.is_opening) return;
++ recordLocal('post', post.id, {
++ title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
++ actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
++ });
++ },
++ threadGone(id) { forgetLocal('thread', id); },
++ postGone(id) { forgetLocal('post', id); },
++
++ /** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */
++ async ingest(v, body = {}) {
++ const ref = body.ref;
++ if (!ref || typeof ref !== 'object' || !contracts.validate('common.entity-ref@1', ref).valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
++ const own = serviceNameOf(v.service);
++ if (ref.service !== own) fail(403, 'pulse.foreign_ref', `A service publishes Pulse items about its own entities (${own}), not ${ref.service}'s`);
++ if (body.visibility != null && body.visibility !== 'public') fail(400, 'pulse.not_public', 'Only public activity enters Pulse');
++ const title = String(body.title == null ? '' : body.title).replace(/\s+/g, ' ').trim();
++ if (!title || title.length > 300) fail(400, 'pulse.invalid_title', 'title is required (at most 300 characters)');
++ const url = String(body.url || '');
++ let parsed = null;
++ try { parsed = new URL(url); } catch { /* invalid */ }
++ if (!parsed || !/^https?:$/.test(parsed.protocol) || url.length > 2000) fail(400, 'pulse.invalid_url', 'url must be an absolute http(s) URL');
++ let origin = body.origin == null ? v.origin : body.origin;
++ if (!ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
++ if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
++ const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);
++ if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');
++ const out = store.upsertItem(db, {
++ source_service: ref.service, source_type: ref.type, source_id: ref.id,
++ title, url: parsed.toString(),
++ actor_subject: origin === 'user' ? (v.subject || null) : null,
++ origin, occurred_at: sqlTime(when),
++ });
++ const projections = await authors.projectionsFor([out.item.actor_subject]);
++ return { item: shape(out.item, projections), created: out.created };
++ },
++
++ /** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */
++ retract(v, service, type, id) {
++ if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');
++ return { removed: store.removeItem(db, service, type, id) };
++ },
++
++ /** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */
++ async list(q = {}) {
++ const origin = q.origin == null || q.origin === '' || q.origin === 'all' ? null : String(q.origin);
++ if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
++ const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
++ const before = decodeCursor(q.after, 2);
++ const { rows, hasMore } = store.listItems(db, { origin, before, limit });
++ const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
++ const last = rows[rows.length - 1];
++ return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last ? encodeCursor([last.occurred_at, last.id]) : null };
++ },
++ };
++}
++
++module.exports = { createPulse, serviceNameOf, ORIGINS };
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
nnew file mode 100644
iindex 0000000..b85cb82
---- /dev/null
++++ b/server/pulse/store.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * Pulse store — pure SQL over pulse_items (server/db.js).
++ *
++ * An item is keyed by its source (service, type, id). Re-recording the same source refreshes
++ * its title and link but never its provenance: origin, actor and occurred_at are what the first
++ * record said.
++ */
++
++/** → { item, created } */
++function upsertItem(db, it) {
++ const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
++ return db.transaction(() => {
++ const key = [it.source_service, it.source_type, String(it.source_id)];
++ if (find.get(...key)) {
++ db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);
++ return { item: find.get(...key), created: false };
++ }
++ db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at);
++ return { item: find.get(...key), created: true };
++ })();
++}
++
++function removeItem(db, service, type, id) {
++ return db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id)).changes;
++}
++
++/**
++ * Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
++ * against their source at read time — a paste made private, a deleted post or a thread in a
++ * space that stopped being public never shows, even if nothing removed its item.
++ * opts.origin 'user' | 'ai' | 'system'
++ * opts.before [occurred_at, id] of the last item already seen
++ * → { rows, hasMore }
++ */
++function listItems(db, { origin = null, before = null, limit = 30 } = {}) {
++ const rows = db.prepare(`
++ SELECT i.* FROM pulse_items i
++ WHERE (@origin IS NULL OR i.origin = @origin)
++ AND (@at IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id))
++ AND (i.source_service <> 'community'
++ OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
++ OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
++ WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'))
++ OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
++ WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public')))
++ ORDER BY i.occurred_at DESC, i.id DESC
++ LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
++ const hasMore = rows.length > limit;
++ if (hasMore) rows.pop();
++ return { rows, hasMore };
++}
++
++module.exports = { upsertItem, removeItem, listItems };
ddiff --git a/server/relay/api.js b/server/relay/api.js
nnew file mode 100644
iindex 0000000..2a3733e
---- /dev/null
++++ b/server/relay/api.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++/**
++ * /api/v1/relay — Discord relay administration, staff only (an admin/global_mod browser, or a
++ * service holding community.comment.moderate: acting as itself, or vouching with X-OV-Staff).
++ *
++ * GET /deliveries?status=pending|delivered|failed&limit= what was sent, what is failing and why
++ * POST /deliveries/:id/retry queue a failed delivery again
++ * GET /mappings space → webhook variable name
++ * POST /mappings { space, webhook_url_ref, enabled? } map a space (the URL stays in the env)
++ * PUT /mappings/:id { enabled }
++ *
++ * Responses never contain a webhook URL, only the variable name and whether it is set.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run, jsonBody, fail, intIn } = require('../http/v1');
++const { discussionModerator } = require('../identity/capabilities');
++const forumStore = require('../forum/store');
++
++function createRelayApi({ relay, db, viewers }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++
++ router.use((req, res, next) => {
++ const v = req.viewer;
++ if (discussionModerator(v)) return next();
++ if (v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'Sign in as staff', ctx: req.ov });
++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'Relay administration is for staff', ctx: req.ov });
++ });
++
++ const idOf = (req) => (/^\d{1,15}$/.test(req.params.id) ? Number(req.params.id) : fail(404, 'route.not_found', 'Not found'));
++
++ router.get('/deliveries', run((req) => {
++ const status = ['pending', 'delivered', 'failed'].includes(req.query.status) ? req.query.status : null;
++ return { enabled: relay.enabled, deliveries: relay.listDeliveries({ status, limit: intIn(req.query.limit, 50, 1, 200) }) };
++ }));
++ router.post('/deliveries/:id/retry', run((req) => {
++ if (!relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
++ return { ok: true };
++ }));
++ router.get('/mappings', run(() => ({ enabled: relay.enabled, mappings: relay.listMappings() })));
++ router.post('/mappings', jsonBody, run((req) => {
++ const b = req.body || {};
++ const space = forumStore.getSpace(db, String(b.space || ''));
++ if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
++ const ref = String(b.webhook_url_ref || '');
++ if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
++ return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false }) };
++ }, 201));
++ router.put('/mappings/:id', jsonBody, run((req) => {
++ const m = relay.setMappingEnabled(idOf(req), !!(req.body || {}).enabled);
++ if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');
++ return { mapping: m };
++ }));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createRelayApi };
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
nnew file mode 100644
iindex 0000000..c558540
---- /dev/null
++++ b/server/relay/discord.js
@@@ -0,0 +1,185 @@
++'use strict';
++
++/**
++ * Discord relay, outbound only (DISCORD_RELAY_ENABLED; off by default).
++ *
++ * When a thread is created in a public space that has an enabled relay_mappings row, one
++ * relay_deliveries row is created per (thread, mapping) — its dedupe key — and a worker posts a
++ * message with source attribution and a link to the mapping's Discord webhook.
++ *
++ * - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an
++ * environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at
++ * send time.
++ * - Loop prevention: a thread whose origin is 'discord' (a future inbound relay) is never
++ * relayed out, checked both when queueing and when sending.
++ * - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with
++ * exponential backoff (baseMs · 2^(attempt−1), capped at an hour; a 429's retry_after is
++ * honoured) up to maxAttempts, then the delivery is 'failed'. Other 4xx answers fail at once.
++ * - Mentions are disabled (allowed_mentions.parse = []), so a title can never ping anyone.
++ * Failures are visible to staff at GET /api/v1/relay/deliveries.
++ */
++const pasteStore = require('../pastes/store');
++const forumStore = require('../forum/store');
++const { markdownToText } = require('../render/markdown');
++const { sqlTime, isoTime } = require('../http/v1');
++const { AI_DISPLAY_NAME } = require('../identity/authors');
++
++const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
++const MAX_BACKOFF_MS = 60 * 60_000;
++const SEND_TIMEOUT_MS = 10_000;
++
++/** Discord markdown in names/titles shown as plain text. */
++const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
++
++function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now() } = {}) {
++ const base = (config.baseUrl || '').replace(/\/$/, '');
++ let timer = null;
++ let draining = null;
++
++ function deliveryKey(threadId, mappingId) { return `thread:${threadId}:mapping:${mappingId}`; }
++
++ /** Queue a new thread for every enabled out-mapping of its space. → number of deliveries queued */
++ function enqueueThread(thread, space) {
++ if (!enabled || !thread || !space) return 0;
++ if (thread.origin === 'discord') return 0; // loop prevention
++ if (space.visibility !== 'public') return 0; // members/staff spaces never leave the site
++ const mappings = db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1").all(space.id);
++ let queued = 0;
++ const insert = db.prepare(`INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT(dedupe_key) DO NOTHING`);
++ for (const m of mappings) queued += insert.run(thread.id, m.id, deliveryKey(thread.id, m.id), sqlTime(now())).changes;
++ if (queued) kick();
++ return queued;
++ }
++
++ function kick() { setImmediate(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }); }
++
++ function message(thread, space) {
++ const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
++ let who = 'someone';
++ if (thread.origin === 'ai') who = AI_DISPLAY_NAME;
++ else if (thread.author_subject) {
++ const p = pasteStore.getProjections(db, [thread.author_subject]).get(thread.author_subject);
++ if (p) who = p.display_name || p.username || who;
++ }
++ const url = `${base}/s/${space.slug}/t/${thread.slug}`;
++ return {
++ username: 'OpenVibe.Community',
++ content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(who)}: <${url}>`,
++ allowed_mentions: { parse: [] },
++ embeds: [{
++ title: String(thread.title).slice(0, 256),
++ url,
++ description: markdownToText(opening ? opening.body_markdown : '', 300),
++ footer: { text: `OpenVibe.Community · s/${space.slug}` },
++ timestamp: isoTime(thread.created_at) || undefined,
++ }],
++ };
++ }
++
++ function backoff(attempts, retryAfterSec) {
++ const exp = Math.min(baseMs * Math.pow(2, Math.max(attempts - 1, 0)), MAX_BACKOFF_MS);
++ return Math.max(exp, retryAfterSec ? retryAfterSec * 1000 : 0);
++ }
++
++ function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0 }) {
++ const attempts = d.attempts + 1;
++ if (ok) {
++ db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = NULL, delivered_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
++ .run(attempts, status, d.id);
++ return 'delivered';
++ }
++ const giveUp = !retry || attempts >= maxAttempts;
++ db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
++ .run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);
++ return giveUp ? 'failed' : 'retry';
++ }
++
++ async function send(d) {
++ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);
++ const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
++ if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });
++ if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });
++ if (!ENV_NAME.test(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an environment variable name' });
++ const url = env[d.webhook_url_ref];
++ if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });
++ if (!/^https?:\/\//i.test(url)) return record(d, { ok: false, error: `${d.webhook_url_ref} is not an http(s) URL` });
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Community relay' },
++ body: JSON.stringify(message(thread, space)),
++ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
++ });
++ } catch (err) {
++ return record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });
++ }
++ if (res.ok) return record(d, { ok: true, status: res.status });
++ let detail = '';
++ let retryAfter = Number(res.headers.get('retry-after')) || 0;
++ try { const j = await res.json(); detail = j && (j.message || j.error) ? String(j.message || j.error) : ''; if (j && j.retry_after) retryAfter = Math.max(retryAfter, Number(j.retry_after) || 0); } catch { /* not JSON */ }
++ const retry = res.status === 429 || res.status === 408 || res.status >= 500;
++ return record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });
++ }
++
++ /** Send every due delivery once. One drain at a time. → { delivered, retry, failed } */
++ function drain() {
++ if (draining) return draining;
++ draining = (async () => {
++ const summary = { delivered: 0, retry: 0, failed: 0 };
++ if (!enabled) return summary;
++ const due = db.prepare(`SELECT d.*, m.webhook_url_ref FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id
++ WHERE d.status = 'pending' AND m.enabled = 1 AND d.next_attempt_at <= ? ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));
++ for (const d of due) summary[await send(d)]++;
++ return summary;
++ })().finally(() => { draining = null; });
++ return draining;
++ }
++
++ function start() {
++ if (!enabled || timer) return;
++ timer = setInterval(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, pollMs);
++ if (timer.unref) timer.unref();
++ kick();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ // ── admin (staff) ────────────────────────────────────────
++ function shapeMapping(m) {
++ return { id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: !!env[m.webhook_url_ref], enabled: !!m.enabled, created_at: isoTime(m.created_at) };
++ }
++ function listMappings() {
++ return db.prepare('SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id ORDER BY m.id').all().map(shapeMapping);
++ }
++ function addMapping({ space_id, webhook_url_ref, enabled: on = true }) {
++ db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', ?, ?)
++ ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0);
++ return shapeMapping(db.prepare("SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?").get(space_id, webhook_url_ref));
++ }
++ function setMappingEnabled(id, on) {
++ if (!db.prepare('UPDATE relay_mappings SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id).changes) return null;
++ return shapeMapping(db.prepare('SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id WHERE m.id = ?').get(id));
++ }
++ function listDeliveries({ status = null, limit = 50 } = {}) {
++ return db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug
++ FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id
++ WHERE (? IS NULL OR d.status = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, limit)
++ .map((d) => ({
++ id: d.id, dedupe_key: d.dedupe_key, status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
++ next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at),
++ mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },
++ thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },
++ }));
++ }
++ /** Put a failed (or pending) delivery back in the queue now, with a fresh attempt budget. */
++ function retry(id) {
++ const n = db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id).changes;
++ if (n) kick();
++ return n;
++ }
++
++ return { enabled, enqueueThread, drain, start, stop, listMappings, addMapping, setMappingEnabled, listDeliveries, retry, ENV_NAME, message };
++}
++
++module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME };
ddiff --git a/server/render/forum.js b/server/render/forum.js
nnew file mode 100644
iindex 0000000..3ba4631
---- /dev/null
++++ b/server/render/forum.js
@@@ -0,0 +1,211 @@
++'use strict';
++
++/**
++ * Forum pages — /s (spaces), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.
++ * Complete without JavaScript: sorting and pagination are links, replying, voting and the
++ * moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
++ * Markdown renderer's output (render/markdown.js).
++ */
++const seo = require('../seo');
++const { renderPage } = require('./layout');
++const { escapeHtml: esc } = require('./highlight');
++const { markdownToText } = require('./markdown');
++const { timeTag, fmtDate, num, authorHtml } = require('./pages');
++
++const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
++const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };
++
++function who(a) {
++ if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';
++ if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;
++ return authorHtml(a);
++}
++
++function spaceHref(slug, { sort = 'hot', page = 1 } = {}) {
++ const q = new URLSearchParams();
++ if (sort && sort !== 'hot') q.set('sort', sort);
++ if (page > 1) q.set('page', String(page));
++ const s = q.toString();
++ return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
++}
++
++function pager(href, page, pages, labels = ['Newer', 'Older']) {
++ if (pages <= 1) return '';
++ return `<nav class="pager" aria-label="Pages">
++ ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}
++ <span class="pager-info">Page ${page} of ${pages}</span>
++ ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
++ </nav>`;
++}
++
++const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '');
++
++// ── /s ───────────────────────────────────────────────────────
++function spacesPage({ spaces }) {
++ const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `
++ <li class="space-item">
++ <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
++ ${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
++ <p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
++ </li>`).join('')}
++ </ul>` : '<p class="empty">No spaces yet.</p>';
++ const body = `
++<header class="page-head">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Spaces</span></nav>
++ <h1>Spaces</h1>
++ <p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
++</header>
++${list}`;
++ return renderPage({
++ title: 'Spaces',
++ description: 'Spaces on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
++ canonicalPath: '/s',
++ active: 'spaces',
++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
++ body,
++ });
++}
++
++// ── /s/:space ────────────────────────────────────────────────
++function threadRow(t, space) {
++ const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
++ return `<li class="thread-row">
++ <span class="thread-score" title="Score">${num(t.score)}</span>
++ <div class="thread-main">
++ <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>
++ <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
++ </div>
++ </li>`;
++}
++
++function spacePage({ space, threads, sort, page, pages, total, user }) {
++ const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
++ const indexable = space.visibility === 'public';
++ const body = `
++<header class="page-head">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
++ <h1>${esc(space.name)}${visBadge(space)}</h1>
++ ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
++ <p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New thread</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>
++</header>
++<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
++<section data-results>
++ ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>`}
++ ${pager((n) => spaceHref(space.slug, { sort, page: n }), page, pages)}
++</section>`;
++ return renderPage({
++ title: `${space.name} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
++ description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
++ canonicalPath: spaceHref(space.slug, { sort, page }),
++ robots: indexable ? 'index,follow' : 'noindex,nofollow',
++ active: 'spaces',
++ feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
++ body,
++ });
++}
++
++// ── /s/:space/t/:slug ────────────────────────────────────────
++function postHtml(p, n) {
++ if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
++ return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
++ <header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
++ <div class="md">${p.body_html}</div>
++ </article>`;
++}
++
++function voteForm(base, thread, viewer) {
++ if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
++ const btn = (value, icon, label) => {
++ const active = thread.my_vote === value;
++ return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="fa-solid ${icon}" aria-hidden="true"></i></button>`;
++ };
++ return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
++}
++
++function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {
++ const base = `/s/${space.slug}/t/${thread.slug}`;
++ const opening = posts.find((p) => p.is_opening) || null;
++ const indexable = space.visibility === 'public';
++ const first = (page - 1) * perPage;
++ const replyBlock = viewer.can_reply
++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
++ <label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), &gt; quotes and lists.</span></div>
++ </form>`
++ : thread.locked ? '<p class="alert">This thread is locked.</p>'
++ : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
++ const modBlock = viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
++ ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
++ <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
++ ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
++ </div>` : '';
++
++ const body = `
++<article class="thread">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(seo.clean(thread.title, 60))}</span></nav>
++ <header class="thread-head">
++ ${voteForm(base, thread, viewer)}
++ <div>
++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}</h1>
++ <p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
++ </div>
++ </header>
++ ${modBlock}
++ <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1)).join('\n')}</div>
++ ${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
++ ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
++</article>`;
++ const description = seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title;
++ return renderPage({
++ title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
++ description,
++ canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,
++ robots: indexable ? 'index,follow' : 'noindex,nofollow',
++ ogType: 'article',
++ published: thread.created_at || undefined,
++ modified: (opening && opening.updated_at) || undefined,
++ active: 'spaces',
++ historyType: 'page',
++ historyTitle: thread.title,
++ footerVariant: 'compact',
++ feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
++ jsonLd: [
++ seo.threadLd({ space, thread, posts, opening, description }),
++ seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
++ ],
++ body,
++ });
++}
++
++// ── /s/:space/new ────────────────────────────────────────────
++function newThreadPage({ space, user, values = {}, error = null }) {
++ const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new">
++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
++ <label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
++ <label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
++ <div class="form-actions">
++ <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
++ <span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
++ </div>
++</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
++ const body = `
++<header class="page-head">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
++ <h1>New thread in ${esc(space.name)}</h1>
++</header>
++${form}`;
++ return renderPage({
++ title: `New thread · ${space.name}`,
++ description: `Start a thread in ${space.name} on OpenVibe.Community.`,
++ canonicalPath: `/s/${space.slug}/new`,
++ robots: 'noindex,follow',
++ active: 'spaces',
++ footerVariant: 'compact',
++ body,
++ });
++}
++
++module.exports = { spacesPage, spacePage, threadPage, newThreadPage, spaceHref, who };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3668db3..4c5da70 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,7 +14,7 @@ const { escapeHtml } = require('./highlight');
const SITE_NAME = 'OpenVibe.Community';
const NETWORK_URL = 'https://openvibe.network';
--const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, and soon spaces, threads and submissions. Free speech within the rules.';
++const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and soon submissions. Free speech within the rules.';
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
@@@ -45,6 +45,8 @@ function navbarInit(opts) {
apiBase: NETWORK_URL,
links: [
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
++ { label: 'Spaces', href: '/s', active: opts.active === 'spaces' },
++ { label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
],
menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
@@@ -68,6 +70,8 @@ function footerInit(opts) {
heading: 'Community',
items: [
{ name: 'Pastes', url: '/pastes' },
++ { name: 'Spaces', url: '/s' },
++ { name: 'Pulse', url: '/pulse' },
{ name: 'New paste', url: '/new' },
{ name: 'My pastes', url: '/my' },
{ name: 'What is coming', url: '/#coming' },
@@@ -80,7 +84,8 @@ function footerInit(opts) {
/**
* @param {object} o
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
-- * ogImage, jsonLd (array), body (main HTML), active ('home'|'pastes'|'new'|'my'),
++ * ogImage, jsonLd (array), body (main HTML), active ('home'|'pastes'|'spaces'|'pulse'|'new'|'my'),
++ * feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* published/modified (ISO, for article:*), noChrome (error pages during outages)
*/
@@@ -116,7 +121,7 @@ ${o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.m
<meta name="twitter:description" content="${escapeHtml(description)}">
<meta name="twitter:image" content="${escapeHtml(ogImage)}">
${require('openvibe-shared/app-icon').headTags({ site: 'community', iconBase: '/assets' })}
--<link rel="alternate" type="application/rss+xml" title="${SITE_NAME} — latest pastes" href="/feed.xml">
++${(o.feeds || [{ title: `${SITE_NAME} — latest pastes`, href: '/feed.xml' }]).map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`).join('\n')}
<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
<link rel="stylesheet" href="${asset('css/community.css')}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..6cef84d
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,147 @@
++'use strict';
++
++/**
++ * A small, safe Markdown renderer for forum posts.
++ *
++ * Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;
++ * the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,
++ * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep
++ * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".
++ * Fenced code blocks go through the same highlight.js wrapper pastes use (which escapes).
++ *
++ * Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so
++ * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an
++ * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare
++ * https:// links. No images, tables, footnotes or raw HTML.
++ */
++const { escapeHtml: esc, highlight } = require('./highlight');
++
++const MAX_SOURCE = 100_000;
++const MAX_QUOTE_DEPTH = 3;
++const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})\s*$/;
++const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
++const QUOTE = /^ {0,3}>\s?(.*)$/;
++const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
++const UL = /^ {0,3}[-*+]\s+(.*)$/;
++const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;
++const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;
++
++function unescapeEntities(s) {
++ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
++}
++
++/** Inline formatting over one block's text (raw, unescaped in; safe HTML out). */
++function inline(raw) {
++ const slots = [];
++ const hold = (html) => `\u0000${slots.push(html) - 1}\u0000`;
++ // 1. code spans keep their content literal
++ let s = String(raw).replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, (_m, _t, code) => hold(`<code>${esc(code)}</code>`));
++ // 2. everything else is escaped before any tag is written
++ s = esc(s);
++ // 3. [text](url) — only safe schemes; the rest stays as text
++ s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
++ if (!SAFE_URL.test(unescapeEntities(url))) return m;
++ return hold(`<a href="${url}" rel="nofollow ugc noopener">${emphasis(text)}</a>`);
++ });
++ // 4. bare links
++ s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {
++ let url = m, tail = '';
++ while (/[.,;:!?)\]'"]$/.test(url) || /&(quot|#39|gt|lt);$/.test(url)) {
++ const ent = url.match(/&(quot|#39|gt|lt);$/);
++ const cut = ent ? ent[0].length : 1;
++ tail = url.slice(-cut) + tail; url = url.slice(0, -cut);
++ }
++ return url.length > 8 ? hold(`<a href="${url}" rel="nofollow ugc noopener">${url}</a>`) + tail : m;
++ });
++ s = emphasis(s);
++ s = s.replace(/\n/g, '<br>\n');
++ // Restore held pieces (a link's text may itself hold a code span).
++ for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => slots[Number(n)] || '');
++ return s;
++}
++
++/** Bold / italic / strike over already-escaped text. */
++function emphasis(s) {
++ return s
++ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
++ .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')
++ .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
++ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
++ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
++}
++
++function isBlockStart(line) {
++ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
++}
++
++function blocks(lines, depth) {
++ const out = [];
++ let i = 0;
++ while (i < lines.length) {
++ const line = lines[i];
++ if (!line.trim()) { i++; continue; }
++
++ let m = line.match(FENCE);
++ if (m) {
++ const fence = m[1], lang = m[2];
++ const closing = new RegExp(`^ {0,3}\\${fence[0]}{${fence.length},}\\s*$`);
++ const body = [];
++ i++;
++ while (i < lines.length && !closing.test(lines[i])) body.push(lines[i++]);
++ i++; // closing fence (or end of input)
++ const code = body.join('\n');
++ const hl = lang ? highlight(code, lang) : { html: esc(code), language: 'plaintext' };
++ out.push(`<pre class="md-code"><code class="hljs language-${esc(hl.language)}">${hl.html}</code></pre>`);
++ continue;
++ }
++ if (HR.test(line)) { out.push('<hr>'); i++; continue; }
++ m = line.match(HEADING);
++ if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }
++ if (QUOTE.test(line)) {
++ const inner = [];
++ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
++ out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);
++ continue;
++ }
++ if (UL.test(line) || OL.test(line)) {
++ const ordered = !UL.test(line);
++ const re = ordered ? OL : UL;
++ const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
++ const items = [];
++ while (i < lines.length && lines[i].trim()) {
++ const mm = lines[i].match(re);
++ if (mm) items.push(ordered ? mm[2] : mm[1]);
++ else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
++ else break;
++ i++;
++ }
++ const tag = ordered ? 'ol' : 'ul';
++ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);
++ continue;
++ }
++ const para = [];
++ while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);
++ out.push(`<p>${inline(para.join('\n'))}</p>`);
++ }
++ return out.join('\n');
++}
++
++/** Markdown source → safe HTML (see the header for what is supported). */
++function renderMarkdown(source) {
++ const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
++ return blocks(text.split('\n'), 0);
++}
++
++/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */
++function markdownToText(source, max = 300) {
++ const t = String(source == null ? '' : source).replace(/\u0000/g, '')
++ .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
++ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1')
++ .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
++ .replace(/(\*\*|__|~~|`)/g, '')
++ .replace(/\s+/g, ' ')
++ .trim();
++ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
++}
++
++module.exports = { renderMarkdown, markdownToText };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 74fd7f3..192a83a 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -94,10 +94,11 @@ function homePage({ latest, trending, languages, user }) {
<section class="hero">
<p class="eyebrow">OpenVibe.Community</p>
<h1>Share it with a link. Talk about it here.</h1>
-- <p class="lede">Paste code, logs, configs or a screenshot and get a short link that works anywhere. Reading and sharing need no account. Sign in with your OpenVibe account to edit, comment and keep a list of your own. Spaces and threads are next.</p>
++ <p class="lede">Paste code, logs, configs or a screenshot and get a short link that works anywhere. Reading and sharing need no account. Sign in with your OpenVibe account to edit, comment and keep a list of your own, and talk things through in <a href="/s">Spaces</a>.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> Start a paste</a>
<a class="btn" href="/pastes"><i class="fa-solid fa-paste" aria-hidden="true"></i> Browse pastes</a>
++ <a class="btn" href="/s"><i class="fa-solid fa-comments" aria-hidden="true"></i> Spaces</a>
${user ? `<a class="btn btn-ghost" href="/my"><i class="fa-solid fa-user" aria-hidden="true"></i> My pastes</a>` : `<a class="btn btn-ghost" href="/auth/login?next=%2F"><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i> Sign in with your OpenVibe account</a>`}
</div>
<ul class="hero-points">
@@@ -129,15 +130,15 @@ function homePage({ latest, trending, languages, user }) {
<section class="section coming" id="coming">
<h2>What is coming</h2>
<div class="coming-grid">
-- <div class="coming-item"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><h3>Spaces</h3><p>Places for the communities around each streamer, game and project — run by the people in them.</p></div>
-- <div class="coming-item"><i class="fa-solid fa-comments" aria-hidden="true"></i><h3>Threads</h3><p>Long-form discussion that outlives a chat scrollback. Forums, the way they should have stayed.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><h3><a href="/s">Spaces</a></h3><p>Open now: General, Feedback and Showcase. Places for the communities around each streamer, game and project come next — run by the people in them.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-comments" aria-hidden="true"></i><h3><a href="/s/general">Threads</a></h3><p>Long-form discussion that outlives a chat scrollback, readable without JavaScript. Forums, the way they should have stayed.</p></div>
<div class="coming-item"><i class="fa-solid fa-inbox" aria-hidden="true"></i><h3>Submissions</h3><p>Send in clips, art, ideas and reports; the community reviews and features the best of it.</p></div>
</div>
-- <p class="muted">Pastes are live today. The rest is being built in the open — follow along on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a> and <a href="https://discord.gg/M6MuRUaeJj" rel="noopener">Discord</a>.</p>
++ <p class="muted">Pastes, spaces, threads and the <a href="/pulse">Pulse</a> of the network are live today. The rest is being built in the open — follow along on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a> and <a href="https://discord.gg/M6MuRUaeJj" rel="noopener">Discord</a>.</p>
</section>`;
return renderPage({
title: null,
-- description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — and soon spaces, threads and submissions. Free speech within the rules.',
++ description: 'The people of OpenVibe. A community-run, open source home for pastes — code, logs and screenshots with a link — spaces and threads, and soon submissions. Free speech within the rules.',
canonicalPath: '/',
active: 'home',
jsonLd: [seo.websiteLd()],
@@@ -382,4 +383,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
--module.exports = { homePage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo };
++module.exports = { homePage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml };
ddiff --git a/server/render/pulse.js b/server/render/pulse.js
nnew file mode 100644
iindex 0000000..50d377e
---- /dev/null
++++ b/server/render/pulse.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/**
++ * /pulse — what is happening across the OpenVibe network, newest first, from Pulse
++ * (server/pulse). Filtering by origin and paging ("Older") are links, so it works without
++ * JavaScript. AI items carry an AI label and no person; system items name no one either.
++ */
++const seo = require('../seo');
++const { renderPage } = require('./layout');
++const { escapeHtml: esc } = require('./highlight');
++const { timeTag } = require('./pages');
++const { who } = require('./forum');
++
++const FILTERS = [['', 'Everything'], ['user', 'People'], ['ai', 'AI'], ['system', 'System']];
++const SERVICE_NAMES = { community: 'Community', live: 'Live', media: 'Media', network: 'Network', tools: 'Tools', games: 'Games', wiki: 'Wiki', blog: 'Blog', reviews: 'Reviews' };
++
++const href = (origin, after) => {
++ const q = new URLSearchParams();
++ if (origin) q.set('origin', origin);
++ if (after) q.set('after', after);
++ const s = q.toString();
++ return `/pulse${s ? `?${s}` : ''}`;
++};
++
++function itemHtml(it) {
++ const service = SERVICE_NAMES[it.source.service] || it.source.service;
++ const local = it.source.service === 'community';
++ const byline = it.origin === 'ai'
++ ? '<span class="badge badge-ai" title="Generated by AI — not written by a person">AI</span>'
++ : it.origin === 'system' ? '<span class="badge">System</span>'
++ : it.actor ? who(it.actor) : '<span class="muted">Anonymous</span>';
++ return `<li class="pulse-item pulse-${esc(it.origin)}">
++ <span class="badge pulse-source" title="${esc(`${it.source.service} · ${it.source.type}`)}">${esc(service)} · ${esc(it.source.type)}</span>
++ <div class="pulse-main">
++ <a class="thread-title" href="${esc(it.url)}"${local ? '' : ' rel="nofollow ugc noopener"'}>${esc(it.title)}</a>
++ <p class="thread-meta">${byline} <span class="sep">·</span> ${timeTag(it.occurred_at)}</p>
++ </div>
++ </li>`;
++}
++
++function pulsePage({ items, origin = '', after = null, nextCursor = null }) {
++ const tabs = FILTERS.map(([id, label]) => `<a class="tab${id === origin ? ' active' : ''}" href="${esc(href(id))}"${id === origin ? ' aria-current="page"' : ''}>${label}</a>`).join('');
++ const body = `
++<header class="page-head">
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Pulse</span></nav>
++ <h1>Pulse</h1>
++ <p class="muted">Public activity from across OpenVibe as it happens: new pastes, threads and replies here, and what the other sites share. Only public things appear, and anything made by AI is labelled as AI.</p>
++</header>
++<nav class="tabs sort-tabs" aria-label="Filter by origin">${tabs}</nav>
++<section data-results>
++ ${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : '<p class="empty">Nothing here yet.</p>'}
++ ${after || nextCursor ? `<nav class="pager" aria-label="Pages">${after ? `<a href="${esc(href(origin))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(origin, nextCursor))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}
++</section>`;
++ return renderPage({
++ title: `Pulse${origin ? ` — ${FILTERS.find(([id]) => id === origin)[1]}` : ''}`,
++ description: 'The pulse of the OpenVibe network: new public pastes, threads, replies and what the other OpenVibe sites share, with AI-made items labelled as AI.',
++ canonicalPath: href(origin, after),
++ // The first page of each filter is worth indexing; deep cursors are not.
++ robots: after ? 'noindex,follow' : 'index,follow',
++ active: 'pulse',
++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
++ body,
++ });
++}
++
++module.exports = { pulsePage };
ddiff --git a/server/seo.js b/server/seo.js
iindex c6aa934..adaf116 100644
---- a/server/seo.js
++++ b/server/seo.js
@@@ -1,8 +1,8 @@
'use strict';
/**
-- * robots.txt, the dynamic sitemap and the RSS feed of latest pastes. JSON-LD builders live
-- * here too so page templates stay about markup.
++ * robots.txt, the dynamic sitemap and the RSS feeds (latest pastes; latest threads overall and
++ * per space). JSON-LD builders live here too so page templates stay about markup.
*/
const config = require('./config');
const catalog = require('./pastes/catalog');
@@@ -11,6 +11,11 @@ const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
const SITEMAP_TTL_MS = 60 * 60 * 1000;
++// The forum service (server/forum/service.js), when the app has one: threads join the sitemap
++// and get their own feeds.
++let _forum = null;
++function useForum(forum) { _forum = forum || null; _sitemap = null; }
++
function isoDate(v) {
if (!v) return null;
const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
@@@ -74,6 +79,45 @@ function pasteLd(paste, { description, image }) {
return base;
}
++/** Who wrote a thread or post, for JSON-LD: a person, the AI label (never a person), or anonymous. */
++function discussionAuthorLd(a) {
++ if (!a) return { '@type': 'Person', name: 'Anonymous' };
++ if (a.is_ai) return { '@type': 'Organization', name: a.display_name || 'OpenVibe AI' };
++ const name = a.display_name || a.username || 'Anonymous';
++ return a.username ? { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(a.username)}` } : { '@type': 'Person', name };
++}
++
++/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
++function threadLd({ space, thread, posts, opening, description }) {
++ const { markdownToText } = require('./render/markdown');
++ const url = abs(`/s/${space.slug}/t/${thread.slug}`);
++ const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
++ return {
++ '@context': 'https://schema.org',
++ '@type': 'DiscussionForumPosting',
++ headline: clean(thread.title, 110),
++ text: opening && !opening.deleted ? markdownToText(opening.body_markdown, 5000) : description,
++ url,
++ mainEntityOfPage: url,
++ author: discussionAuthorLd(thread.author),
++ datePublished: isoDate(thread.created_at) || undefined,
++ dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
++ isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
++ commentCount: thread.reply_count,
++ interactionStatistic: [
++ { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
++ { '@type': 'InteractionCounter', interactionType: 'https://schema.org/LikeAction', userInteractionCount: Math.max(Number(thread.score) || 0, 0) },
++ ],
++ comment: replies.map((p) => ({
++ '@type': 'Comment',
++ text: markdownToText(p.body_markdown, 2000),
++ author: discussionAuthorLd(p.author),
++ datePublished: isoDate(p.created_at) || undefined,
++ url: `${url}#post-${p.id}`,
++ })),
++ };
++}
++
// ── robots.txt ───────────────────────────────────────────────
function robotsTxt() {
return [
@@@ -83,6 +127,7 @@ function robotsTxt() {
'Disallow: /auth/',
'Disallow: /my',
'Disallow: /new',
++ 'Disallow: /s/*/new',
'Disallow: /*?sso=',
'',
`Sitemap: ${config.baseUrl}/sitemap.xml`,
@@@ -104,6 +149,12 @@ async function buildSitemap() {
if (Number(p.is_nsfw) || Number(p.burn_after_read)) continue;
add(`/p/${p.slug}`, isoDate(p.updated_at || p.created_at), 'weekly', '0.6');
}
++ if (_forum) {
++ add('/s', null, 'hourly', '0.8');
++ add('/pulse', null, 'hourly', '0.5');
++ for (const s of _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
++ for (const t of _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
++ }
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
}
async function sitemapHandler(_req, res) {
@@@ -143,6 +194,36 @@ ${items.join('\n')}
res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(xml);
}
++// ── RSS feeds of the latest threads (/s/feed.xml, /s/:space/feed.xml) ──
++function threadFeed({ space = null, threads }) {
++ const { markdownToText } = require('./render/markdown');
++ const items = threads.map((t) => {
++ const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
++ return ` <item>
++ <title>${escapeHtml(t.title)}</title>
++ <link>${escapeHtml(url)}</link>
++ <guid isPermaLink="true">${escapeHtml(url)}</guid>
++ ${isoDate(t.created_at) ? `<pubDate>${new Date(isoDate(t.created_at)).toUTCString()}</pubDate>` : ''}
++ <category>${escapeHtml(t.space_name)}</category>
++ <description>${escapeHtml(markdownToText(t.opening, 300))}</description>
++ </item>`;
++ });
++ const link = abs(space ? `/s/${space.slug}` : '/s');
++ const self = abs(space ? `/s/${space.slug}/feed.xml` : '/s/feed.xml');
++ return `<?xml version="1.0" encoding="UTF-8"?>
++<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
++ <channel>
++ <title>${escapeHtml(space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`)}</title>
++ <link>${escapeHtml(link)}</link>
++ <atom:link href="${escapeHtml(self)}" rel="self" type="application/rss+xml"/>
++ <description>${escapeHtml(space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Community.')}</description>
++ <language>en</language>
++${items.join('\n')}
++ </channel>
++</rss>
++`;
++}
++
function resetCaches() { _sitemap = null; _sitemapAt = 0; }
--module.exports = { isoDate, clean, websiteLd, breadcrumbLd, pasteLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, resetCaches };
++module.exports = { isoDate, clean, websiteLd, breadcrumbLd, pasteLd, threadLd, discussionAuthorLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, threadFeed, useForum, resetCaches };
ddiff --git a/server/votes.js b/server/votes.js
nnew file mode 100644
iindex 0000000..dea22fa
---- /dev/null
++++ b/server/votes.js
@@@ -0,0 +1,57 @@
++'use strict';
++
++/**
++ * Up/down votes on comments and forum threads — one row per (target, person), value 1 or -1.
++ *
++ * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same IMMEDIATE
++ * transaction, by recomputing the target's score from the vote rows. The score is never
++ * incremented or decremented, so interleaved votes from any number of connections or processes
++ * cannot drift it: whatever order the writes land in, the stored score is the sum of the rows
++ * that exist when the last transaction commits. IMMEDIATE takes SQLite's write lock up front
++ * (instead of upgrading a read lock), so two writers queue on busy_timeout rather than failing.
++ */
++const TARGETS = {
++ comment: { votes: 'comment_votes', key: 'comment_id', table: 'comments', counts: true },
++ thread: { votes: 'thread_votes', key: 'thread_id', table: 'threads', counts: false },
++};
++
++/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */
++function applyVote(db, kind, targetId, subject, value) {
++ const t = TARGETS[kind];
++ if (!t) throw new Error(`unknown vote target ${kind}`);
++ if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');
++ return db.transaction(() => {
++ if (value === 0) {
++ db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);
++ } else {
++ db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)
++ ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`)
++ .run(targetId, subject, value);
++ }
++ const agg = db.prepare(`SELECT COALESCE(SUM(value), 0) AS score,
++ COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
++ COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS downvotes
++ FROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);
++ if (t.counts) db.prepare(`UPDATE ${t.table} SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?`).run(agg.score, agg.upvotes, agg.downvotes, targetId);
++ else db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);
++ return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };
++ }).immediate();
++}
++
++/** The person's current votes on some targets. → Map id → 1|-1 */
++function myVotes(db, kind, targetIds, subject) {
++ const t = TARGETS[kind];
++ const out = new Map();
++ if (!subject || !targetIds.length) return out;
++ const stmt = db.prepare(`SELECT value FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`);
++ for (const id of new Set(targetIds)) { const r = stmt.get(id, subject); if (r) out.set(id, r.value); }
++ return out;
++}
++
++/** Parse a request's vote value: 1, -1 or 0 (numbers or numeric strings), else null. */
++function parseVote(v) {
++ const n = typeof v === 'string' && /^-?[01]$/.test(v.trim()) ? Number(v) : v;
++ return n === 1 || n === -1 || n === 0 ? n : null;
++}
++
++module.exports = { applyVote, myVotes, parseVote };
ddiff --git a/test/comments.test.js b/test/comments.test.js
nnew file mode 100644
iindex 0000000..1c24c6c
---- /dev/null
++++ b/test/comments.test.js
@@@ -0,0 +1,285 @@
++'use strict';
++/**
++ * /api/v1/comments — typed comment threads: resolve (idempotent, allowlist, services, labels,
++ * Community refs), pagination and one-level nesting, anonymous comments under the shared
++ * anonymous-write budget, per-person limits, votes, deletes, visibility (lock/hide), service
++ * capabilities (including the local match for ids openvibe-contracts does not know yet), CORS.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const { checkCapability } = require('../server/identity/capabilities');
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
++ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
++ const WRITE = 'community.comment.write', MOD = 'community.comment.moderate';
++
++ const call = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (ip) h['x-forwarded-for'] = ip;
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
++ const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
++
++ let vod;
++
++ await check('capabilities: proposed ids match locally (exact or prefix.*); known ids go through the library', () => {
++ assert.strictEqual(checkCapability({ cap: [WRITE] }, WRITE).allowed, true);
++ assert.strictEqual(checkCapability({ cap: ['community.comment.*'] }, MOD).allowed, true);
++ assert.strictEqual(checkCapability({ cap: ['community.*'] }, 'community.pulse.write').allowed, true);
++ assert.deepStrictEqual(checkCapability({ cap: ['community.paste.write'] }, WRITE), { allowed: false, code: 'capability.denied', reason: `${WRITE} not granted` });
++ assert.strictEqual(checkCapability({ cap: ['community.comment.writer'] }, WRITE).allowed, false, 'no partial-word matches');
++ assert.strictEqual(checkCapability({ cap: ['x.y'] }, 'community.nothing.here').code, 'capability.unknown', 'ids nobody proposed stay unknown');
++ assert.strictEqual(checkCapability({ cap: ['community.paste.create'] }, 'community.paste.create').allowed, true);
++ });
++
++ await check('resolve: get-or-create, idempotent (201 then 200, same id), unique per service+type+id', async () => {
++ const ref = { service: 'live', type: 'vod', id: '123' };
++ const a = await resolve(ref, { cookie: alexJwt });
++ assert.strictEqual(a.status, 201, a.text);
++ assert.strictEqual(a.json().created, true);
++ vod = a.json().thread;
++ assert.deepStrictEqual(vod.ref, ref);
++ assert.strictEqual(vod.visibility, 'public');
++ assert.strictEqual(vod.comment_count, 0);
++ const b = await resolve(ref);
++ assert.strictEqual(b.status, 200);
++ assert.strictEqual(b.json().thread.id, vod.id);
++ assert.strictEqual(b.json().created, false);
++ const other = await resolve({ service: 'live', type: 'clip', id: '123' });
++ assert.notStrictEqual(other.json().thread.id, vod.id);
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'vod' AND ref_id = '123'").get().c, 1);
++ });
++
++ await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {
++ const denied = await resolve({ service: 'games', type: 'map', id: '1' }, { cookie: alexJwt });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json().code, 'ref.type_not_allowed');
++ assert.match(denied.headers.get('content-type'), /application\/problem\+json/);
++ const wrongType = await resolve({ service: 'live', type: 'user', id: '1' });
++ assert.strictEqual(wrongType.status, 403);
++ const invalid = await resolve({ service: 'live', type: 'vod' });
++ assert.strictEqual(invalid.status, 400);
++ assert.strictEqual(invalid.json().code, 'ref.invalid');
++ const extra = await resolve({ service: 'live', type: 'vod', id: '1', evil: true });
++ assert.strictEqual(extra.status, 400, 'EntityRef allows no extra fields');
++ const game = await resolve({ service: 'games', type: 'map', id: '1', label: 'Map One' }, { token: svc([WRITE]) });
++ assert.strictEqual(game.status, 201, game.text);
++ assert.strictEqual(game.json().thread.ref.label, 'Map One');
++ const noCap = await resolve({ service: 'games', type: 'map', id: '2' }, { token: svc(['community.paste.write']) });
++ assert.strictEqual(noCap.status, 403);
++ assert.strictEqual(noCap.json().code, 'capability.denied');
++ const prefix = await resolve({ service: 'games', type: 'map', id: '3' }, { token: svc(['community.*']) });
++ assert.strictEqual(prefix.status, 201, 'a community.* grant covers the proposed ids');
++ const browserLabel = await resolve({ service: 'wiki', type: 'page', id: 'Home', label: 'I say so' }, { cookie: alexJwt });
++ assert.strictEqual(browserLabel.json().thread.ref.label, undefined);
++ });
++
++ await check('resolve: Community refs must exist — pastes (not private) and posts in public spaces', async () => {
++ const pub = (await t.app.locals.pastes.createText({ kind: 'user', subject: alex.subject_id, origin: 'user' }, { content: 'hello', visibility: 'public' })).slug;
++ const priv = (await t.app.locals.pastes.createText({ kind: 'user', subject: alex.subject_id, origin: 'user' }, { content: 'secret', visibility: 'private' })).slug;
++ assert.strictEqual((await resolve({ service: 'community', type: 'paste', id: pub })).status, 201);
++ assert.strictEqual((await resolve({ service: 'community', type: 'paste', id: priv })).status, 404);
++ assert.strictEqual((await resolve({ service: 'community', type: 'paste', id: 'no-such-paste-1' })).status, 404);
++ const { post: op } = await t.app.locals.forum.createThread({ kind: 'user', subject: alex.subject_id, origin: 'user' }, 'general', { title: 'A thread', body: 'first' });
++ assert.strictEqual((await resolve({ service: 'community', type: 'post', id: String(op.id) })).status, 201);
++ assert.strictEqual((await resolve({ service: 'community', type: 'post', id: '999999' })).status, 404);
++ });
++
++ await check('comment: signed-in author from the JWT (body identity ignored), projection names, count kept', async () => {
++ const r = await post(vod.id, { message: ' first! ', author_subject: sam.subject_id, user_id: 9 }, { cookie: alexJwt });
++ assert.strictEqual(r.status, 201, r.text);
++ const c = r.json().comment;
++ assert.strictEqual(c.message, 'first!');
++ assert.strictEqual(c.author.subject, alex.subject_id);
++ assert.strictEqual(c.author.username, 'alex');
++ assert.strictEqual(c.display_name, 'Alex');
++ assert.strictEqual(c.anon_name, null);
++ assert.strictEqual(c.origin, 'user');
++ assert.strictEqual(c.can_delete, true);
++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, 1);
++ assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
++ assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
++ assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
++ });
++
++ await check('anonymous comments: sanitized anon_name; the shared 20-per-10-minutes budget per address', async () => {
++ const ip = '203.0.113.77';
++ const r = await post(vod.id, { message: 'drive-by', anon_name: '<b>Ghost</b>!' }, { ip });
++ assert.strictEqual(r.status, 201, r.text);
++ const c = r.json().comment;
++ assert.strictEqual(c.author, null);
++ assert.strictEqual(c.anon_name, 'bGhostb');
++ assert.strictEqual(c.can_delete, false);
++ // The paste API and the comment API draw from the same anonymous budget.
++ for (let i = 0; i < 18; i++) assert.strictEqual((await call('/api/pastes', { method: 'POST', json: { content: `anon ${i}` }, ip })).status, 201);
++ assert.strictEqual((await post(vod.id, { message: 'one more' }, { ip })).status, 201);
++ const over = await post(vod.id, { message: 'too many' }, { ip });
++ assert.strictEqual(over.status, 429);
++ assert.strictEqual((await post(vod.id, { message: 'signed in is fine' }, { cookie: samJwt, ip })).status, 201);
++ });
++
++ await check('per-person limits: cooldown, duplicate text, per-minute cap — the same person via a service too', async () => {
++ const { createCommentService } = require('../server/comments/service');
++ const strict = createCommentService({ db: t.db, limits: { comments: { cooldownSec: 10, perMinute: 5 } } });
++ const who = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++ await strict.add(who, vod.id, { message: 'one' });
++ await assert.rejects(strict.add(who, vod.id, { message: 'two' }), (e) => e.status === 429 && /wait/.test(e.message));
++ const loose = createCommentService({ db: t.db, limits: { comments: { cooldownSec: 0, perMinute: 3 } } });
++ await loose.add(who, vod.id, { message: 'a' });
++ await assert.rejects(loose.add(who, vod.id, { message: 'a' }), (e) => e.status === 400 && e.code === 'request.duplicate');
++ await loose.add(who, vod.id, { message: 'b' });
++ await loose.add(who, vod.id, { message: 'c' });
++ await assert.rejects(loose.add({ kind: 'service', subject: who.subject, origin: 'user', claims: { cap: [WRITE] } }, vod.id, { message: 'd' }), (e) => e.status === 429);
++ });
++
++ await check('replies nest one level: a reply to a reply joins the top-level comment; foreign parents refused', async () => {
++ const top = (await post(vod.id, { message: 'top level' }, { cookie: alexJwt })).json().comment;
++ const r1 = (await post(vod.id, { message: 'reply', parent_id: top.id }, { cookie: samJwt })).json().comment;
++ const r2 = await post(vod.id, { message: 'reply to reply', parent_id: r1.id }, { cookie: alexJwt });
++ assert.strictEqual(r2.json().comment.parent_id, top.id);
++ const other = (await resolve({ service: 'live', type: 'vod', id: '456' })).json().thread;
++ assert.strictEqual((await post(other.id, { message: 'cross', parent_id: top.id }, { cookie: alexJwt })).status, 400);
++ const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
++ const shown = page.comments.find((c) => c.id === top.id);
++ assert.strictEqual(shown.reply_count, 2);
++ assert.deepStrictEqual(shown.replies.map((r) => r.message), ['reply', 'reply to reply']);
++ assert.ok(page.comments.every((c) => c.parent_id === null), 'only top-level comments at the top');
++ const replies = (await call(`/api/v1/comments/threads/${vod.id}?parent=${top.id}&after=${r1.id}`)).json();
++ assert.deepStrictEqual(replies.comments.map((c) => c.message), ['reply to reply']);
++ });
++
++ await check('pagination: ?after= cursor, oldest first by default, ?sort=new newest first, next_cursor ends', async () => {
++ const th = (await resolve({ service: 'blog', type: 'post', id: 'pagination' })).json().thread;
++ for (let i = 1; i <= 7; i++) await post(th.id, { message: `m${i}` }, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } });
++ const seen = [];
++ let cursor = '';
++ for (let guard = 0; guard < 10; guard++) {
++ const r = (await call(`/api/v1/comments/threads/${th.id}?limit=3${cursor ? `&after=${cursor}` : ''}`)).json();
++ seen.push(...r.comments.map((c) => c.message));
++ if (!r.next_cursor) break;
++ cursor = r.next_cursor;
++ }
++ assert.deepStrictEqual(seen, ['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7']);
++ const newest = (await call(`/api/v1/comments/threads/${th.id}?limit=2&sort=new`)).json();
++ assert.deepStrictEqual(newest.comments.map((c) => c.message), ['m7', 'm6']);
++ const next = (await call(`/api/v1/comments/threads/${th.id}?limit=2&sort=new&after=${newest.next_cursor}`)).json();
++ assert.deepStrictEqual(next.comments.map((c) => c.message), ['m5', 'm4']);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
++ });
++
++ await check('votes: add, change, remove; score recomputed; my_vote; people only', async () => {
++ const c = (await post(vod.id, { message: 'vote on me' }, { cookie: alexJwt })).json().comment;
++ const vote = (value, who) => call(`/api/v1/comments/${c.id}/votes`, { method: 'POST', json: { value }, ...who });
++ assert.deepStrictEqual((await vote(1, { cookie: samJwt })).json(), { comment_id: c.id, score: 1, upvotes: 1, downvotes: 0, my_vote: 1 });
++ assert.strictEqual((await vote(1, { cookie: alexJwt })).json().score, 2);
++ assert.deepStrictEqual((await vote(-1, { cookie: samJwt })).json(), { comment_id: c.id, score: 0, upvotes: 1, downvotes: 1, my_vote: -1 });
++ assert.strictEqual((await vote(0, { cookie: samJwt })).json().score, 1);
++ assert.strictEqual((await vote(0, { cookie: samJwt })).json().score, 1, 'removing twice is harmless');
++ assert.strictEqual((await vote(2, { cookie: samJwt })).status, 400);
++ assert.strictEqual((await vote(1, {})).status, 401, 'anonymous cannot vote');
++ assert.strictEqual((await vote(1, { token: svc([WRITE]) })).status, 401, 'a service must name the person');
++ assert.strictEqual((await vote(-1, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).json().score, 0);
++ const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`, { cookie: alexJwt })).json();
++ assert.strictEqual(page.comments.find((x) => x.id === c.id).my_vote, 1);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(c.id).n, 2);
++ });
++
++ await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
++ const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
++ await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
++ const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count;
++ assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
++ assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
++ assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
++ const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, before - 1);
++ assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');
++ const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
++ const tomb = page.comments.find((c) => c.id === top.id);
++ assert.ok(tomb && tomb.deleted && tomb.message === null && tomb.author === null);
++ assert.deepStrictEqual(tomb.replies.map((r) => r.message), ['a reply that stays']);
++ assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt })).status, 404);
++ const staffTarget = (await post(vod.id, { message: 'staff removes this' }, { cookie: samJwt })).json().comment;
++ assert.strictEqual((await call(`/api/v1/comments/${staffTarget.id}`, { method: 'DELETE', cookie: adminJwt })).status, 200);
++ const viaService = (await post(vod.id, { message: 'service staff removes this' }, { cookie: samJwt })).json().comment;
++ const noVouch = await call(`/api/v1/comments/${viaService.id}`, { method: 'DELETE', token: svc([WRITE, MOD]), headers: { 'x-ov-subject': alex.subject_id } });
++ assert.strictEqual(noVouch.status, 403, 'acting for a non-staff person');
++ const vouch = await call(`/api/v1/comments/${viaService.id}`, { method: 'DELETE', token: svc([WRITE, MOD]), headers: { 'x-ov-subject': alex.subject_id, 'x-ov-staff': '1' } });
++ assert.strictEqual(vouch.status, 200, vouch.text);
++ });
++
++ await check('visibility: services need comment.moderate; locked = read-only; hidden = 404 except moderators', async () => {
++ const th = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
++ const c = (await post(th.id, { message: 'before lock' }, { cookie: samJwt })).json().comment;
++ const put = (visibility, who) => call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility }, ...who });
++ assert.strictEqual((await put('locked', { token: svc([WRITE]) })).status, 403);
++ assert.strictEqual((await put('locked', { cookie: alexJwt })).status, 403, 'people are not moderators');
++ assert.strictEqual((await put('bogus', { token: svc([MOD]) })).status, 400);
++ const locked = await put('locked', { token: svc([MOD]) });
++ assert.strictEqual(locked.status, 200, locked.text);
++ assert.strictEqual(locked.json().thread.visibility, 'locked');
++ const blocked = await post(th.id, { message: 'after lock' }, { cookie: samJwt });
++ assert.strictEqual(blocked.status, 403);
++ assert.strictEqual(blocked.json().code, 'thread.locked');
++ assert.strictEqual((await call(`/api/v1/comments/${c.id}/votes`, { method: 'POST', json: { value: 1 }, cookie: alexJwt })).status, 403);
++ const read = (await call(`/api/v1/comments/threads/${th.id}`)).json();
++ assert.strictEqual(read.comments.length, 1);
++ assert.strictEqual(read.viewer.can_comment, false);
++ assert.strictEqual((await post(th.id, { message: 'staff may' }, { cookie: adminJwt })).status, 201);
++
++ assert.strictEqual((await put('hidden', { cookie: adminJwt })).status, 200, 'staff browsers too');
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`)).status, 404);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);
++ const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
++ assert.deepStrictEqual(again, { id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
++ const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });
++ assert.strictEqual(mod.status, 200);
++ assert.strictEqual(mod.json().comments.length, 2);
++ assert.strictEqual((await put('public', { token: svc([MOD]) })).json().thread.visibility, 'public');
++ assert.strictEqual((await post(th.id, { message: 'open again' }, { cookie: samJwt })).status, 201);
++ });
++
++ await check('AI comments from a service: origin ai, never attributed, labelled', async () => {
++ const r = await post(vod.id, { message: 'summary of the stream', anon_name: 'Pretender' }, { token: svc([WRITE]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id } });
++ assert.strictEqual(r.status, 201, r.text);
++ const c = r.json().comment;
++ assert.strictEqual(c.origin, 'ai');
++ assert.strictEqual(c.author.subject, null);
++ assert.strictEqual(c.author.is_ai, true);
++ assert.strictEqual(c.display_name, 'OpenVibe AI');
++ assert.strictEqual(c.anon_name, null);
++ assert.strictEqual(t.db.prepare('SELECT author_subject FROM comments WHERE id = ?').get(c.id).author_subject, null);
++ assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
++ });
++
++ await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
++ const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
++ assert.strictEqual(pre.status, 204);
++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), 'https://openvibe.live');
++ assert.match(pre.headers.get('access-control-allow-headers'), /Authorization/);
++ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null);
++ const evil = await call(`/api/v1/comments/threads/${vod.id}`, { headers: { origin: 'https://evil.example' } });
++ assert.strictEqual(evil.headers.get('access-control-allow-origin'), null);
++ const bearer = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'POST', token: alexJwt, headers: { origin: 'https://openvibe.live' }, json: { message: 'from live, by bearer' } });
++ assert.strictEqual(bearer.status, 201, bearer.text);
++ assert.strictEqual(bearer.json().comment.author.subject, alex.subject_id);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
nnew file mode 100644
iindex 0000000..6cbeb33
---- /dev/null
++++ b/test/forum-ssr.test.js
@@@ -0,0 +1,207 @@
++'use strict';
++/**
++ * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug
++ * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,
++ * JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const forumStore = require('../server/forum/store');
++
++const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
++const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
++const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++
++(async () => {
++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
++ const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
++
++ await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
++ const r = await t.get('/s');
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<title>Spaces · OpenVibe.Community</title>');
++ has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');
++ has(r.text, '<meta name="robots" content="index,follow">');
++ has(r.text, 'href="/s/feed.xml"');
++ for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);
++ assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));
++ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);
++ });
++
++ await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {
++ const page = await t.get('/s/general/new');
++ assert.strictEqual(page.status, 200);
++ has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');
++ has(page.text, '<meta name="robots" content="noindex,follow">');
++ const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));
++ assert.strictEqual(anon.status, 303);
++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);
++ const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
++ assert.strictEqual(bad.status, 400);
++ has(bad.text, 'Titles are 3 to 200 characters');
++ has(bad.text, '&lt;img src=x onerror=alert(1)&gt;</textarea>');
++ const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
++ assert.strictEqual(ok.status, 303, ok.text);
++ assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');
++ const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
++ assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
++ });
++
++ const url = '/s/general/t/how-do-i-b-embed-b-comments';
++
++ await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
++ const r = await t.get(url);
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<title>How do I &lt;b&gt;embed&lt;/b&gt; comments? · OpenVibe.Community</title>');
++ has(r.text, `<link rel="canonical" href="https://openvibe.community${url}">`);
++ has(r.text, '<meta property="og:type" content="article">');
++ has(r.text, '<meta property="article:published_time"');
++ has(r.text, '<strong>Live</strong>');
++ has(r.text, '<code class="hljs language-javascript"><span class="hljs-keyword">const</span>');
++ has(r.text, '&lt;script&gt;alert(1)&lt;/script&gt;');
++ assert.ok(!r.text.includes('<script>alert(1)</script>'));
++ const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');
++ assert.ok(post, 'DiscussionForumPosting JSON-LD');
++ assert.strictEqual(post.headline, 'How do I <b>embed</b> comments?');
++ assert.strictEqual(post.url, `https://openvibe.community${url}`);
++ assert.deepStrictEqual(post.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
++ assert.ok(post.datePublished && post.text.startsWith('Asking for Live.'));
++ assert.strictEqual(post.commentCount, 0);
++ assert.ok(!r.text.match(/<script type="application\/ld\+json">[^<]*<\/script>/g).some((s) => /<\/(b|script)>/i.test(s.slice(35, -9))), 'no raw closing tags inside JSON-LD');
++ assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList' && o.itemListElement.length === 4));
++ has(r.text, `/auth/login?next=${encodeURIComponent(url)}`, 'anonymous readers get a sign-in link instead of the form');
++ assert.ok(!r.text.includes(`action="${url}/reply"`));
++ });
++
++ await check('no-JS reply and vote: 303 back to the post; score and replies render; JSON-LD comments', async () => {
++ const reply = await t.get(`${url}/reply`, as(samJwt, form({ body: 'Use **/api/v1/comments**.' })));
++ assert.strictEqual(reply.status, 303, reply.text);
++ assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));
++ const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));
++ assert.strictEqual(empty.status, 400);
++ has(empty.text, 'Write something first');
++ const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));
++ assert.strictEqual(vote.status, 303);
++ assert.strictEqual(vote.headers.get('location'), url);
++ const r = await t.get(url, as(samJwt));
++ has(r.text, `action="${url}/reply"`);
++ has(r.text, `action="${url}/vote"`);
++ has(r.text, 'class="vote-btn active" type="submit" name="value" value="0" aria-pressed="true"', 'pressing your vote again removes it');
++ has(r.text, '<span class="vote-score" title="Score">1</span>');
++ has(r.text, 'Use <strong>/api/v1/comments</strong>.');
++ const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');
++ assert.strictEqual(post.commentCount, 1);
++ assert.strictEqual(post.comment[0].author.name, 'Sam');
++ assert.ok(post.comment[0].url.includes('#post-'));
++ const anonVote = await t.get(`${url}/vote`, form({ value: '1' }));
++ assert.strictEqual(anonVote.status, 303);
++ assert.match(anonVote.headers.get('location'), /^\/auth\/login/);
++ });
++
++ await check('moderation forms: people 403, staff pin and lock; the author deletes their thread', async () => {
++ assert.strictEqual((await t.get(`${url}/state`, as(samJwt, form({ pinned: '1' })))).status, 403);
++ assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ pinned: '1' })))).status, 303);
++ assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ locked: '1' })))).status, 303);
++ const r = await t.get(url, as(samJwt));
++ has(r.text, 'This thread is locked.');
++ assert.ok(!r.text.includes(`action="${url}/reply"`));
++ const list = await t.get('/s/general');
++ has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');
++ const admin = await t.get(url, as(adminJwt));
++ has(admin.text, 'Unpin');
++ has(admin.text, 'Unlock');
++ const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
++ const mineUrl = mine.headers.get('location');
++ assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);
++ const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));
++ assert.strictEqual(del.status, 303);
++ assert.strictEqual(del.headers.get('location'), '/s/general');
++ assert.strictEqual((await t.get(mineUrl)).status, 404);
++ });
++
++ await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
++ const space = forumStore.getSpace(t.db, 'showcase');
++ for (let i = 1; i <= 27; i++) forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
++ const p1 = await t.get('/s/showcase?sort=new');
++ assert.strictEqual(p1.status, 200);
++ has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');
++ has(p1.text, 'href="/s/showcase"');
++ has(p1.text, 'href="/s/showcase?sort=top"');
++ has(p1.text, 'rel="next" href="/s/showcase?sort=new&amp;page=2"');
++ has(p1.text, 'Page 1 of 2');
++ assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);
++ const p2 = await t.get('/s/showcase?sort=new&page=2');
++ has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');
++ has(p2.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new&amp;page=2">');
++ has(p2.text, 'Showcase item 1<');
++ assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);
++ assert.strictEqual((await t.get('/s/nowhere')).status, 404);
++ });
++
++ await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
++ t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
++ const ins = forumStore.getSpace(t.db, 'insiders');
++ forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
++ const anon = await t.get('/s/insiders');
++ assert.strictEqual(anon.status, 303);
++ assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
++ const member = await t.get('/s/insiders', as(samJwt));
++ assert.strictEqual(member.status, 200);
++ has(member.text, '<meta name="robots" content="noindex,nofollow">');
++ const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));
++ has(th.text, '<meta name="robots" content="noindex,nofollow">');
++ assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);
++ assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);
++ const feed = await t.get('/s/feed.xml');
++ assert.ok(!feed.text.includes('Members only chat'));
++ assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
++ require('../server/seo').resetCaches();
++ const map = await t.get('/sitemap.xml');
++ assert.ok(!map.text.includes('members-only-chat'));
++ });
++
++ await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
++ const space = forumStore.getSpace(t.db, 'feedback');
++ forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
++ const r = await t.get('/s/feedback/t/weekly-summary');
++ has(r.text, 'OpenVibe AI');
++ has(r.text, '<span class="badge badge-ai"');
++ assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });
++ });
++
++ await check('sitemap, feeds and robots.txt include the forum', async () => {
++ require('../server/seo').resetCaches();
++ const map = await t.get('/sitemap.xml');
++ for (const loc of ['/s', '/s/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
++ const feed = await t.get('/s/feed.xml');
++ assert.strictEqual(feed.status, 200);
++ has(feed.text, '<rss version="2.0"');
++ has(feed.text, `<link>https://openvibe.community${url}</link>`);
++ has(feed.text, '<title>How do I &lt;b&gt;embed&lt;/b&gt; comments?</title>');
++ const spaceFeed = await t.get('/s/showcase/feed.xml');
++ has(spaceFeed.text, 'Showcase item 27');
++ assert.ok(!spaceFeed.text.includes('embed'));
++ const robots = await t.get('/robots.txt');
++ has(robots.text, 'Disallow: /s/*/new');
++ });
++
++ await t.close();
++
++ // The forum lives in Community's database in every mode, including PASTES_AUTHORITY=live.
++ const live = await boot();
++ await check('forum pages work with PASTES_AUTHORITY=live too', async () => {
++ const r = await live.get('/s/general');
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<h1>General</h1>');
++ });
++ await live.close();
++ done();
++})();
ddiff --git a/test/forum.test.js b/test/forum.test.js
nnew file mode 100644
iindex 0000000..f7975d6
---- /dev/null
++++ b/test/forum.test.js
@@@ -0,0 +1,206 @@
++'use strict';
++/**
++ * Forum rules and /api/v1/spaces + /api/v1/posts: seeded spaces, thread creation (slugs,
++ * validation, limits), replies and counters, sorting (hot formula, new, top, pinned first,
++ * pagination), votes, edits with versions, deletes, locks, members/staff spaces, service
++ * capabilities (community.post.create, community.comment.moderate) and AI authorship.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const forumStore = require('../server/forum/store');
++
++(async () => {
++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
++ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
++ const POST = 'community.post.create', MOD = 'community.comment.moderate';
++ const forum = t.app.locals.forum;
++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
++
++ const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++
++ let first;
++
++ await check('seeded spaces: general, feedback, showcase — all public', async () => {
++ const r = await call('/api/v1/spaces');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public']]);
++ assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
++ });
++
++ await check('create thread: author from the JWT, slug from the title (deduplicated), opening post rendered safely', async () => {
++ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: ' Hello, World! ', body: '**hi** <script>alert(1)</script>', author_subject: sam.subject_id } });
++ assert.strictEqual(r.status, 201, r.text);
++ first = r.json().thread;
++ assert.strictEqual(first.slug, 'hello-world');
++ assert.strictEqual(first.title, 'Hello, World!');
++ assert.strictEqual(first.url, '/s/general/t/hello-world');
++ assert.strictEqual(first.author.subject, alex.subject_id);
++ const op = r.json().post;
++ assert.strictEqual(op.is_opening, true);
++ assert.strictEqual(op.body_html, '<p><strong>hi</strong> &lt;script&gt;alert(1)&lt;/script&gt;</p>');
++ const again = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world', body: 'dup title' } });
++ assert.strictEqual(again.json().thread.slug, 'hello-world-2');
++ const elsewhere = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world!', body: 'other space' } });
++ assert.strictEqual(elsewhere.json().thread.slug, 'hello-world', 'slugs are unique per space');
++ assert.strictEqual(forumStore.slugify('Ünïcödé — ☃ !!'), 'unicode');
++ assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');
++ });
++
++ await check('create thread: validation and who may post', async () => {
++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);
++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');
++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: ' ' } })).json().code, 'post.empty');
++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: 'x'.repeat(40_001) } })).json().code, 'post.too_long');
++ const noCap = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc(['community.comment.write']), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Via service', body: 'x' } });
++ assert.strictEqual(noCap.status, 403);
++ const noSubject = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), json: { title: 'Via service', body: 'x' } });
++ assert.strictEqual(noSubject.status, 401, 'a service names the person or says it is AI');
++ const ok = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'Via service', body: 'as sam' } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ assert.strictEqual(ok.json().thread.author.username, 'sam');
++ });
++
++ await check('AI threads: origin ai, no author subject, labelled', async () => {
++ const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { title: 'Stream highlights', body: 'AI summary' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().thread.origin, 'ai');
++ assert.strictEqual(r.json().thread.author.is_ai, true);
++ assert.strictEqual(r.json().thread.author.subject, null);
++ assert.strictEqual(t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id).author_subject, null);
++ });
++
++ await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
++ const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });
++ assert.strictEqual(r1.status, 201, r1.text);
++ assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);
++ await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });
++ const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
++ assert.strictEqual(th.thread.reply_count, 2);
++ assert.deepStrictEqual(th.posts.map((p) => p.body_markdown), ['**hi** <script>alert(1)</script>', 'first reply', 'second reply']);
++ assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: samJwt, json: { locked: true } })).status, 403);
++ const lock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: adminJwt, json: { locked: true } });
++ assert.strictEqual(lock.status, 200, lock.text);
++ assert.strictEqual(lock.json().thread.locked, true);
++ const refused = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'let me in' } });
++ assert.strictEqual(refused.status, 403);
++ assert.strictEqual(refused.json().code, 'thread.locked');
++ assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: adminJwt, json: { body: 'mod note' } })).status, 201);
++ assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).status, 403);
++ const svcUnlock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([MOD]), json: { locked: false } });
++ assert.strictEqual(svcUnlock.status, 200, 'a moderating service acting as itself');
++ assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([POST]), json: { locked: false } })).status, 403);
++ });
++
++ await check('edits keep versions; only the author or a moderator edits', async () => {
++ const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
++ const reply = th.posts.find((p) => p.body_markdown === 'first reply');
++ assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: alexJwt, json: { body: 'hijack' } })).status, 403);
++ const e1 = await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'first reply (edited)' } });
++ assert.strictEqual(e1.status, 200, e1.text);
++ assert.strictEqual(e1.json().post.revision, 2);
++ await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: adminJwt, json: { body: 'moderated' } });
++ const v = await call(`/api/v1/posts/${reply.id}/versions`, { cookie: samJwt });
++ assert.deepStrictEqual(v.json().versions.map((x) => [x.revision, x.body_markdown]), [[1, 'first reply'], [2, 'first reply (edited)'], [3, 'moderated']]);
++ assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);
++ });
++
++ await check('votes on threads: add/change/remove, score recomputed, my_vote shown', async () => {
++ const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
++ assert.strictEqual((await vote(1, samJwt)).json().score, 1);
++ assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
++ assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
++ assert.strictEqual((await vote(0, samJwt)).json().score, 1);
++ assert.strictEqual((await vote(1)).status, 401);
++ assert.strictEqual((await vote('x', samJwt)).status, 400);
++ assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world', { cookie: alexJwt })).json().thread.my_vote, 1);
++ });
++
++ await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {
++ const space = forumStore.getSpace(t.db, 'showcase');
++ const mk = (title, score, hoursAgo) => {
++ const { thread } = forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });
++ t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")
++ .run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);
++ return thread;
++ };
++ t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);
++ mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144
++ mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192
++ mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75
++ mk('ancient', 3, 500);
++ const now = new Date('2026-09-22T12:00:00Z');
++ const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);
++ assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);
++ assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');
++ assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);
++ assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);
++ const ancient = t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get().id;
++ t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);
++ assert.strictEqual((await titles('hot'))[0], 'ancient');
++ const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });
++ const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });
++ assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);
++ const hot = t.db.prepare('SELECT ov_hot(5, 2) AS h').get().h;
++ assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');
++ });
++
++ await check('deletes: author or moderator; opening post deletes the thread; tombstones keep numbering', async () => {
++ const th = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Delete me later', body: 'op' } })).json().thread;
++ const reply = (await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'a reply' } })).json().post;
++ await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'another' } });
++ assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
++ assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: alexJwt })).status, 200);
++ const after = (await call(`/api/v1/spaces/general/threads/${th.slug}`)).json();
++ assert.strictEqual(after.thread.reply_count, 1);
++ assert.deepStrictEqual(after.posts.map((p) => p.deleted), [false, true, false]);
++ assert.strictEqual(after.posts[1].body_markdown, null);
++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`, { method: 'DELETE', cookie: alexJwt })).status, 403);
++ const op = after.posts[0];
++ assert.strictEqual((await call(`/api/v1/posts/${op.id}`, { method: 'DELETE', cookie: samJwt })).json().deleted, 'thread');
++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`)).status, 404);
++ const other = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Moderated away', body: 'x' } })).json().thread;
++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${other.slug}`, { method: 'DELETE', cookie: adminJwt })).status, 200);
++ });
++
++ await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {
++ t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
++ assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);
++ assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);
++ assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: adminJwt })).status, 200);
++ const listed = (await call('/api/v1/spaces', { cookie: samJwt })).json().spaces.map((s) => s.slug);
++ assert.ok(listed.includes('insiders') && !listed.includes('mods'));
++ assert.ok(!(await call('/api/v1/spaces')).json().spaces.some((s) => s.slug === 'insiders'));
++ assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: samJwt, json: { title: 'Sneaky', body: 'x' } })).status, 404);
++ assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: adminJwt, json: { title: 'Mod talk', body: 'x' } })).status, 201);
++ });
++
++ await check('person limits: thread cooldown and daily cap, reply duplicates', async () => {
++ const { createForumService } = require('../server/forum/service');
++ const strict = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 30 } } });
++ const who = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++ await strict.createThread(who, 'general', { title: 'One thread', body: 'x' });
++ await assert.rejects(strict.createThread(who, 'general', { title: 'Two thread', body: 'y' }), (e) => e.status === 429 && e.code === 'request.rate_limited');
++ const daily = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } });
++ await daily.createThread(who, 'general', { title: 'Second thread', body: 'x' });
++ await assert.rejects(daily.createThread(who, 'general', { title: 'Third thread', body: 'x' }), (e) => e.status === 429 && /Daily/.test(e.message));
++ await daily.reply(who, 'general', 'second-thread', { body: 'same words' });
++ await assert.rejects(daily.reply(who, 'general', 'second-thread', { body: 'same words' }), (e) => e.code === 'request.duplicate');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex a3f6083..8424a5c 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -9,6 +9,7 @@ const mockMedia = require('./mock-media');
* opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a
* fresh in-memory database, with Network and Media mocks behind it.
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
++ * opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
*/
async function boot(opts = {}) {
const liveSrv = await mockLive.start();
@@@ -31,7 +32,7 @@ async function boot(opts = {}) {
process.env.COMMUNITY_DB_PATH = ':memory:';
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
-- const appOpts = {};
++ const appOpts = { ...(opts.appOpts || {}) };
if (opts.authority === 'community') {
appOpts.db = require('../../server/db').openDb(':memory:');
appOpts.pasteLimits = opts.pasteLimits;
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
nnew file mode 100644
iindex 0000000..4f9d382
---- /dev/null
++++ b/test/markdown.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * The forum's Markdown renderer: the supported subset renders, and nothing a post contains can
++ * become markup of its own — raw HTML, dangerous link schemes, attribute breakouts, entity tricks.
++ */
++const assert = require('assert');
++const { renderMarkdown: md, markdownToText } = require('../server/render/markdown');
++const { check, done } = require('./helpers/app');
++
++(async () => {
++ await check('the supported subset renders', () => {
++ assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x &lt; y</code></p>');
++ assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');
++ assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');
++ assert.strictEqual(md('> quoted\n> more'), '<blockquote><p>quoted<br>\nmore</p></blockquote>');
++ assert.strictEqual(md('- a\n- b\n continued'), '<ul><li>a</li><li>b<br>\ncontinued</li></ul>');
++ assert.strictEqual(md('3. c\n4. d'), '<ol start="3"><li>c</li><li>d</li></ol>');
++ assert.strictEqual(md('---'), '<hr>');
++ assert.strictEqual(md('```\n<b>raw</b>\n```'), '<pre class="md-code"><code class="hljs language-plaintext">&lt;b&gt;raw&lt;/b&gt;</code></pre>');
++ assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));
++ assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&amp;c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');
++ assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');
++ assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
++ assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');
++ assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
++ });
++
++ await check('raw HTML is text, never markup', () => {
++ for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {
++ const out = md(evil);
++ assert.ok(!/<(script|img|iframe|svg|a )|<!--/i.test(out.replace(/<a href="[^"]*" rel="nofollow ugc noopener">/g, '')), `${evil} → ${out}`);
++ assert.ok(out.includes('&lt;'), 'escaped');
++ }
++ });
++
++ await check('links: only http(s), mailto, same-site paths and fragments; no attribute breakouts', () => {
++ for (const bad of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', '//evil.example/x', '/\\evil.example', 'java&#115;cript:alert(1)']) {
++ const out = md(`[click](${bad})`);
++ assert.ok(!out.includes('<a '), `${bad} must not become a link: ${out}`);
++ }
++ const quote = md('[x](https://a.b/"onmouseover="alert(1))');
++ assert.ok(!/\sonmouseover\s*=/i.test(quote) && !/href="[^"]*"[^ >]/.test(quote), `quotes stay inside the attribute: ${quote}`);
++ const bare = md('https://a.b/"><script>alert(1)</script>');
++ assert.ok(!bare.includes('<script>'), bare);
++ assert.ok(!/href="[^"]*"[^ >]/.test(bare), bare);
++ const nul = md('a\u0000b');
++ assert.strictEqual(nul, '<p>ab</p>');
++ });
++
++ await check('markdownToText strips the syntax for descriptions and feeds', () => {
++ assert.strictEqual(markdownToText('# Hi\n**b** [l](http://x) `c`\n\n- item'), 'Hi b l c item');
++ assert.strictEqual(markdownToText('x'.repeat(50), 10), `${'x'.repeat(9)}…`);
++ assert.strictEqual(markdownToText('<script>'), '<script>', 'plain text: the caller escapes it');
++ });
++
++ done();
++})();
ddiff --git a/test/pulse.test.js b/test/pulse.test.js
nnew file mode 100644
iindex 0000000..faddbdb
---- /dev/null
++++ b/test/pulse.test.js
@@@ -0,0 +1,179 @@
++'use strict';
++/**
++ * Pulse: Community's own public activity enters at write time (public pastes by people, threads
++ * and replies in public spaces) and leaves when it stops being public; other services publish
++ * with community.pulse.write (own refs only, public only, provenance kept); the list is cursor
++ * paginated and filterable by origin; AI items are labelled and never name a person; /pulse
++ * renders without JavaScript.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
++ const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
++ const pastes = t.app.locals.pastes;
++ const forum = t.app.locals.forum;
++ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
++ const PULSE = 'community.pulse.write';
++ const items = () => t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();
++ const bySource = (type, id) => t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));
++ const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (json !== undefined) h['content-type'] = 'application/json';
++ return t.get(path, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined });
++ };
++
++ await check('pastes: only public, person-written, not burn-after-read, not NSFW pastes enter', async () => {
++ const pub = (await pastes.createText(asAlex, { title: 'Public one', content: 'x' })).slug;
++ const anonPub = (await pastes.createText(anon, { title: 'Anonymous public', content: 'y' })).slug;
++ const unl = (await pastes.createText(asAlex, { content: 'z', visibility: 'unlisted' })).slug;
++ const priv = (await pastes.createText(asAlex, { content: 'p', visibility: 'private' })).slug;
++ const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;
++ const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;
++ const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;
++ const it = bySource('paste', pub);
++ assert.ok(it, 'public paste recorded');
++ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);
++ assert.strictEqual(bySource('paste', anonPub).actor_subject, null);
++ for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(bySource('paste', s), undefined, `${s} must not enter Pulse`);
++ });
++
++ await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {
++ const a = (await pastes.createText(asAlex, { title: 'Soon private', content: '1' })).slug;
++ const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;
++ const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;
++ await pastes.update(asAlex, a, { visibility: 'private' });
++ pastes.remove(asAlex, b);
++ assert.strictEqual(bySource('paste', a), undefined);
++ assert.strictEqual(bySource('paste', b), undefined);
++ t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran
++ assert.ok(bySource('paste', c), 'row still there');
++ const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);
++ assert.ok(!listed.includes(c), 'but never listed');
++ });
++
++ await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
++ const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
++ const it = bySource('thread', thread.id);
++ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);
++ const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
++ assert.strictEqual(bySource('post', post.id).title, 'Re: Pulse thread');
++ assert.strictEqual(bySource('post', post.id).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);
++ t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
++ const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
++ assert.strictEqual(bySource('thread', hidden.thread.id), undefined);
++ forum.deletePost(asAlex, post.id);
++ assert.strictEqual(bySource('post', post.id), undefined);
++ forum.deleteThread(asAlex, 'general', 'pulse-thread');
++ assert.strictEqual(bySource('thread', thread.id), undefined);
++ });
++
++ await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {
++ const body = { ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' };
++ assert.strictEqual((await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cookies: [`ov_token=${alexJwt}`] })).status, 403);
++ assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', json: body })).status, 403);
++ const noCap = await call('/api/v1/pulse/items', { method: 'POST', token: svc(['community.comment.write']), json: body });
++ assert.strictEqual(noCap.status, 403);
++ assert.strictEqual(noCap.json().code, 'capability.denied');
++ const ok = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body });
++ assert.strictEqual(ok.status, 201, ok.text);
++ const it = ok.json().item;
++ assert.deepStrictEqual(it.source, { service: 'live', type: 'clip', id: 'c1' });
++ assert.strictEqual(it.origin, 'user');
++ assert.strictEqual(it.actor.username, 'alex');
++ assert.strictEqual(it.label, null);
++ });
++
++ await check('POST /items: own refs only, public only, valid title/url/time; idempotent, provenance kept', async () => {
++ const post = (json, headers = {}, cap = [PULSE]) => call('/api/v1/pulse/items', { method: 'POST', token: svc(cap), headers, json });
++ const base = { ref: { service: 'live', type: 'vod', id: 'v9' }, title: 'A VOD', url: 'https://openvibe.live/vod/v9' };
++ assert.strictEqual((await post({ ...base, ref: { service: 'media', type: 'object', id: 'x' } })).json().code, 'pulse.foreign_ref');
++ assert.strictEqual((await post({ ...base, ref: { service: 'community', type: 'paste', id: 'x' } })).status, 403, 'nobody writes Community items from outside');
++ for (const visibility of ['unlisted', 'private', 'members']) assert.strictEqual((await post({ ...base, visibility })).json().code, 'pulse.not_public');
++ assert.strictEqual((await post({ ...base, url: 'javascript:alert(1)' })).json().code, 'pulse.invalid_url');
++ assert.strictEqual((await post({ ...base, title: '' })).json().code, 'pulse.invalid_title');
++ assert.strictEqual((await post({ ...base, origin: 'robot' })).json().code, 'pulse.invalid_origin');
++ assert.strictEqual((await post({ ...base, occurred_at: '2099-01-01T00:00:00Z' })).json().code, 'pulse.invalid_time');
++ assert.strictEqual((await post({ ...base, ref: { service: 'live', type: 'vod' } })).json().code, 'ref.invalid');
++ const first = await post({ ...base, occurred_at: '2026-09-20T10:00:00Z' }, { 'x-ov-subject': alex.subject_id });
++ assert.strictEqual(first.status, 201);
++ const again = await post({ ...base, title: 'A VOD (renamed)', origin: 'ai', occurred_at: '2026-09-21T10:00:00Z' }, { 'x-ov-subject': ids.newId('user') });
++ assert.strictEqual(again.status, 200, 'same source: updated, not duplicated');
++ const it = again.json().item;
++ assert.strictEqual(it.title, 'A VOD (renamed)');
++ assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');
++ assert.strictEqual(items().filter((i) => i.source_id === 'v9').length, 1);
++ });
++
++ await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {
++ const viaHeader = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm1' }, title: 'Chat went wild', url: 'https://openvibe.live/m/1', origin: 'user' } });
++ assert.strictEqual(viaHeader.status, 201, viaHeader.text);
++ assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);
++ const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });
++ assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);
++ assert.strictEqual(t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get().actor_subject, null);
++ const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });
++ assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);
++ });
++
++ await check('GET /api/v1/pulse: newest first, cursor pagination without gaps or repeats, ?origin= filter', async () => {
++ for (let i = 0; i < 5; i++) {
++ await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: { ref: { service: 'live', type: 'clip', id: `same-time-${i}` }, title: `Tie ${i}`, url: `https://openvibe.live/c/${i}`, occurred_at: '2026-09-21T08:00:00Z' } });
++ }
++ const all = (await call('/api/v1/pulse?limit=100')).json().items;
++ const times = all.map((i) => i.occurred_at);
++ assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest first');
++ const seen = [];
++ let after = '';
++ for (let guard = 0; guard < 50; guard++) {
++ const page = (await call(`/api/v1/pulse?limit=3${after ? `&after=${encodeURIComponent(after)}` : ''}`)).json();
++ seen.push(...page.items.map((i) => i.id));
++ if (!page.next_cursor) break;
++ after = page.next_cursor;
++ }
++ assert.deepStrictEqual(seen, all.map((i) => i.id), 'the cursor walks the same list');
++ const ai = (await call('/api/v1/pulse?origin=ai')).json().items;
++ assert.ok(ai.length === 2 && ai.every((i) => i.origin === 'ai' && i.actor === null));
++ assert.strictEqual((await call('/api/v1/pulse?origin=bots')).status, 400);
++ assert.strictEqual((await call('/api/v1/pulse?after=garbage')).status, 400);
++ });
++
++ await check('DELETE /items/:service/:type/:id retracts only the caller\'s own items', async () => {
++ assert.strictEqual((await call('/api/v1/pulse/items/media/object/x', { method: 'DELETE', token: svc([PULSE]) })).status, 403);
++ const r = await call('/api/v1/pulse/items/live/clip/c1', { method: 'DELETE', token: svc([PULSE]) });
++ assert.deepStrictEqual(r.json(), { removed: 1 });
++ assert.ok(!(await call('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === 'c1'));
++ });
++
++ await check('/pulse renders without JS: AI badge with no person, filters and "Older" as links, noindex deep pages', async () => {
++ const r = await t.get('/pulse');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.community/pulse">'));
++ assert.ok(r.text.includes('<meta name="robots" content="index,follow">'));
++ assert.ok(r.text.includes('Chat went wild'));
++ assert.ok(r.text.includes('<span class="badge badge-ai" title="Generated by AI — not written by a person">AI</span>'));
++ assert.ok(r.text.includes('href="/pulse?origin=ai"'));
++ const aiOnly = await t.get('/pulse?origin=ai');
++ assert.ok(aiOnly.text.includes('Chat went wild') && !aiOnly.text.includes('A VOD (renamed)'));
++ const aiRow = aiOnly.text.slice(aiOnly.text.indexOf('Chat went wild') - 600, aiOnly.text.indexOf('Chat went wild') + 400);
++ assert.ok(!aiRow.includes('Alex'), 'an AI item never shows a person');
++ assert.ok(r.text.includes('rel="nofollow ugc noopener"'), 'links to other sites are nofollow');
++ const small = await t.get('/pulse?origin=user');
++ assert.strictEqual(small.status, 200);
++ const cursor = (await call('/api/v1/pulse?limit=2')).json().next_cursor;
++ const deep = await t.get(`/pulse?after=${encodeURIComponent(cursor)}`);
++ assert.ok(deep.text.includes('<meta name="robots" content="noindex,follow">'));
++ const bad = await t.get('/pulse?after=nonsense');
++ assert.strictEqual(bad.status, 302);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/relay.test.js b/test/relay.test.js
nnew file mode 100644
iindex 0000000..dd9fd95
---- /dev/null
++++ b/test/relay.test.js
@@@ -0,0 +1,230 @@
++'use strict';
++/**
++ * Discord relay (outbound): off unless DISCORD_RELAY_ENABLED; one delivery per (thread, mapping);
++ * the webhook URL comes from the environment variable a mapping names; retries with backoff on
++ * 5xx/429/network errors, immediate failure on other 4xx; loop prevention for Discord-origin
++ * threads; members/staff spaces never relayed; the staff admin endpoint. A stub webhook server
++ * stands in for Discord; the relay's clock is injected so backoff is tested without waiting.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const { openDb } = require('../server/db');
++const forumStore = require('../server/forum/store');
++const { createDiscordRelay } = require('../server/relay/discord');
++const { sqlTime } = require('../server/http/v1');
++
++function stubWebhook() {
++ const hits = [];
++ const plan = []; // queued responses: { status, body, headers }
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ hits.push({ path: req.url, body: raw ? JSON.parse(raw) : null });
++ const next = plan.shift() || { status: 204 };
++ if (next.hang) return; // never answers
++ res.writeHead(next.status, { 'Content-Type': 'application/json', ...(next.headers || {}) });
++ res.end(next.body ? JSON.stringify(next.body) : '');
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`, hits, plan, close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++(async () => {
++ const hook = await stubWebhook();
++ const db = openDb(':memory:');
++ const general = forumStore.getSpace(db, 'general');
++ const feedback = forumStore.getSpace(db, 'feedback');
++ const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };
++ let clock = Date.parse('2026-09-22T12:00:00Z');
++ const now = () => clock;
++ const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });
++ const alex = ids.newId('user');
++ require('../server/pastes/store').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });
++ relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
++ relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });
++ const newThread = (space, title, extra = {}) => forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra }).thread;
++ const deliveries = () => db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
++ const settle = () => new Promise((r) => setImmediate(r));
++
++ await check('off by default: nothing is queued or sent', async () => {
++ const off = createDiscordRelay({ db, env, now });
++ assert.strictEqual(off.enabled, false);
++ assert.strictEqual(off.enqueueThread(newThread(general, 'Quiet thread'), general), 0);
++ assert.deepStrictEqual(await off.drain(), { delivered: 0, retry: 0, failed: 0 });
++ assert.strictEqual(deliveries().length, 0);
++ });
++
++ await check('a new thread in a mapped space is posted once, with attribution, a link, and no mentions', async () => {
++ const th = newThread(general, 'Relay me <please>');
++ assert.strictEqual(relay.enqueueThread(th, general), 1);
++ assert.strictEqual(relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');
++ await settle();
++ await relay.drain();
++ assert.strictEqual(hook.hits.length, 1);
++ const msg = hook.hits[0];
++ assert.strictEqual(msg.path, '/api/webhooks/1/general');
++ assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });
++ assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/s\/general\/t\/relay-me-please>$/);
++ assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');
++ assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');
++ assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
++ assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');
++ const d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key], ['delivered', 1, 204, `thread:${th.id}:mapping:1`]);
++ await relay.drain();
++ assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');
++ });
++
++ await check('retries with exponential backoff on 5xx, then delivers', async () => {
++ hook.hits.length = 0;
++ hook.plan.push({ status: 502, body: { message: 'bad gateway' } });
++ const th = newThread(feedback, 'Flaky Discord');
++ relay.enqueueThread(th, feedback);
++ await settle(); await relay.drain();
++ let d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);
++ assert.match(d.last_error, /Discord answered 502: bad gateway/);
++ assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');
++ await relay.drain();
++ assert.strictEqual(hook.hits.length, 1, 'not due yet');
++ clock += 60_000;
++ hook.plan.push({ status: 500 });
++ await relay.drain();
++ d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');
++ clock += 120_000;
++ await relay.drain();
++ d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);
++ assert.strictEqual(hook.hits.length, 3);
++ });
++
++ await check('429 honours retry_after; exhausted attempts end as failed; other 4xx fail at once', async () => {
++ const th = newThread(general, 'Rate limited');
++ hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });
++ relay.enqueueThread(th, general);
++ await settle(); await relay.drain();
++ let d = deliveries().find((x) => x.thread_id === th.id);
++ assert.strictEqual(d.status, 'pending');
++ assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);
++ clock += 300_000;
++ hook.plan.push({ status: 503 }, { status: 503 });
++ await relay.drain();
++ clock += 10 * 60_000;
++ await relay.drain();
++ d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);
++
++ const gone = newThread(general, 'Webhook deleted');
++ hook.plan.push({ status: 404, body: { message: 'Unknown Webhook' } });
++ relay.enqueueThread(gone, general);
++ await settle(); await relay.drain();
++ d = deliveries().find((x) => x.thread_id === gone.id);
++ assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);
++ assert.match(d.last_error, /404: Unknown Webhook/);
++ });
++
++ await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {
++ const showcase = forumStore.getSpace(db, 'showcase');
++ relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });
++ const th = newThread(showcase, 'Env later');
++ relay.enqueueThread(th, showcase);
++ await settle(); await relay.drain();
++ let d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);
++ env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';
++ clock += 60_000;
++ await relay.drain();
++ d = deliveries().find((x) => x.thread_id === th.id);
++ assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');
++ assert.ok(d.last_error);
++ env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;
++ clock += 120_000;
++ await relay.drain();
++ d = deliveries().find((x) => x.thread_id === th.id);
++ assert.strictEqual(d.status, 'delivered');
++ const everything = JSON.stringify(db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(deliveries());
++ assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');
++ });
++
++ await check('loop prevention: Discord-origin threads are never relayed; members/staff spaces never leave the site', async () => {
++ const before = hook.hits.length;
++ const fromDiscord = newThread(general, 'Came from Discord', { origin: 'discord' });
++ assert.strictEqual(relay.enqueueThread(fromDiscord, general), 0);
++ // Even a delivery row that got queued somehow is refused at send time.
++ db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));
++ await relay.drain();
++ const d = deliveries().find((x) => x.thread_id === fromDiscord.id);
++ assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);
++ db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
++ const ins = forumStore.getSpace(db, 'insiders');
++ relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
++ assert.strictEqual(relay.enqueueThread(newThread(ins, 'Members only'), ins), 0);
++ assert.strictEqual(hook.hits.length, before);
++ });
++
++ await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget', async () => {
++ relay.setMappingEnabled(1, false);
++ assert.strictEqual(relay.enqueueThread(newThread(general, 'Mapping off'), general), 0);
++ relay.setMappingEnabled(1, true);
++ const failed = deliveries().find((x) => x.status === 'failed' && x.last_status === 404);
++ assert.strictEqual(relay.retry(failed.id), 1);
++ await settle(); await relay.drain();
++ assert.strictEqual(deliveries().find((x) => x.id === failed.id).status, 'delivered');
++ assert.strictEqual(relay.retry(failed.id), 0, 'delivered ones stay delivered');
++ });
++
++ await check('app wiring: a thread created through the API is relayed; the admin endpoint is staff-only and URL-free', async () => {
++ const t = await boot({
++ authority: 'community',
++ appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/9/app` } }, forumLimits: { threads: { cooldownSec: 0 } } },
++ });
++ const net = t.network;
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', role: 'user' });
++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
++ const call = (path, { method = 'GET', cookie, token, json, headers = {} } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (json !== undefined) h['content-type'] = 'application/json';
++ return t.get(path, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);
++ const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });
++ assert.strictEqual(bad.status, 400);
++ assert.strictEqual(bad.json().code, 'relay.invalid_ref');
++ const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } });
++ assert.strictEqual(m.status, 201, m.text);
++ assert.deepStrictEqual([m.json().mapping.space, m.json().mapping.webhook_configured, m.json().mapping.enabled], ['general', true, true]);
++ const before = hook.hits.length;
++ const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Through the app', body: 'hi' } });
++ assert.strictEqual(th.status, 201);
++ for (let i = 0; i < 50 && hook.hits.length === before; i++) await new Promise((r) => setTimeout(r, 10));
++ assert.strictEqual(hook.hits.length, before + 1);
++ assert.strictEqual(hook.hits[hook.hits.length - 1].path, '/api/webhooks/9/app');
++ hook.plan.push({ status: 400, body: { message: 'Invalid Form Body' } });
++ await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'This one fails', body: 'hi' } });
++ for (let i = 0; i < 50 && hook.hits.length === before + 1; i++) await new Promise((r) => setTimeout(r, 10));
++ await t.app.locals.relay.drain();
++ assert.strictEqual((await call('/api/v1/relay/deliveries')).status, 401);
++ assert.strictEqual((await call('/api/v1/relay/deliveries', { cookie: samJwt })).status, 403);
++ const failed = await call('/api/v1/relay/deliveries?status=failed', { cookie: adminJwt });
++ assert.strictEqual(failed.status, 200, failed.text);
++ assert.strictEqual(failed.json().enabled, true);
++ assert.strictEqual(failed.json().deliveries.length, 1);
++ assert.match(failed.json().deliveries[0].last_error, /400: Invalid Form Body/);
++ assert.strictEqual(failed.json().deliveries[0].thread.url, '/s/general/t/this-one-fails');
++ const svcAdmin = await call('/api/v1/relay/deliveries', { token: net.signService({ cap: ['community.comment.moderate'] }) });
++ assert.strictEqual(svcAdmin.status, 200);
++ assert.ok(!(failed.text + svcAdmin.text + (await call('/api/v1/relay/mappings', { cookie: adminJwt })).text).includes('/api/webhooks/'), 'responses never carry a webhook URL');
++ await t.close();
++ });
++
++ await hook.close();
++ done();
++})();
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex ff24724..e3b6061 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
// Navbar/footer init payload matches the shared-chrome contract.
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.service, 'community');
-- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/new']);
++ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/s', '/pulse', '/new']);
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
ddiff --git a/test/votes-race.test.js b/test/votes-race.test.js
nnew file mode 100644
iindex 0000000..95266bf
---- /dev/null
++++ b/test/votes-race.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++/**
++ * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same IMMEDIATE
++ * transaction (server/votes.js), so however writes interleave the stored score is the sum of the
++ * vote rows. Checked two ways against a real database file:
++ * 1. two connections in one process, their writes interleaved step by step;
++ * 2. four worker threads hammering the same comment and thread at once.
++ */
++const { Worker, isMainThread, workerData, parentPort } = require('worker_threads');
++const path = require('path');
++const fs = require('fs');
++const os = require('os');
++
++if (!isMainThread) {
++ // ── worker: random votes by a small crowd, as fast as it can ──
++ const { openDb } = require('../server/db');
++ const { applyVote } = require('../server/votes');
++ const db = openDb(workerData.file);
++ let seed = workerData.seed;
++ const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
++ let done = 0;
++ try {
++ for (let i = 0; i < workerData.ops; i++) {
++ const subject = workerData.subjects[rand(workerData.subjects.length)];
++ const value = [1, -1, 0][rand(3)];
++ if (rand(2)) applyVote(db, 'comment', workerData.commentId, subject, value);
++ else applyVote(db, 'thread', workerData.threadId, subject, value);
++ done++;
++ }
++ parentPort.postMessage({ ok: true, done });
++ } catch (err) {
++ parentPort.postMessage({ ok: false, done, error: err.message });
++ } finally { db.close(); }
++ return;
++}
++
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const { applyVote } = require('../server/votes');
++const commentStore = require('../server/comments/store');
++const forumStore = require('../server/forum/store');
++const { check, done } = require('./helpers/app');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-votes-'));
++const file = path.join(dir, 'votes.db');
++const a = openDb(file);
++const b = openDb(file);
++
++const { thread: cthread } = commentStore.resolveThread(a, { service: 'live', type: 'vod', id: 'race' });
++const comment = commentStore.insertComment(a, { thread_id: cthread.id, message: 'vote on me' });
++const space = forumStore.getSpace(a, 'general');
++const { thread } = forumStore.createThread(a, { space_id: space.id, title: 'Race thread', body_markdown: 'go' });
++const subjects = Array.from({ length: 10 }, () => ids.newId('user'));
++
++function invariant(db, label) {
++ const c = db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
++ const cv = db.prepare(`SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(value = 1), 0) AS up, COALESCE(SUM(value = -1), 0) AS down FROM comment_votes WHERE comment_id = ?`).get(comment.id);
++ assert.deepStrictEqual([c.score, c.upvotes, c.downvotes], [cv.s, cv.up, cv.down], `${label}: comment score is the sum of its votes`);
++ const t = db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);
++ const tv = db.prepare('SELECT COALESCE(SUM(value), 0) AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);
++ assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);
++ const dup = db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM comment_votes WHERE comment_id = ? GROUP BY subject_id HAVING COUNT(*) > 1)').get(comment.id).n;
++ assert.strictEqual(dup, 0, `${label}: one vote per person`);
++}
++
++(async () => {
++ await check('interleaved UPSERTs on two connections: add, flip, remove — the score never drifts', () => {
++ const [s1, s2, s3] = subjects;
++ const steps = [
++ [a, s1, 1], [b, s1, -1], [a, s2, 1], [b, s2, 1], [a, s1, -1], [b, s3, -1],
++ [a, s3, 0], [b, s1, 1], [a, s2, 0], [b, s2, -1], [a, s3, 1], [b, s3, 1],
++ ];
++ for (const [db, s, v] of steps) {
++ const out = applyVote(db, 'comment', comment.id, s, v);
++ applyVote(db === a ? b : a, 'thread', thread.id, s, v);
++ invariant(a, 'after step'); invariant(b, 'other connection');
++ assert.strictEqual(out.my_vote, v);
++ }
++ // Final state is the last write per person: s1 +1, s2 −1, s3 +1.
++ const c = a.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
++ assert.deepStrictEqual(c, { score: 1, upvotes: 2, downvotes: 1 });
++ });
++
++ await check('a stale reader cannot overwrite a newer score: the recount reads the rows inside the write lock', () => {
++ // Connection b reads, a writes, then b writes: b's score comes from the rows, not from what it read.
++ const staleScore = b.prepare('SELECT score FROM comments WHERE id = ?').get(comment.id).score;
++ applyVote(a, 'comment', comment.id, subjects[4], 1);
++ const out = applyVote(b, 'comment', comment.id, subjects[5], 1);
++ assert.strictEqual(out.score, staleScore + 2);
++ invariant(a, 'stale reader');
++ });
++
++ await check('four worker threads voting concurrently on one comment and one thread: invariants hold, no busy errors', async () => {
++ const results = await Promise.all([1, 2, 3, 4].map((n) => new Promise((resolve, reject) => {
++ const w = new Worker(__filename, { workerData: { file, seed: n * 7919, ops: 250, subjects, commentId: comment.id, threadId: thread.id } });
++ w.once('message', resolve);
++ w.once('error', reject);
++ })));
++ for (const r of results) assert.ok(r.ok, `worker failed: ${r.error}`);
++ assert.strictEqual(results.reduce((s, r) => s + r.done, 0), 1000);
++ invariant(a, 'after the storm');
++ const rows = a.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(comment.id).n;
++ assert.ok(rows <= subjects.length);
++ });
++
++ a.close(); b.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ done();
++})();