openvibers/openvibe.community README.md

README.md

main 67.6 KB · 891 lines · markdown

1![OpenVibe.Community](https://openvibe.community/shared/social/community.jpg)
2
3# OpenVibe.Community
4
5**The OpenVibe community home** — [openvibe.community](https://openvibe.community). The roadmap, feedback, pastes and Pulse, from the people who build and use the network.
6
7**Live site:** https://openvibe.community · **API docs:** https://openvibe.services · **For AI:** https://openvibe.community/llms.txt
8**Run it:** `npm ci && npm run dev` (Node 20 or newer) · **Tests:** `npm test`
9
10Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services).
11
12## Purpose
13
14The community hub of the OpenVibe network: community-run, open source, free speech within
15the rules. It is the home of **pastes** (code, text and screenshots with a link), the
16**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
17embeds, **Pulse**, the network's public activity, and [**submissions**](#submissions) — clips, art,
18ideas and reports people send in for community review.
19
20The forum is Community's: on 2026-10-08 the owner moved it back here from OpenVibe.Space
21(where it had lived briefly). OpenVibe.Space becomes code hosting and "spaces" — apps people
22build on their OpenVibe account, the network's services and the SDK — and redirects every
23forum URL here.
24
25It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that
26server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
27little progressive JavaScript for pagination, copy buttons and the upload path.
28
29## Owns
30
31- pastes (the only authority since 2026-09-22), their versions, likes and
32 comments
33- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
34 categories), Pulse (the network's public activity), submissions and the Discord relay
35- the `community.*` events, Search documents for public threads, and the `community.profile` user module
36- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);
37 embedded PGlite in development), with Valkey for the per-actor limit counters
38
39## Does not own
40
41- identity and SSO (OpenVibe.Network), file bytes (OpenVibe.Media keeps screenshots and attachments),
42 memberships (OpenVibe.VIP decides members-only spaces and threads), chat rooms (OpenVibe.Chat)
43- the content of the products whose threads it hosts: a product stores only the thread id
44
45## Depends on
46
47- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve`, the `community.profile` module)
48- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
49 (a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
50- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
51- `openvibe-contracts` v0.338.0, `openvibe-sdk` v0.89.0 (events outbox, per-actor limits, no-looks, share and the
52 Locker), `openvibe-shared` v4.168.0 (the Share sheet's Locked part and `ov-locker.js`), pinned by release tarball
53
54## How it fits the network
55
56Pastes moved to Community in roadmap Wave 5; since 2026-09-22 Community's own database is their only
57authority (there is no switch any more — `live-client.js` and the `/api/pastes` proxy were deleted) —
58see [Community as the paste authority](#community-as-the-paste-authority). The import brought
59889 + 2 pastes, 60 comments and 48 likes; 5 rows (1 paste, 4 comments, `ambiguous_owner`) wait in
60`import_hold`. Live and Tools forward paste writes here, and Media answers old paste URLs with a 301 to
61this site. On 2026-10-21 production held 1,193 pastes; 4,508 comment threads under other products' pages
62(Deals offers, Live recordings and clips, Host plans, Blog posts, pastes and wiki pages) with about 80 comments
63between them, most of them on pastes; 39 forum threads, all opened by the system (34 public-roadmap items and 5
64discussions), none by a person yet; and 300 Pulse items. The Discord relay is off. A restore drill passed on
652026-09-23.
66
67| Concern | Where it lives | How Community reaches it |
68| --- | --- | --- |
69| Paste storage + API | **Community's PostgreSQL** (`pastes`, `server/pastes/*`) | own database |
70| Screenshot / attachment bytes | **OpenVibe.Media** (`/api/v1/community/files`, objects v2) | service token (`media.object.upload`) |
71| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |
72| Raw text, screenshots | Community's own routes; bytes on Media's public host | served locally, or a 302 to the stored image |
73| OpenVibe Frame, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
74| VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |
75| A space's chat room | **OpenVibe.Chat** (`POST/DELETE /api/chat/rooms/:room/attachments`) | `OV_CHAT_INTERNAL_URL`, the signed-in person's own Network token (no service grant) |
76
77Community resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`),
78which is exactly what Community stores, so there is no second identity to map and no proxy. Creation,
79listing, anonymous-post limits, ownership and author names all come from Community's own rules
80(`server/pastes/service.js`).
81
82### Community as the paste authority
83
84Community is the only paste authority: `/api/pastes/*` is the native API
85(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
86response shapes the browser clients already use, and every page, `/p/:slug/raw`,
87`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
88redirects its old paste URLs here after cutover). The list (`GET /api/pastes`) also takes
89`origin=user|ai` (people's pastes or AI output), `sort=newest|oldest|top` (`top`: views, a like
90worth five), `since=` (created at or after; ISO 8601 or `YYYY-MM-DD HH:MM:SS` UTC) and
91`pinned_first=0` (pinned pastes stay in sort order), which is how OpenVibe.Live's Content and
92Moments feeds read it.
93
94- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
95 owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
96 cached in `legacy_id_map`). In responses `user_id` carries that subject id, next to
97 `owner_subject` and `origin`; names/avatars come from `subject_projection`, a cache
98 refreshed through the Network's `POST /internal/identity/resolve-batch` (service token,
99 `identity.subject.resolve`).
100- **Service callers** (Live's adapter, Live's AI jobs) send a Network service token for
101 audience `openvibe.community` and need the route's capability: `community.paste.create`
102 (create), `community.paste.write` (edit, delete, fork, like, copy, comment as someone),
103 `community.paste.moderate` (`/admin/*`, `/bulk`, `/:slug/censor`, the AI queue
104 `?needs_ai=1` and `POST /:slug/ai`). Headers: `X-OV-Subject: usr_…|gst_…` names the acting
105 person (no subject = anonymous); `X-OV-Origin: ai` stores AI output with origin `ai` and no
106 owner; `X-OV-Source-Ref: <EntityRef JSON>` records e.g. the stream; `X-OV-Staff: 1` (needs
107 moderate) vouches that the acting person is staff. Services may bring a `slug` and
108 `metadata`; browsers never. Identity is never read from bodies or queries.
109- **Limits** as before: 20 anonymous writes per 10 minutes per address (API and the no-JS
110 form share it); people get Media's 30 s paste cooldown, 200/day and comment limits.
111- **Screenshots**: new uploads lose their EXIF/XMP metadata and go to Media's file store
112 (`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
113 `media.object.upload`); the paste keeps the public URL and `media_ref`
114 `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
115- **Unsafe-image review (VIS-6)**: with `OV_VISION_URL` and Community's service principal, an image paste is
116 reviewed before it is returned. Vision sorts it into exactly three categories: `fine` keeps its visibility;
117 `sex_acts` becomes private and NSFW (gone from every public list, Pulse, search, sitemap and anonymous page,
118 kept for its owner); `minor_sexual` becomes private, NSFW and quarantined with the operator report id. If
119 Vision is unreachable the paste becomes private and `review_status='pending'`, never public; the visibility its
120 owner asked for is kept in `review_requested_visibility`/`_audience`/`_nsfw`, and the background retry
121 (`server/pastes/review-retry.js`, `COMMUNITY_REVIEW_RETRY_MS`, off at 0) restores it when a later review says
122 `fine`. Text pastes are not reviewed. `paste.review_category`, `review_status`, `review_report_id`,
123 `reviewed_at` and the requested fields are in the paste shape; the enforcement lives in `server/pastes/review.js`.
124 Tests: `test/paste-review.test.js`.
125- **Addresses**: a public paste's canonical address is `/p/<title-slug>-<public_id>`. The
126 `public_id` is 8 base58 characters, generated once (migration 0021; existing rows are filled by
127 `scripts/backfill-paste-public-ids.js` in batches, and a read fills one lazily). The title slug is
128 up to six lowercase ASCII words from the title (or the first content line when untitled, or
129 nothing), so the id alone is a valid address and every old `adj-noun-NN` slug 301s to the
130 canonical one; a retitle moves it and the old form 301s. A new unlisted or private paste (and a
131 fork of one) keeps an `adj-noun-` + 16 random base62 secret slug: its secret part is the only
132 thing keeping it unlisted, and its public id is not a second key. `server/pastes/url.js` owns
133 the rules; `test/paste-urls.test.js` pins them.
134- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
135
136Moving the data: Media's `openvibe.media.pastes-export` bundle was imported once (Wave 5, 2026-09-22; the ledger is
137`legacy_id_map`, the report `migration_runs`). The importer (`scripts/import-pastes.js`) was retired with the move to
138PostgreSQL and is in git history.
139
140`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network
141has no service-token lookup by username), so a person appears there once Community has seen
142them — signed in here, or named as an author in a resolved projection.
143
144## Endpoints
145
146Pages (server-rendered HTML):
147
148| Route | What |
149| --- | --- |
150| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
151| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
152| `GET /p/:slug` | Paste page at `/p/<title-slug>-<public id>` (old slugs and the bare id 301 there) — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
153| `POST /p/:slug/comments` | Comment on the paste (signed in; the no-JS form) — its typed comment thread, the one `/c/:accessId` and the APIs show |
154| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |
155| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |
156| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
157| `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` |
158| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
159| `GET /forum` | The Forum (board index); `GET /forum[/…]` answers 301 → `/forum[/…]` |
160| `GET /forum/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |
161| `GET /forum/:space/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |
162| `GET /forum/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |
163| `GET /forum/:space/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |
164| `GET /forum/:space/new`, `POST /forum/:space/new` | Start a thread (signed in) |
165| `POST /forum/:space/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |
166| `POST /forum/:space/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |
167| `POST /forum/:space/:slug/post/:id/delete` | A moderator removes one post, with the same optional `rule` and `reason` |
168| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
169| `GET /submissions`, `POST /submissions` | Accepted submissions — `?kind=clip\|art\|idea\|report`, `?mine=1` (your own, any status), `?after=`; signed-in people submit with the no-JS form |
170| `GET /submissions/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |
171| `POST /submissions/:slug/withdraw\|review` | The no-JS forms: the author withdraws; a moderator accepts or rejects with a note |
172| `GET /submissions/review` | The moderators' queue (pending, newest first) |
173| `GET /c/:accessId`, `POST /c/:accessId` | One comment thread's own page — the same thread the owner product embeds (e.g. a Live VOD), newest first, `?after=` for older; signed-in people comment with the no-JS form. Opens only by the unguessable access id, `noindex` |
174
175API and machine endpoints:
176
177| Route | What |
178| --- | --- |
179| `ANY /api/pastes/*` | The native API — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete and `/:slug/versions` |
180| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
181| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
182| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
183| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) |
184| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
185| `GET /api/v1/resources` | OpenVibe.Services' index of a person's pastes (`community.paste`) and the spaces they created (`community.space`): `common.resource-list-result@1`, filters `kind`, `owner`, `project` (always empty), `cursor`, `limit`. A service token with `community.resource.read` only; loopback (nginx answers 404 publicly). Summaries only, never paste content; deleted pastes left out; no OVRN (`/:ovrn` is 404) |
186| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
187| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
188| `GET /release.json`, `POST /release-metrics` | What this server runs (ADR-016 release manifest, `metrics_url: /release-metrics`); open tabs' update reports (release-watch's same-origin beacon, no auth) |
189| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
190| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
191| `GET /auth/logout` | Clears session, sets `ov_sso_hint=guest`, honours `?next=` |
192| `GET /auth/me` | Offline-verified profile from `ov_token` |
193| `POST /auth/refresh` | Rotate via refresh token |
194| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
195| `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full |
196| `GET /forum/feed.xml`, `GET /forum/:space/feed.xml` | RSS of the latest threads (public spaces) |
197| `GET /forum/:space/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |
198
199**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
200when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
201Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
202`media` (screenshot and file uploads) is optional: a failure keeps the site ready
203(comments, forum, Pulse and public reads still work) and is listed in `degraded` with
204`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.
205Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP
206golden signals by route template (`http_requests_total{method,route,status_class}`,
207`http_request_duration_seconds`, `http_requests_in_flight`), process metrics and
208`release_info`, and `release_client_updates_total{outcome,reason}` from the tabs' reports to
209`POST /release-metrics` (openvibe-shared `release.mount`); content counts (pastes, comments,
210threads) are deliberately not metrics.
211
212**Request telemetry (plan T1 step 10).** Each request is aggregated by route template, method and
213status class, never by raw URL. Once a minute one `http.request` sample per key, plus the
214active-requests, p95 latency and event-loop-lag gauges, is written to this service's own
215`telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape);
216rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and
217`/metrics` are not counted. `server/app.js` mounts openvibe-sdk's `telemetryMiddleware` (a no-op
218until init); `server/index.js` calls `telemetry.init()` at production start, runs the 14-day
219retention and flushes on stop. The table is created by migration `0009_telemetry.sql`.
220
221Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so
222the shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`
223(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
224`account`).
225
226## Comments API
227
228`/api/v1/comments` is the comment system every OpenVibe product embeds instead of owning
229comment tables. A thread belongs to one entity, named by an EntityRef
230(`common.entity-ref@1`: `{ service, type, id, label? }`), and is created the first time anyone
231resolves it. Errors are `application/problem+json` (`errors.problem@1`, with the legacy
232`error` field).
233
234| Route | What |
235| --- | --- |
236| `POST /threads/resolve` `{ ref }` | Get-or-create the entity's thread (201 created, 200 existing; idempotent, unique on service+type+id) |
237| `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 |
238| `POST /threads/:id/comments` `{ message, parent_id?, anon_name? }` | Comment. A reply to a reply joins the top-level comment's replies |
239| `GET /:commentId` | Services only: one comment and its thread (with the ref) — an owner product checks what a comment belongs to before it edits or deletes it for someone. Browsers get 404 (comment ids are sequential) |
240| `PATCH /:commentId` `{ message }` | The author only; sets `edited_at` |
241| `DELETE /:commentId` | The author or a moderator (soft; a top-level comment with replies stays as a tombstone) |
242| `POST /:commentId/votes` `{ value: 1\|-1\|0 }` | Add, change or remove your vote (people only) |
243| `PUT /threads/:id/visibility` `{ visibility: public\|hidden\|locked }` | Moderators — e.g. the owner service hides the thread of an entity it took down |
244
245Who may do what:
246
247- **Browsers** (the Network JWT as `ov_token` cookie here, or `Authorization: Bearer` from
248 another OpenVibe site through CORS — `API_CORS_ORIGINS`, never cookies) resolve threads only
249 for these types: `live` stream, channel · `media` object · `community` paste, post
250 · `wiki` page · `blog` post · `reviews` entity. Community's own refs must exist and be
251 visible. Labels are only taken from services. Live VODs and clips are resolved by Live only:
252 a private one is missing to everyone but its owners and staff, which only Live can decide, so
253 Live hands the access id only to people who may see the item.
254- **Signed-in only**: threads of `live` vod and clip take no anonymous comments (Live's rule) —
255 a browser without a subject, or a service naming nobody, gets 401 `auth.required`.
256- **Anonymous** visitors comment with an `anon_name`, under the same 20-writes-per-10-minutes
257 budget per address as anonymous pastes. People are limited per subject (10 s cooldown,
258 5 per minute, no duplicate in a row; 60 votes a minute), whichever way they write.
259- **Services** (Network service token, audience `openvibe.community`): `community.comment.write`
260 resolves any ref and comments/votes/deletes as `X-OV-Subject`, or as AI with
261 `X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").
262 `community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person it
263 vouches is staff with `X-OV-Staff: 1`.
264- **Thread ids**: every thread has an unguessable access id (`cth_` + 22 characters). Browsers
265 get it as the thread's `id` (from resolve) and can address a thread only by it; the sequential
266 id works for services only (their resolve answer carries it as `id`, plus `access_id` to hand to
267 a browser). Nobody can walk thread ids to find the refs and comments of entities they were not
268 given.
269- **Locked** threads can be read but take no comments or votes (moderators still may);
270 **hidden** threads are 404 for everyone but moderators.
271- **Paste comments** are the paste's typed thread (`community`/`paste`/`<slug>`): the paste page
272 (`/p/:slug` shows it newest first, `?sort=old` and `?after=<last comment id>` as links, `#comment-<id>`
273 anchors; `POST /p/:slug/comments`, signed in, the no-JS form; none on burn-after-read pastes), `/api/pastes/:slug/comments` (GET, POST and
274 DELETE keep their old shapes and rules: anonymous comments with a name, the author, the paste's
275 owner or staff delete; Live's adapter calls them as the person) and `/api/v1/comments` all read and
276 write that one thread. A paste thread follows the paste: private ones are their owner's and staff's
277 only, deleted and burned ones nobody's (404 on every call, by access id too, so a paste made private
278 later takes its comments with it); unlisted ones are open to whoever has the link. Migration
279 `0004_paste_comments_to_threads.sql` copied the old `paste_comments` rows onto the threads (ledger
280 `legacy_id_map`, `community`/`paste_comment` → `comment`); nothing writes `paste_comments` any more and
281 it stays, read-only, for one release.
282- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for the
283 viewer.
284
285### Live's VOD and clip comments
286
287Since roadmap Wave 5 (exit criterion: Live and a second product share one Community thread),
288OpenVibe.Live keeps no comments of its own: its `/api/comments/:type/:id` routes are an adapter
289over the thread of `{ service: 'live', type: 'vod'|'clip', id }` (Live's
290`server/comments-client.js`). Live resolves the thread with its service token after its own
291visibility check, reads and comments as the signed-in person (`X-OV-Subject`), edits as the
292author, and deletes as the author, as staff (`X-OV-Staff: 1`) or — for the VOD's or clip's
293owner — as itself (`community.comment.moderate`). Deleting a VOD or clip hides its thread. The
294same thread is this site's page `/c/<access id>`; Live links to it under a public or unlisted
295item's comments.
296
297**Checking that both show the same thread** (production or local):
298
2991. Open a public VOD on Live (`https://openvibe.live/vod/<id>`), post a comment, and follow
300 "View this thread on OpenVibe.Community" under the comments. The page `/c/cth_…` lists the
301 same comments, newest first, with the same authors.
3022. Comment on that Community page while signed in, reload the Live VOD: the new comment is
303 there. Delete it on Live: it is gone from the Community page.
3043. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's token
305 returns the comments both pages render (`test/comments.test.js` checks the page against it).
306
307**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;
308held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,
309`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is the
310screenshot-reference migration (`scripts/migrate-screenshot-refs.js`, applied 2026-09-25: no paste keeps a legacy media reference).
311
312Embedding it — server-side, from another product's backend (the usual way; the person is the
313one your own session says it is):
314
315```js
316const COMMUNITY = 'http://127.0.0.1:4200/api/v1/comments';
317const token = await tokens.authHeaders(); // openvibe-contracts serviceAuth token client,
318 // audience openvibe.community, community.comment.write
319const ref = { service: 'live', type: 'vod', id: String(vod.id), label: vod.title };
320const { thread } = await (await fetch(`${COMMUNITY}/threads/resolve`, {
321 method: 'POST', headers: { ...token, 'Content-Type': 'application/json' }, body: JSON.stringify({ ref }),
322})).json();
323const page = await (await fetch(`${COMMUNITY}/threads/${thread.id}`, { headers: token })).json();
324// page.comments[i] = { id, author: { subject, username, display_name, avatar_url } | null, anon_name,
325// display_name, origin, message, score, my_vote, reply_count, replies: [...], ... }
326await fetch(`${COMMUNITY}/threads/${thread.id}/comments`, {
327 method: 'POST',
328 headers: { ...token, 'X-OV-Subject': viewer.subjectId, 'Content-Type': 'application/json' },
329 body: JSON.stringify({ message: 'Great VOD!' }),
330});
331```
332
333In the browser, from a page on an allowed origin that holds the visitor's Network JWT:
334
335```js
336const api = (path, opts = {}) => fetch(`https://openvibe.community/api/v1/comments${path}`, {
337 ...opts, headers: { Authorization: `Bearer ${networkJwt}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
338}).then((r) => r.json());
339const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'stream', id: streamId } }) });
340let { comments, next_cursor } = await api(`/threads/${thread.id}`);
341if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`));
342await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) });
343```
344
345Render `message` as text (it is plain text, never HTML).
346
347## Forum: forums, threads, posts
348
349People see **the Forum** and **forums** (renamed from "Spaces" on 2026-10-17: openvibe.space is a different product).
350In the code, the database and the API a forum is still a `space` (`spaces`, `/api/v1/spaces`, `community.space.*`).
351
352Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces:
353`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone
354reads, people post), `members` (signed-in people only; every Network account counts as a
355member for now), `staff` (moderators only; looks missing to everyone else).
356
357**Share a draft into the forum.** Another OpenVibe site opens `/forum/discuss?title=…&body=…`
358(optionally with `&paste=<id>`, which attaches a paste) and the picker carries it to
359`/forum/<space>/new` with the fields filled in. Both are plain text, cut to 200 characters of
360title and 40,000 of body and escaped when shown; the link never posts, and a signed-out visitor
361signs in and comes back to the same prefilled form, served `private, no-store` and `noindex`.
362
363- **Pages work without JavaScript** — sorting and pagination are links, replying, voting,
364 pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` is
365 refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
366 JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;
367 members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap
368 lists `/forum`, `/pulse`, public spaces and the latest 1000 threads; `/forum/*/new` is disallowed in
369 robots.txt.
370- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is the
371 deterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`
372 (`server/db.js`), computed against one `now` per page.
373- **Markdown** (`server/render/markdown.js`) is a safe subset: everything is escaped first and
374 only fixed tags are written — paragraphs, line breaks, headings (demoted to h3–h6), quotes,
375 lists, `---`, fenced code (highlighted like pastes), inline code, bold/italic/strike, links
376 (http(s), mailto, same-site paths, fragments; `rel="nofollow ugc noopener"`). Raw HTML is
377 shown as text.
378- **Writers**: people (browser JWT, or a service with `community.post.create` naming them in
379 `X-OV-Subject`) and AI output (`X-OV-Origin: ai` — no author, labelled as AI). Anonymous
380 visitors read. Limits per person: a thread every 30 s, 3 a minute, 20 a day; a post every
381 10 s, 6 a minute, no duplicate in a row. Edits keep every revision in `post_versions`;
382 deletes are soft (tombstones keep the numbering; deleting the opening post deletes the
383 thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
384 pin, lock and delete; locked threads take no replies or votes.
385- **A space's own moderators** (`space_moderators`, migration 0005): people listed for one space act there as
386 moderators do everywhere: thread state, status and category, edits and deletes, replies in locked
387 threads, the VIP gate (they pass it), members-only, settings (name, description, style, votes, ratings,
388 kind), categories and the chat room. They cannot move the space on the board index (group, parent,
389 position), create spaces, manage the board index's groups, add roadmap items or open staff spaces. Those
390 stay with staff. Any moderator of a space, or staff, adds and removes that space's moderators. They must
391 be Network users (`usr_…`), and nobody is added across a block in either direction. A listing counts only
392 for the person signed in themselves, never for a service naming them. Account deletion erases the rows.
393 No capability or Contracts change is involved. The cutover runbook is
394 `docs/cutover-community-0005-space-moderators.md`.
395
396- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
397 - the board index at `/forum` lists every space under its group, with topics, posts and the last post;
398 - a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;
399 - a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.
400 Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:
401 the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),
402 the name and description, and create spaces (`/forum/new`).
403 Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another
404 space, and both link to each other.
405- **Ratings** (after Facepunch), one per person per post, never your own:
406 - positive: Agree ✅, Winner 🏆, Funny 😂, Informative 💡, Friendly 😊, Sympathy ❤️;
407 - negative: Dumb 📦, Disgusting 🤢;
408 - utility: Bad reading 📖, Late 🕒.
409 The same rating again takes it back, and another replaces it. Hovering shows who gave it.
410- **Pastes on posts.** Up to 4 public or unlisted pastes per post, shown as cards with their first lines or
411 the screenshot. A paste made private or deleted drops out. Every paste page has "Discuss in a space".
412- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a
413 `discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questions
414 categories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`
415 only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):
416 every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary
417 is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.
418- **Starter threads.** Every forum but the Roadmap opens with a pinned guide by OpenVibe (welcome, how to get
419 help, how feedback works, showcase, off-topic rules) from [`docs/forum/starters.json`](docs/forum/starters.json),
420 synced at boot by `server/forum/starters.js` the same way. Staff unpinning, locking or deleting one sticks.
421
422- **Images on posts.** Up to 4 per thread or reply: PNG, JPEG, GIF or WebP by content, 8 MB each, with
423 metadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by the
424 person (tenant `community`, Community's service token; `server/media/objects.js`). `attachments` holds
425 the reference until the post naming it is saved. The no-JS forms upload and attach in one step.
426
427API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
428
429| Route | What |
430| --- | --- |
431| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
432| `GET /spaces/:space/moderation-log` | The space's newest 100 moderation rows `{ space, entries: [{ action, rule, reason, actor_role, created_at, thread, post_id }] }` — in role only, never the moderator's subject; public for public spaces |
433| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |
434| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
435| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
436| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |
437| `POST /spaces` · `PUT /spaces/:space/settings` `{ style, votes, reactions, group, parent, name, description, kind }` | Moderators: new space · settings. `GET/PUT /api/v1/space-groups[/:group]` manages the board index's groups |
438| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space |
439| `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post |
440| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
441| `PUT /spaces/:space/threads/:slug/status` `{ status, rule?, reason? }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
442| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
443| `DELETE /spaces/:space/threads/:slug` `{ rule?, reason? }` | Author or moderator |
444| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
445| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
446| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked?, rule?, reason? }` | Moderators |
447| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
448| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
449| `PUT /spaces/:space/chat-room` `{ room: slug \| https://openvibe.chat/r/<slug> }` | The space's owner or staff, signed in themselves: attach a chat room (201; the same room again 200) |
450| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
451| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` `{ rule?, reason? }` · `GET /posts/:id/versions` | Author or moderator |
452| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
453| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /forum/:space/moderators` and `POST /forum/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
454
455### A space's chat room (OpenVibe.Chat)
456
457A space can have one chat room on [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (roadmap WS-I
458task 4, `server/chat-rooms.js`). Its owner (the space's creator, or the creator whose members' space it is)
459or discussion staff attach one by its address or link, from the space page (no JavaScript) or
460`PUT /api/v1/spaces/:space/chat-room`; the space page (feed and forum style) then links it, with its kind
461(chat, call, announcements) and "members only" for a private room. Each side checks its own end:
462Community checks the space; Chat checks that the same person manages the room, because Community asks it
463with the person's own Network token (`POST ${OV_CHAT_INTERNAL_URL}/api/chat/rooms/:room/attachments`,
464`{ service: 'community', resource: <space>, title }`). So services cannot attach (a service has no
465person's token; moderator services may detach), and no capability or grant is involved. Chat's refusals
466come back as `chat_room.not_owner` (403), `chat_room.not_found` (404: no such room, or a private one the
467person is not in) and `chat_room.unavailable` (503/502); on the page they are a notice. Attaching the same
468room again is a no-op; another room replaces it (Chat is told the space let go of the old one, best
469effort); detaching removes Community's link and asks Chat to drop its side when the person may. Community
470keeps the link in `space_chat_rooms` (the room's id, slug, and its name, kind and visibility as Chat
471answered). No event is published for it (no contract has one yet).
472
473### Members-only spaces and threads (OpenVibe.VIP)
474
475A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)
476members: `members_only_owner` holds the creator's Network subject (`usr_…`). Moderators gate spaces
477(for any creator) and threads; a thread's author gates it to their own members (and opens it again),
478by the API, the `members_only` box on the new-thread form, or the button on the thread page.
479
480- **Reading and posting need an active entitlement**: listing a gated space's threads, reading a
481 thread, starting a thread, replying, voting, editing and post history. Community asks VIP
482 `POST /api/v1/policies/evaluate` `{ subject, resource: { service: 'community', type: 'space'|'thread', id },
483 owner, fallback: { requirement: 'member', binding: 'community:members_only' } }` — the owner is the
484 one Community stored, and the creator's own VIP rule for that resource (plan or perk requirement) wins
485 over the default gate. A gated thread in a gated space needs both.
486- **The owner and discussion moderators always pass** (the owner without asking VIP).
487- **Fails closed**: signed out, not a member, VIP down, a refused token or no `OV_OAUTH_CLIENT_SECRET`
488 are all `403 vip.members_only` `{ reason, gate, members_only: { owner, owner_username, join_url } }`.
489 Pages show a `noindex` teaser (space name, or the thread title) with the join link to the creator's
490 page on openvibe.vip, never a post.
491- **Listings** keep a gated thread's title with `members_only` (never a body); gated spaces are listed
492 with their join link.
493- **Never public**: gated spaces and threads stay out of Pulse (not recorded, removed when gated later,
494 and filtered at read time), the Discord relay (not queued; when gated later, what was waiting is
495 skipped and what was sent is deleted on Discord; re-checked at send), the sitemap, the RSS feeds
496 (a gated space's feed is 404) and JSON-LD; a member's view of a gated thread is `noindex` as well.
497- **Convergence**: answers are cached per viewer and resource (`server/vip/`, VIP's `createVipCache`):
498 a "yes" at most `VIP_CACHE_TTL_MS` (30 s), a "no" 10 s, a failure 2 s. Community has no Events inbox,
499 so **once VIP stops granting** (it applied Billing's `billing.entitlement.changed` and emitted
500 `vip.membership.changed`) **Community stops within 30 s**; the end-to-end bound from Billing's change
501 is VIP's (seconds with events, at most `VIP_PROJECTION_MAX_AGE_MS` + 30 s without; see the table in
502 OpenVibe.VIP's README). `app.locals.vip.cache.handleEvent(envelope)` converges at once if an Events
503 subscription is added later. `test/members-only.test.js` proves both.
504- The client (`server/vip/vip-client.js`) is OpenVibe.VIP's `client/vip-client.js` vendored at 2accbeb
505 until VIP publishes a tag to pin.
506- **Grant needed** on the Network: `community` → `vip.resource.policy.evaluate` on `openvibe.vip`.
507
508Votes (comments and threads) are one UPSERT per person plus a score recomputed from the vote
509rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift
510a score.
511
512## Discord relay
513
514Off unless `DISCORD_RELAY_ENABLED=true`, and inert without the owner's webhooks, mappings and bot
515token. Setup, owner steps, limits and operations: [docs/discord-relay.md](docs/discord-relay.md).
516
517- **Out**: a new thread in a mapped public space is announced through the mapping's Discord
518 webhook ("New thread in s/<space> by <name>", title, excerpt, link); its replies follow into the
519 same channel (or the Discord thread the mapping names); edits PATCH and deletes (or gating a
520 thread members-only) DELETE the Discord messages through the **external message map**
521 (`relay_message_map`, unique both ways; WS-J task 5). Creates are queued by the relay's
522 **Events worker** from Community's own `community.thread.*` / `community.post.*` read back from
523 OpenVibe.Events' pull API with a stored opaque cursor (`relay_cursors.position`; WS-J task 6), or by the forum
524 itself when the worker is off (no `EVENTS_URL` / client secret, or `DISCORD_RELAY_EVENTS=off`).
525 Dedupe keys in `relay_deliveries` mean nothing is queued or posted twice.
526- **In** (`DISCORD_RELAY_INBOUND=on`, `DISCORD_BOT_TOKEN`, `inbound: true` on the mapping): a
527 Discord gateway client (Node 22's WebSocket; intents GUILD_MESSAGES and MESSAGE_CONTENT) turns
528 replies to relayed messages in mapped channels into posts with origin `discord`, attributed to
529 the Discord name only (never to anyone on the site), size- and rate-limited, mentions defused;
530 edits and deletes on Discord follow through the map.
531- **Loop prevention**: origin `discord` never goes out (checked when queueing and sending);
532 webhook, bot and system messages never come in. Members/staff spaces and VIP members-only
533 content never leave the site.
534- **Secrets**: a mapping names the environment variable holding the webhook URL
535 (`webhook_url_ref`, allow-listed `DISCORD_WEBHOOK_*` or exactly `DISCORD_RELAY_WEBHOOK_VARS`);
536 the URL and the bot token never enter the database or any API response.
537- **Retries and the dead letter**: network errors, timeouts, 5xx, 429 and an unset variable are
538 retried with exponential backoff up to `DISCORD_RELAY_MAX_ATTEMPTS`, then the delivery is
539 `failed` (the dead letter); other 4xx fail at once. Mentions are disabled on every message.
540- **Staff** (admin/global_mod browsers, or services with `community.comment.moderate`):
541 `GET /api/v1/relay/status` (queue, Events worker position/caught-up/errors/gaps, gateway state),
542 `GET /api/v1/relay/deliveries?status=failed`, `POST /api/v1/relay/deliveries/:id/retry|drop`,
543 `GET /api/v1/relay/inbound` and `POST /api/v1/relay/inbound/:id/dismiss` (inbound failures),
544 `GET|POST /api/v1/relay/mappings`, `PUT /api/v1/relay/mappings/:id`.
545
546## Pulse
547
548A read model of public activity across the network, with provenance (`pulse_items`, unique
549per source service/type/id).
550
551- **Community's own**: new public pastes written by a person (not burn-after-read, not NSFW),
552 new threads and replies in public spaces, and submissions once accepted — recorded at write time,
553 removed when deleted, made non-public, withdrawn or rejected, and re-checked against their source
554 on every read, so private, unlisted or unreviewed things never show.
555- **Other services** publish with `community.pulse.write`:
556 `POST /api/v1/pulse/items { ref, title, url, origin?, occurred_at?, visibility? }` — the
557 ref's `service` must be the caller's own (`svc:live` → `live`), `visibility` other than
558 `public` is refused, `X-OV-Subject` names the person for origin `user`. Re-posting a source
559 updates its title and link; origin, actor and time stay the first record's.
560 `DELETE /api/v1/pulse/items/:service/:type/:id` retracts one.
561- **From Events** (`POST /internal/events`, `server/pulse/consumer.js`):
562 - go-lives (`live.stream.started`), plus public, indexable Blog posts, Wiki pages and News stories;
563 - game milestones (roadmap WS-M task 2): Network's `network.module.updated` for a person's
564 `games.progress.summary`, whose `level` is a public field. The first record seen for a person is a
565 baseline. Each later record that crosses a multiple of 5 becomes one item, "Reached level 10 in Scraplandia"
566 (Games · level), with the person as actor. The last level per person is kept in `game_progress`.
567- **Reading**: `GET /api/v1/pulse?origin=user|ai|system&after=<cursor>&limit=` (newest first,
568 keyset cursor), and the `/pulse` page. AI items carry `label: 'AI'` and never an actor —
569 they are never attributed to a person (roadmap §33); system items name no one either.
570
571## Submissions
572
573Clips, art, ideas and reports people send in for community review (`submissions`, migration
574`0003_submissions.sql`; `server/submissions/`). They have a table of their own and nothing to do with
575the forum, so they stay here when spaces move to a service of their own.
576
577- **Who submits**: a signed-in person, from the browser (`ov_token` cookie, or `Authorization: Bearer`
578 from another OpenVibe site through CORS). The author is the JWT's `subject_id` (`usr_…`) — never a
579 body field. Signed out: 401 `auth.required` (the form sends you to sign in). Services cannot submit:
580 openvibe-contracts has no submission capability yet (403 `capability.denied`). At most 10 a day per
581 person (429 `submission.daily_limit`).
582- **What**: `kind` (`clip`, `art`, `idea`, `report`), `title` (200), `body` (Markdown, 10 000), an http(s)
583 `url` and/or a `media_ref` (an OpenVibe.Media object, `med_…`: the bytes stay in Media). A clip or art
584 needs a link or a Media object.
585- **Review**: new submissions are `pending`, seen by their author and by discussion moderators
586 (site staff, or a service holding `community.comment.moderate` — `discussionModerator`) and 404 for
587 everyone else. A moderator accepts or rejects (also a decision already made) with an optional note
588 the author sees; the author withdraws while pending or accepted. Accepted ones are public pages
589 (canonical, Open Graph, IndexNow), listed at `/submissions` and recorded in [Pulse](#pulse) as the
590 author's (origin `user`); pending, rejected and withdrawn ones never are.
591- **Pages** work without JavaScript: filters and paging are links, submit, withdraw, accept and
592 reject are plain form posts (SameSite=Lax cookie; a foreign `Origin` is refused).
593
594| Route | Who | What |
595| --- | --- | --- |
596| `GET /api/v1/submissions?kind=&status=&mine=1&after=&limit=` | anyone | newest first, keyset cursor: accepted ones; `mine=1` your own, any status; moderators any `status` (others asking for one get 403 `submission.moderators_only`) |
597| `POST /api/v1/submissions { kind, title, body?, url?, media_ref? }` | a signed-in person | 201 `{ submission }`, `pending` |
598| `GET /api/v1/submissions/:slug` | see above | `{ submission }` — `reviewer` and `review_note` only for the author and moderators |
599| `POST /api/v1/submissions/:slug/withdraw` | the author | `withdrawn` (409 `submission.not_withdrawable` once rejected or withdrawn) |
600| `POST /api/v1/submissions/:slug/review { decision: accept\|reject, note? }` | moderators | `accepted` / `rejected` (409 `submission.withdrawn`) |
601
602Errors are problem+json. Account deletion erases a person's submissions (and their Pulse items) and
603unsigns the decisions they made; the export has `submissions.json`.
604
605## Platform blocks
606
607People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community
608honours it (`server/identity/blocks.js`): nobody replies in a forum thread whose author blocked them, replies
609to a comment whose author blocked them (or to a reply that joins that comment), or comments on a paste (paste
610API or comment thread) or forum post whose owner blocked them. The API answers 403 `community.blocked` (a
611problem under `/api/v1`, `{ error, code }` under `/api/pastes`); the no-JS forms show the message with the
612draft kept. Anonymous comments carry no subject and so are not affected; moderation never is.
613
614The blocks come from `network.block.changed` (POST `/internal/events`, `server/pulse/consumer.js`) into the
615`network_blocks` projection, newest revision per pair. Boot creates the missing subscription
616(`startSubscriptions`, `COMMUNITY_EVENTS_SECRET`, `EVENTS_URL`); a new subscription gets no history, so
617blocks made before it existed need a replay from OpenVibe.Events.
618
619## Capabilities
620
621Community checks service tokens against these capabilities (all released in openvibe-contracts,
622same shape as its `manifests/capabilities`):
623
624| Id | Status | Used for |
625| --- | --- | --- |
626| `community.paste.create` / `.write` / `.moderate` | active in contracts | pastes |
627| `community.comment.write` | active in contracts (v0.7.0) | comment threads as a person or AI |
628| `community.comment.moderate` | active in contracts (v0.7.0) | thread visibility, comment/post/thread moderation, relay admin |
629| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
630| `community.post.create` | active in contracts (v0.7.0) | forum writes |
631| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
632| `community.resource.read` | active in contracts (0.134.0) | OpenVibe.Services' resource index: a person's pastes and spaces in "Your resources" |
633
634This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all go
635through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
636the installed contracts do not know locally, with the library's own matching rule (the exact id
637or a `prefix.*` grant).
638
639Called elsewhere, as the service principal `community` (the OAuth client `community`):
640
641| Service | Grant | Why |
642| --- | --- | --- |
643| OpenVibe.Network | `identity.subject.resolve`; `network.modules.write` on `community.profile` | author names and avatars; the profile module |
644| OpenVibe.Media | `media.object.upload` | new screenshots and forum attachments |
645| OpenVibe.VIP | `vip.resource.policy.evaluate` | members-only spaces and threads |
646| OpenVibe.Events | `events.event.publish`, `events.subscription.manage` | the outbox relay; Pulse, account and block subscriptions created at boot |
647
648A space's chat room is attached with the signed-in person's own Network token (no service grant).
649
650### Per-actor limits
651
652Every API router also limits who calls it, once the viewer is resolved and before the route does any
653work (`server/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4). The per-address `/api/`
654limit and the per-person content limits (cooldowns, writes a minute, daily caps) stay. Counted: a
655person as `user:usr_…` (their own token, or named by a service or app in `X-OV-Subject`); a
656first-party service relaying a signed-out visitor by the address it forwards; a service or app acting
657as itself (moderation, AI output, Pulse) by its principal; a signed-out browser by address. A
658first-party service reading for itself is not counted on reads. Past a limit: `429` problem+json
659`rate_limited` with `Retry-After`, one `[Limits]` log line and `community_rate_limited_total{limit,window}`.
660
661| Routes | Per caller |
662| --- | --- |
663| Reads of each API (pastes, comments, forum, Pulse, relay) | `COMMUNITY_LIMITS_MINUTE` / `COMMUNITY_LIMITS_HOUR` (120 a minute, 3000 an hour) |
664| Comment, paste comment and reply create | 20 / 300 |
665| Thread create, crosspost; space create | 10 / 60 |
666| Paste create 30 / 600; screenshot 20 / 300; fork 20 / 300; AI summary 60 / 1200 | as listed |
667| Edits (comments, posts, pastes, thread category and members-only) | 30 / 300 |
668| Deletes and moderation (visibility, comment, post, paste deletes) | 60 / 600 |
669| Votes, reactions, likes and copies | 120 / 1200 |
670| Space settings, categories, status, state, groups, chat room; relay changes | 30 / 300 |
671| Attachment upload 20 / 200; paste censor 20 / 200; bulk and fork cleanups 10 / 100 | as listed |
672| Thread resolve 60 / 1200; Pulse item post and retract 60 / 1200 | as listed |
673
674Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the signed Events
675deliveries at `/internal/events` (they carry token cutoffs and account deletions). Pages keep their
676per-address form limits. `test/actor-limits.test.js`.
677
678## SEO
679
680Discovery is written by `openvibe-shared/seo`; Community only supplies the data
681(`server/discovery.js`, `server/render/jsonld.js`). Every page head comes from `seo.headTags`
682(description, canonical, robots, Open Graph + Twitter card, JSON-LD) plus `seo.pageSummary`
683(an `ai-summary` meta and a `WebPage` JSON-LD), with `article:*` times on pastes. JSON-LD:
684`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on paste
685pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.
686Every paste page is `noindex` (link-shared content, SEO-29), so no paste is in the sitemap and
687paste changes never ping IndexNow (a ping for a noindex page is two contradictory signals).
688Unlisted/private pastes, search result pages and members/staff spaces are `noindex` too. The
689sitemap lists home, `/pastes`, `/forum`, `/pulse`, public spaces and their latest threads
690(rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers
691and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/forum/*/new` and `?sso=` URLs;
692`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastes
693in full (no NSFW, no burn-after-read, nothing members-only).
694
695Cache headers come from `openvibe-shared/cache-policy`: a static file at its current `?v=` hash
696is immutable for a year, any other static file is 5 minutes with a day of stale-while-revalidate,
697pages (rendered for the person reading them), raw and download are `private, no-store`, and
698robots/llms/sitemap are public for 1 h, `/feed.xml` 15 min and the thread feeds 5 min.
699
700## Configuration
701
702- `OV_VISION_URL` — OpenVibe.Vision's base URL for the unsafe-image review; unset means no image review runs.
703
704Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0600).
705
706| Variable | Default | Meaning |
707| --- | --- | --- |
708| `PORT` / `HOST` | `4200` / `127.0.0.1` | Listen address (nginx in front) |
709| `BASE_URL` | `https://openvibe.community` in production | Canonical origin |
710| `TRUST_PROXY` | `2` | X-Forwarded-For hops (Cloudflare → nginx → Node) |
711| `OV_NETWORK_URL` | `https://openvibe.network` | Issuer / authorize URL / JWKS |
712| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Token grants, JWKS (tried first) |
713| `OV_OAUTH_CLIENT_ID` | `community` | Registered on the Network |
714| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |
715| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.community/auth/callback` | Must match the registration |
716| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
717| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
718| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots |
719| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset (development: embedded PGlite in `data/pglite`) | PostgreSQL through PgBouncer, and the owner's direct connection for migrations (written by OpenVibe.Host `roles/data/add-service.sh community`) |
720| `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes |
721| `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 |
722| `DISCORD_RELAY_ENABLED` | off | `true` turns the Discord relay on ([docs/discord-relay.md](docs/discord-relay.md)) |
723| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Relay sender cadence, first retry delay, attempts before `failed` (the dead letter) |
724| *(any name)* e.g. `DISCORD_WEBHOOK_FEEDBACK` | — | A Discord webhook URL, named by a relay mapping's `webhook_url_ref` |
725| `DISCORD_RELAY_EVENTS` / `DISCORD_RELAY_EVENTS_POLL_MS` | on / `5000` | The relay's Events worker (needs `EVENTS_URL` and the client secret); `off` leaves creates to the forum |
726| `DISCORD_RELAY_INBOUND` / `DISCORD_BOT_TOKEN` | off / — | Replies from Discord through the gateway (a bot with the MESSAGE CONTENT intent) |
727| `DISCORD_GATEWAY_URL` | `wss://gateway.discord.gg/?v=10&encoding=json` | The Discord gateway |
728| `DISCORD_RELAY_INBOUND_PER_MINUTE` / `DISCORD_RELAY_INBOUND_MAX_CHARS` | `6` / `4000` | Inbound limits per Discord author per mapping, and per message |
729| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
730| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
731| `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |
732| `OV_CHAT_INTERNAL_URL` | `http://127.0.0.1:4400` | OpenVibe.Chat's API (a space's chat room) |
733| `OV_CHAT_URL` | `https://openvibe.chat` | Public Chat site, for the room links |
734| `CHAT_TIMEOUT_MS` | `4000` | One Chat call |
735| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
736| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
737| `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |
738| `INDEXNOW_KEY` | unset (IndexNow off) | IndexNow key: served at `/<key>.txt`, and a public, indexable page appearing, changing or going away pings the engines |
739
740## Run
741
742```
743npm install
744npm start # http://127.0.0.1:4200
745npm test # mocks Live, Network and Media in-process; no network needed
746npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit (or pass <ref>)
747```
748
749`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against
750this one, from `test/fixtures/n-1/`: every call `community.js`, the templates' forms and the
751openvibe-sdk community client make, and every link, script and form of the pages that release served,
752each answered compatibly (status, JSON, the response fields the client reads); then this release must
753keep every migration that release ran byte for byte and only add. After each deploy, record the
754release now in production as the next N-1 and commit `test/fixtures/n-1/`.
755
756## Acceptance
757
758`npm test` runs every `test/*.test.js` with mock Live, Network and Media and a real RS256 key, no
759network. What they prove, among others: the paste API as the browser clients use it and the import
760(`pastes-api`, `import`); comment threads, the forum and its server-rendered pages (`comments`, `forum`,
761`forum-ssr`, `ssr`); members-only gates that fail closed (`members-only`); private things stay private on
762every read path and nobody acts on someone else's ids (`security-private`, `security-idor`); SSRF,
763secrets, open redirects (`security-ssrf`, `security-secrets`, `open-redirect`); Pulse and the Events
764consumer (`pulse`, `pulse-consumer`, `events`); revocation, blocks, account export and deletion
765(`revocation`, `blocks`, `account-data`); the Discord relay (`relay*`); per-actor limits
766(`actor-limits`); submissions from the form to review, the page and Pulse (`submissions`); graceful
767shutdown (`shutdown`); and the previous release against this one (`n-1`).
768
769## Security
770
771Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
772
773- **Auth.** Browsers use Network JWTs (Bearer or `ov_token`); services use tokens for audience
774 `openvibe.community` with the route's capability, and name the person in `X-OV-Subject`. Identity is
775 never read from bodies or queries; a token issued before a person's `token_valid_after` is refused.
776- **Private data.** Unlisted and private pastes get unguessable slugs, burn-after-read pastes are read
777 once, members-only spaces and threads fail closed when VIP is unreachable, screenshots lose their
778 EXIF/XMP metadata, and view counts use hashed visitor ids (`VIEW_HASH_SECRET`).
779- **Egress.** Community calls only its configured Network, Live, Media, VIP, Chat and Events hosts,
780 and Discord (webhooks named by variable, the gateway) when the relay is on.
781- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `COMMUNITY_EVENTS_SECRET`, `VIEW_HASH_SECRET`,
782 `DISCORD_BOT_TOKEN` and the Discord webhook variables live in `/etc/openvibe/community.env` (0600), by
783 name only; a relay mapping stores a variable's name, never the URL.
784
785## Deploy
786
787```
788/opt/openvibe.community # git checkout, `npm ci --omit=dev`
789/etc/openvibe/community.env # secrets (0600)
790deploy/systemd/openvibe-community.service # → /etc/systemd/system/, User=ubuntu, port 4200
791deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, TLS from
792 # /etc/letsencrypt/live/openvibe.community/
793```
794
795Production deploys with `sudo ovhost deploy community` on the host (strategy `git-checkout`: fetch,
796fast-forward `/opt/openvibe.community`, install on a lockfile change, restart, wait for `/api/ready`).
797The unit is `openvibe-community.service` on `127.0.0.1:4200`, the env file `/etc/openvibe/community.env`. The database is
798`ov_community` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh community` writes its settings); the
799release migrates it at boot. After a deploy, record the N-1 fixtures (`npm run n-1:record`).
800Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
801restart; afterwards `sudo ovhost rollback community --to <sha>`. Migrations only add tables and columns.
802
803The schema
804is created idempotently at boot in every mode (comments, the forum, Pulse and the relay live
805in Community's database whichever service owns pastes; the three seed spaces are inserted
806once). The database lives in the unit's `StateDirectory` (`/var/lib/openvibe-community`), so
807the code tree stays read-only.
808
809Flipping to `community` (done in production on 2026-09-22) needed the Network's `community` OAuth client to have the
810`identity.subject.resolve` capability (audience `openvibe.network`) and
811`media.object.upload` (audience `openvibe.media`), and Live's service client the
812`community.paste.*` capabilities it uses.
813
814The Network must have the OAuth client `community` registered with redirect
815`https://openvibe.community/auth/callback`, and serve the current `openvibe-shared`
816`navbar.js` (with `links`/`menu`/`silentLogin` support) for the top links and silent sign-in
817to appear.
818
819## Layout
820
821```
822server/
823 index.js process entry (listen, graceful stop)
824 graceful.js SIGTERM: stop timers and scans, drain HTTP (4 s), settle the relays, close the DB, exit 0 (5 s at most)
825 app.js Express app factory: middleware, routes
826 config.js env → config
827 db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot
828 auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
829 identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
830 identity/network.js Network resolve-batch client + subject_projection cache
831 media/files.js new screenshot bytes → Media's file store (service token)
832 media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding
833 live-client.js (removed: Community is the only paste authority)
834 pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)
835 pastes/api.js native /api/pastes/*
836 pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
837 pastes/store.js pure SQL over pastes / versions / likes / projections (comments: comments/store.js)
838 pastes/source.js where pages read pastes from (the store)
839 pastes/catalog.js recent public pastes: trending, related, language filter
840 sharing.js openvibe-sdk/share: the one canView, the audience, members, transfers, the gate
841 comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
842 routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
843 forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
844 vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored client
845 pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
846 submissions/ clips, art, ideas, reports for review: store, service, api (/api/v1/submissions), routes (pages)
847 relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (creates
848 from Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)
849 http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
850 identity/capabilities.js capability checks; discussion staff/moderators
851 identity/authors.js author display from subject_projection; the AI label
852 votes.js race-safe up/down votes (comments, threads)
853 limits.js per-person write limits
854 render/layout.js page shell: SEO head (openvibe-shared/seo), OpenVibe Frame, hashed assets
855 render/jsonld.js JSON-LD builders (WebSite, breadcrumbs, paste, thread, authors)
856 render/pages.js home / browse / paste / new / my / error templates
857 render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
858 render/pulse.js the /pulse page
859 render/submissions.js the submissions list, a submission's page, the review queue
860 render/comments.js a comment thread's own page
861 render/markdown.js the safe Markdown subset for posts
862 render/highlight.js highlight.js wrapper, language list, download extensions
863 discovery.js robots disallow set, llms sections, sitemap rows (1 h), feed items
864public/ css/community.css, js/community.js, favicon.svg, og-default.png
865(openvibe-shared is the pinned OpenVibe.Shared v2.15.0 release, installed by npm)
866deploy/ systemd unit, nginx vhost
867test/ run.js + *.test.js (mock Network and Media with a real RS256 key)
868docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits
869```
870
871## What is next
872
873Spaces per streamer, game and project (with membership), the Discord relay's production round
874trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for
875comment threads arriving through Events, and dropping the read-only `paste_comments` table a
876release after paste comments moved onto the typed comment threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).
877
878## Related services
879
880- Identity/SSO: https://openvibe.network (OpenVibers/OpenVibe.Network)
881- Streaming: https://openvibe.live (OpenVibers/OpenVibe.Live)
882- Tools: https://openvibe.tools (OpenVibers/OpenVibe.Tools)
883- Media: https://openvibe.media (OpenVibers/OpenVibe.Media)
884- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
885
886<!-- versions:start -->
887- openvibe-contracts: v0.419.0
888- openvibe-sdk: v0.161.0
889- openvibe-shared: v4.241.0
890<!-- versions:end -->
891