openvibers/openvibe.community Commits
Wave 5: typed comment threads, forum (spaces/threads/posts), Discord relay, Pulse
Changes
@@@ -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@@@ -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) thatserver-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 sothe 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.++## SEOEvery page carries a title, description, canonical, robots, Open Graph + Twitter card andJSON-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 limitsrender/layout.js page shell: SEO head, shared chrome, hashed assetsrender/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 postsrender/highlight.js highlight.js wrapper, language list, download extensionsseo.js robots, sitemap, RSS, JSON-LD builderspublic/ 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-shdeploy/ systemd unit, nginx vhostscripts/import-pastes.js Media paste bundle importertest/ 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 servicesnnew file mode 100644@@@ -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/*"++ ]++}nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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"++ ]++}@@@ -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",@@@ -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; }}@@@ -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;nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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,++ },};@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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) {nnew file mode 100644@@@ -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 };@@@ -55,8 +55,10 @@ const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, mifunction 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 = {}, limif (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 = {}, limconst 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 = {}, limif (!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++;}})();nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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://…), > 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 };@@@ -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">nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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, '<img src=x onerror=alert(1)></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 <b>embed</b> 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, '<script>alert(1)</script>');++ 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&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&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 <b>embed</b> 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();++})();nnew file mode 100644@@@ -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> <script>alert(1)</script></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();++})();@@@ -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;nnew file mode 100644@@@ -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 < 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"><b>raw</b></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&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('<'), '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', 'javascript: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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();@@@ -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}');nnew file mode 100644@@@ -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();++})();
@@ -42,7 +42,20 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
@@ -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)
# 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).
# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
PASTES_AUTHORITY=live
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
COMMUNITY_DB_PATH=./data/community.db
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
#VIEW_HASH_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
@@ -3,8 +3,9 @@
@@ -3,8 +3,9 @@
**https://openvibe.community — the people of OpenVibe.**
**https://openvibe.community — the people of OpenVibe.**
The community hub of the OpenVibe network: community-run, open source, free speech within
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
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
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
@@ -92,12 +93,22 @@ Pages (server-rendered HTML):
@@ -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 /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 /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 /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:
API and machine endpoints:
| Route | What |
| 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`) |
| `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 /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/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` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
@@ -105,18 +116,198 @@ API and machine endpoints:
@@ -105,18 +116,198 @@ API and machine endpoints:
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `POST /auth/refresh` | Rotate via refresh token |
| `POST /auth/refresh` | Rotate via refresh token |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `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
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`
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`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
`account`).
`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
## SEO
Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
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`
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
## Configuration
@@ -138,6 +329,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -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) |
| `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 |
| `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`) |
| `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 |
| `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 |
| `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
@@ -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
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
Before flipping to `community`, the Network's `community` OAuth client needs the
`identity.subject.resolve` capability (audience `openvibe.network`) and
`identity.subject.resolve` capability (audience `openvibe.network`) and
@@ -195,8 +391,20 @@ server/
@@ -195,8 +391,20 @@ server/
pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
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/source.js where pages read pastes from (Live or the store)
pastes/catalog.js recent public pastes: trending, related, language filter
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/layout.js page shell: SEO head, shared chrome, hashed assets
render/pages.js home / browse / paste / new / my / error templates
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
render/highlight.js highlight.js wrapper, language list, download extensions
seo.js robots, sitemap, RSS, JSON-LD builders
seo.js robots, sitemap, RSS, JSON-LD builders
public/ css/community.css, js/community.js, favicon.svg, og-default.png
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
@@ -204,14 +412,15 @@ vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-sh
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
scripts/import-pastes.js Media paste bundle importer
scripts/import-pastes.js Media paste bundle importer
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
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
## Related services
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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/*"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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"
+ ]
+}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-community",
"name": "openvibe-community",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"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",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -150,6 +150,48 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -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 { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
.alert-error { border-color: var(--danger); color: var(--danger); }
.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 */
/* Errors + footer host */
.error { text-align: center; padding: 60px 0; }
.error { text-align: center; padding: 60px 0; }
.error .btn { margin: 4px; }
.error .btn { margin: 4px; }
@@ -161,4 +203,6 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -161,4 +203,6 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.code-wrap { max-height: none; }
.code-wrap { max-height: none; }
.gutter { display: none; }
.gutter { display: none; }
.code-wrap { grid-template-columns: 1fr; }
.code-wrap { grid-template-columns: 1fr; }
+ .thread-row, .pulse-item { flex-wrap: wrap; }
+ .thread-head { gap: 10px; }
}
}
@@ -7,13 +7,17 @@
@@ -7,13 +7,17 @@
*
*
* Pages (server-rendered) API / machine
* Pages (server-rendered) API / machine
* GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below
* 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):
* PASTES_AUTHORITY (config.pastesAuthority):
* 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
* 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
@@ -36,6 +40,20 @@ const pages = require('./render/pages');
@@ -36,6 +40,20 @@ const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
const { createPastesProxy } = require('./pastes/proxy');
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 { extensionFor } = require('./render/highlight');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -81,22 +99,30 @@ function createApp(opts = {}) {
@@ -81,22 +99,30 @@ function createApp(opts = {}) {
app.use('/auth', createAuthRoutes(config, auth));
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')); }
{ 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';
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) {
if (community) {
- const { openDb, getDb } = require('./db');
- const { createNetworkIdentity } = require('./identity/network');
- const { createViewerResolver } = require('./identity/viewer');
const { createMediaFiles } = require('./media/files');
const { createMediaFiles } = require('./media/files');
const { createPasteService } = require('./pastes/service');
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 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);
source.use(service);
- app.locals.db = db;
app.locals.pastes = service;
app.locals.pastes = service;
} else {
} else {
source.use(null);
source.use(null);
@@ -117,6 +143,20 @@ function createApp(opts = {}) {
@@ -117,6 +143,20 @@ function createApp(opts = {}) {
if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));
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 }));
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/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
@@ -249,6 +289,16 @@ function createApp(opts = {}) {
@@ -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) => {
app.get('/my', withUser, wrap(async (req, res) => {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
let pastes = [], total = 0, error = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
@@ -51,6 +51,21 @@ module.exports = {
@@ -51,6 +51,21 @@ module.exports = {
// 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
// '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).
// 'community' — this site's own database is the authority (server/pastes/api.js).
pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
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',
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,
+ },
};
};
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* Community's own database (SQLite, better-sqlite3).
* Community's own database (SQLite, better-sqlite3).
*
*
* Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
* 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
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* of what the Network says about them.
* of what the Network says about them.
*
*
@@ -122,6 +123,172 @@ CREATE TABLE IF NOT EXISTS legacy_id_map (
@@ -122,6 +123,172 @@ CREATE TABLE IF NOT EXISTS legacy_id_map (
PRIMARY KEY (source_system, source_type, source_id)
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 (
CREATE TABLE IF NOT EXISTS import_hold (
source_type TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
source_id TEXT NOT NULL,
@@ -132,6 +299,22 @@ CREATE TABLE IF NOT EXISTS import_hold (
@@ -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. */
/** Open (creating the directory and schema if needed). ':memory:' works for tests. */
function openDb(file) {
function openDb(file) {
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
@@ -140,6 +323,7 @@ function openDb(file) {
@@ -140,6 +323,7 @@ function openDb(file) {
db.pragma('foreign_keys = ON');
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
db.exec(SCHEMA);
+ registerFunctions(db);
return db;
return db;
}
}
@@ -150,4 +334,4 @@ function getDb() {
@@ -150,4 +334,4 @@ function getDb() {
return _shared;
return _shared;
}
}
-module.exports = { openDb, getDb, SCHEMA };
+module.exports = { openDb, getDb, registerFunctions, SCHEMA };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,306 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
@@ -13,13 +13,15 @@
@@ -13,13 +13,15 @@
* openvibe.community. It names the person it acts for in X-OV-Subject; with no subject the
* 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
* 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).
* 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
* 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.
* judged on that token alone: a bad one is refused, never downgraded to anonymous.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
+const { checkCapability } = require('./capabilities');
const { ids, capabilities, serviceAuth, http } = contracts;
const { ids, capabilities, serviceAuth, http } = contracts;
const STAFF_ROLES = new Set(['admin', 'global_mod']);
const STAFF_ROLES = new Set(['admin', 'global_mod']);
@@ -70,13 +72,19 @@ function createViewerResolver({ auth, config, network }) {
@@ -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') {
if (req.get('x-ov-staff') === '1') {
const c = capabilities.check(claims, 'community.paste.moderate');
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) {
async function fromUserToken(token) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
@@ -55,8 +55,10 @@ const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, mi
@@ -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 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 };
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
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');
|| 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
@@ -269,6 +271,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
function created(row, extra = {}) {
function created(row, extra = {}) {
+ tell((p) => p.pasteCreated(row));
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
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
@@ -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 (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
const row = store.updatePaste(db, p.id, patch, v.subject || null);
const row = store.updatePaste(db, p.id, patch, v.subject || null);
+ tell((pl) => pl.pasteChanged(row));
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
@@ -442,6 +446,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -442,6 +446,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const p = visible(v, slug);
const p = visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
store.softDelete(db, p.id);
store.softDelete(db, p.id);
+ tell((pl) => pl.pasteGone(p.slug));
return { success: true };
return { success: true };
},
},
@@ -570,6 +575,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -570,6 +575,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!p) { skipped++; continue; }
if (!p) { skipped++; continue; }
if (action === 'delete') store.softDelete(db, p.id);
if (action === 'delete') store.softDelete(db, p.id);
else store.setVisibility(db, p.id, action);
else store.setVisibility(db, p.id, action);
+ if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
done++;
done++;
}
}
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -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://…), > 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 };
@@ -14,7 +14,7 @@ const { escapeHtml } = require('./highlight');
@@ -14,7 +14,7 @@ const { escapeHtml } = require('./highlight');
const SITE_NAME = 'OpenVibe.Community';
const SITE_NAME = 'OpenVibe.Community';
const NETWORK_URL = 'https://openvibe.network';
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`;
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
// 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) {
@@ -45,6 +45,8 @@ function navbarInit(opts) {
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: [
links: [
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
{ 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' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
],
],
menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
@@ -68,6 +70,8 @@ function footerInit(opts) {
@@ -68,6 +70,8 @@ function footerInit(opts) {
heading: 'Community',
heading: 'Community',
items: [
items: [
{ name: 'Pastes', url: '/pastes' },
{ name: 'Pastes', url: '/pastes' },
+ { name: 'Spaces', url: '/s' },
+ { name: 'Pulse', url: '/pulse' },
{ name: 'New paste', url: '/new' },
{ name: 'New paste', url: '/new' },
{ name: 'My pastes', url: '/my' },
{ name: 'My pastes', url: '/my' },
{ name: 'What is coming', url: '/#coming' },
{ name: 'What is coming', url: '/#coming' },
@@ -80,7 +84,8 @@ function footerInit(opts) {
@@ -80,7 +84,8 @@ function footerInit(opts) {
/**
/**
* @param {object} o
* @param {object} o
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
* 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,
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* published/modified (ISO, for article:*), noChrome (error pages during outages)
* 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
@@ -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:description" content="${escapeHtml(description)}">
<meta name="twitter:image" content="${escapeHtml(ogImage)}">
<meta name="twitter:image" content="${escapeHtml(ogImage)}">
${require('openvibe-shared/app-icon').headTags({ site: 'community', iconBase: '/assets' })}
${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>
<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
<link rel="stylesheet" href="${asset('css/community.css')}">
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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 };
@@ -94,10 +94,11 @@ function homePage({ latest, trending, languages, user }) {
@@ -94,10 +94,11 @@ function homePage({ latest, trending, languages, user }) {
<section class="hero">
<section class="hero">
<p class="eyebrow">OpenVibe.Community</p>
<p class="eyebrow">OpenVibe.Community</p>
<h1>Share it with a link. Talk about it here.</h1>
<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">
<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 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="/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>`}
${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>
</div>
<ul class="hero-points">
<ul class="hero-points">
@@ -129,15 +130,15 @@ function homePage({ latest, trending, languages, user }) {
@@ -129,15 +130,15 @@ function homePage({ latest, trending, languages, user }) {
<section class="section coming" id="coming">
<section class="section coming" id="coming">
<h2>What is coming</h2>
<h2>What is coming</h2>
<div class="coming-grid">
<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 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>
</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>`;
</section>`;
return renderPage({
return renderPage({
title: null,
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: '/',
canonicalPath: '/',
active: 'home',
active: 'home',
jsonLd: [seo.websiteLd()],
jsonLd: [seo.websiteLd()],
@@ -382,4 +383,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
@@ -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 });
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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 };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'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 config = require('./config');
const catalog = require('./pastes/catalog');
const catalog = require('./pastes/catalog');
@@ -11,6 +11,11 @@ const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
@@ -11,6 +11,11 @@ const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
const SITEMAP_TTL_MS = 60 * 60 * 1000;
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) {
function isoDate(v) {
if (!v) return null;
if (!v) return null;
const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
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 }) {
@@ -74,6 +79,45 @@ function pasteLd(paste, { description, image }) {
return base;
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 ───────────────────────────────────────────────
// ── robots.txt ───────────────────────────────────────────────
function robotsTxt() {
function robotsTxt() {
return [
return [
@@ -83,6 +127,7 @@ function robotsTxt() {
@@ -83,6 +127,7 @@ function robotsTxt() {
'Disallow: /auth/',
'Disallow: /auth/',
'Disallow: /my',
'Disallow: /my',
'Disallow: /new',
'Disallow: /new',
+ 'Disallow: /s/*/new',
'Disallow: /*?sso=',
'Disallow: /*?sso=',
'',
'',
`Sitemap: ${config.baseUrl}/sitemap.xml`,
`Sitemap: ${config.baseUrl}/sitemap.xml`,
@@ -104,6 +149,12 @@ async function buildSitemap() {
@@ -104,6 +149,12 @@ async function buildSitemap() {
if (Number(p.is_nsfw) || Number(p.burn_after_read)) continue;
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');
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`;
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) {
async function sitemapHandler(_req, res) {
@@ -143,6 +194,36 @@ ${items.join('\n')}
@@ -143,6 +194,36 @@ ${items.join('\n')}
res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(xml);
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; }
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,285 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -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, '<img src=x onerror=alert(1)></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 <b>embed</b> 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, '<script>alert(1)</script>');
+ 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&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&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 <b>embed</b> 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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -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> <script>alert(1)</script></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();
+})();
@@ -9,6 +9,7 @@ const mockMedia = require('./mock-media');
@@ -9,6 +9,7 @@ const mockMedia = require('./mock-media');
* opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a
* 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.
* fresh in-memory database, with Network and Media mocks behind it.
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
+ * opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
*/
*/
async function boot(opts = {}) {
async function boot(opts = {}) {
const liveSrv = await mockLive.start();
const liveSrv = await mockLive.start();
@@ -31,7 +32,7 @@ async function boot(opts = {}) {
@@ -31,7 +32,7 @@ async function boot(opts = {}) {
process.env.COMMUNITY_DB_PATH = ':memory:';
process.env.COMMUNITY_DB_PATH = ':memory:';
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
- const appOpts = {};
+ const appOpts = { ...(opts.appOpts || {}) };
if (opts.authority === 'community') {
if (opts.authority === 'community') {
appOpts.db = require('../../server/db').openDb(':memory:');
appOpts.db = require('../../server/db').openDb(':memory:');
appOpts.pasteLimits = opts.pasteLimits;
appOpts.pasteLimits = opts.pasteLimits;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 < 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"><b>raw</b></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&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('<'), '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', 'javascript: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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,230 @@
@@ -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();
+})();
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
// Navbar/footer init payload matches the shared-chrome contract.
// Navbar/footer init payload matches the shared-chrome contract.
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.service, 'community');
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.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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();
+})();