openvibers/openvibe.community README.md
README.md
| 1 |  |
| 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 | |
| 10 | Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services). |
| 11 | |
| 12 | ## Purpose |
| 13 | |
| 14 | The community hub of the OpenVibe network: community-run, open source, free speech within |
| 15 | the 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 |
| 17 | embeds, **Pulse**, the network's public activity, and [**submissions**](#submissions) — clips, art, |
| 18 | ideas and reports people send in for community review. |
| 19 | |
| 20 | The 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 |
| 22 | build on their OpenVibe account, the network's services and the SDK — and redirects every |
| 23 | forum URL here. |
| 24 | |
| 25 | It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that |
| 26 | server-renders every page — crawlers and no-JS readers get the whole thing — and adds a |
| 27 | little 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 | |
| 56 | Pastes moved to Community in roadmap Wave 5; since 2026-09-22 Community's own database is their only |
| 57 | authority (there is no switch any more — `live-client.js` and the `/api/pastes` proxy were deleted) — |
| 58 | see [Community as the paste authority](#community-as-the-paste-authority). The import brought |
| 59 | 889 + 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 |
| 61 | this 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 |
| 63 | between them, most of them on pastes; 39 forum threads, all opened by the system (34 public-roadmap items and 5 |
| 64 | discussions), none by a person yet; and 300 Pulse items. The Discord relay is off. A restore drill passed on |
| 65 | 2026-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 | |
| 77 | Community resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`), |
| 78 | which is exactly what Community stores, so there is no second identity to map and no proxy. Creation, |
| 79 | listing, 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 | |
| 84 | Community 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 |
| 86 | response 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 |
| 88 | redirects 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 |
| 90 | worth 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 |
| 92 | Moments 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 | |
| 136 | Moving 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 |
| 138 | PostgreSQL and is in git history. |
| 139 | |
| 140 | `/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network |
| 141 | has no service-token lookup by username), so a person appears there once Community has seen |
| 142 | them — signed in here, or named as an author in a resolved projection. |
| 143 | |
| 144 | ## Endpoints |
| 145 | |
| 146 | Pages (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 | |
| 175 | API 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 |
| 200 | when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the |
| 201 | Network 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`. |
| 205 | Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP |
| 206 | golden 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, |
| 210 | threads) are deliberately not metrics. |
| 211 | |
| 212 | **Request telemetry (plan T1 step 10).** Each request is aggregated by route template, method and |
| 213 | status class, never by raw URL. Once a minute one `http.request` sample per key, plus the |
| 214 | active-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); |
| 216 | rows 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 |
| 218 | until init); `server/index.js` calls `telemetry.init()` at production start, runs the 14-day |
| 219 | retention and flushes on stop. The table is created by migration `0009_telemetry.sql`. |
| 220 | |
| 221 | Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so |
| 222 | the 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 |
| 229 | comment 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 |
| 231 | resolves 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 | |
| 245 | Who 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 | |
| 287 | Since roadmap Wave 5 (exit criterion: Live and a second product share one Community thread), |
| 288 | OpenVibe.Live keeps no comments of its own: its `/api/comments/:type/:id` routes are an adapter |
| 289 | over 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 |
| 291 | visibility check, reads and comments as the signed-in person (`X-OV-Subject`), edits as the |
| 292 | author, and deletes as the author, as staff (`X-OV-Staff: 1`) or — for the VOD's or clip's |
| 293 | owner — as itself (`community.comment.moderate`). Deleting a VOD or clip hides its thread. The |
| 294 | same thread is this site's page `/c/<access id>`; Live links to it under a public or unlisted |
| 295 | item's comments. |
| 296 | |
| 297 | **Checking that both show the same thread** (production or local): |
| 298 | |
| 299 | 1. 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. |
| 302 | 2. 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. |
| 304 | 3. 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`; |
| 308 | held 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 |
| 310 | screenshot-reference migration (`scripts/migrate-screenshot-refs.js`, applied 2026-09-25: no paste keeps a legacy media reference). |
| 311 | |
| 312 | Embedding it — server-side, from another product's backend (the usual way; the person is the |
| 313 | one your own session says it is): |
| 314 | |
| 315 | ```js |
| 316 | const COMMUNITY = 'http://127.0.0.1:4200/api/v1/comments'; |
| 317 | const token = await tokens.authHeaders(); // openvibe-contracts serviceAuth token client, |
| 318 | // audience openvibe.community, community.comment.write |
| 319 | const ref = { service: 'live', type: 'vod', id: String(vod.id), label: vod.title }; |
| 320 | const { thread } = await (await fetch(`${COMMUNITY}/threads/resolve`, { |
| 321 | method: 'POST', headers: { ...token, 'Content-Type': 'application/json' }, body: JSON.stringify({ ref }), |
| 322 | })).json(); |
| 323 | const 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: [...], ... } |
| 326 | await 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 | |
| 333 | In the browser, from a page on an allowed origin that holds the visitor's Network JWT: |
| 334 | |
| 335 | ```js |
| 336 | const 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()); |
| 339 | const { thread } = await api('/threads/resolve', { method: 'POST', body: JSON.stringify({ ref: { service: 'live', type: 'stream', id: streamId } }) }); |
| 340 | let { comments, next_cursor } = await api(`/threads/${thread.id}`); |
| 341 | if (next_cursor) ({ comments } = await api(`/threads/${thread.id}?after=${next_cursor}`)); |
| 342 | await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ value: 1 }) }); |
| 343 | ``` |
| 344 | |
| 345 | Render `message` as text (it is plain text, never HTML). |
| 346 | |
| 347 | ## Forum: forums, threads, posts |
| 348 | |
| 349 | People see **the Forum** and **forums** (renamed from "Spaces" on 2026-10-17: openvibe.space is a different product). |
| 350 | In the code, the database and the API a forum is still a `space` (`spaces`, `/api/v1/spaces`, `community.space.*`). |
| 351 | |
| 352 | Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces: |
| 353 | `general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone |
| 354 | reads, people post), `members` (signed-in people only; every Network account counts as a |
| 355 | member 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 |
| 360 | title and 40,000 of body and escaped when shown; the link never posts, and a signed-out visitor |
| 361 | signs 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 | |
| 427 | API (`/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 | |
| 457 | A space can have one chat room on [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (roadmap WS-I |
| 458 | task 4, `server/chat-rooms.js`). Its owner (the space's creator, or the creator whose members' space it is) |
| 459 | or 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: |
| 462 | Community checks the space; Chat checks that the same person manages the room, because Community asks it |
| 463 | with 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 |
| 465 | person's token; moderator services may detach), and no capability or grant is involved. Chat's refusals |
| 466 | come back as `chat_room.not_owner` (403), `chat_room.not_found` (404: no such room, or a private one the |
| 467 | person is not in) and `chat_room.unavailable` (503/502); on the page they are a notice. Attaching the same |
| 468 | room again is a no-op; another room replaces it (Chat is told the space let go of the old one, best |
| 469 | effort); detaching removes Community's link and asks Chat to drop its side when the person may. Community |
| 470 | keeps the link in `space_chat_rooms` (the room's id, slug, and its name, kind and visibility as Chat |
| 471 | answered). No event is published for it (no contract has one yet). |
| 472 | |
| 473 | ### Members-only spaces and threads (OpenVibe.VIP) |
| 474 | |
| 475 | A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP) |
| 476 | members: `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), |
| 478 | by 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 | |
| 508 | Votes (comments and threads) are one UPSERT per person plus a score recomputed from the vote |
| 509 | rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift |
| 510 | a score. |
| 511 | |
| 512 | ## Discord relay |
| 513 | |
| 514 | Off unless `DISCORD_RELAY_ENABLED=true`, and inert without the owner's webhooks, mappings and bot |
| 515 | token. 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 | |
| 548 | A read model of public activity across the network, with provenance (`pulse_items`, unique |
| 549 | per 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 | |
| 573 | Clips, 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 |
| 575 | the 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 | |
| 602 | Errors are problem+json. Account deletion erases a person's submissions (and their Pulse items) and |
| 603 | unsigns the decisions they made; the export has `submissions.json`. |
| 604 | |
| 605 | ## Platform blocks |
| 606 | |
| 607 | People block each other once, on OpenVibe.Network (roadmap WS-E task 5, Contracts 0.49.0), and Community |
| 608 | honours it (`server/identity/blocks.js`): nobody replies in a forum thread whose author blocked them, replies |
| 609 | to a comment whose author blocked them (or to a reply that joins that comment), or comments on a paste (paste |
| 610 | API or comment thread) or forum post whose owner blocked them. The API answers 403 `community.blocked` (a |
| 611 | problem under `/api/v1`, `{ error, code }` under `/api/pastes`); the no-JS forms show the message with the |
| 612 | draft kept. Anonymous comments carry no subject and so are not affected; moderation never is. |
| 613 | |
| 614 | The 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 |
| 617 | blocks made before it existed need a replay from OpenVibe.Events. |
| 618 | |
| 619 | ## Capabilities |
| 620 | |
| 621 | Community checks service tokens against these capabilities (all released in openvibe-contracts, |
| 622 | same 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 | |
| 634 | This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all go |
| 635 | through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id |
| 636 | the installed contracts do not know locally, with the library's own matching rule (the exact id |
| 637 | or a `prefix.*` grant). |
| 638 | |
| 639 | Called 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 | |
| 648 | A space's chat room is attached with the signed-in person's own Network token (no service grant). |
| 649 | |
| 650 | ### Per-actor limits |
| 651 | |
| 652 | Every API router also limits who calls it, once the viewer is resolved and before the route does any |
| 653 | work (`server/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4). The per-address `/api/` |
| 654 | limit and the per-person content limits (cooldowns, writes a minute, daily caps) stay. Counted: a |
| 655 | person as `user:usr_…` (their own token, or named by a service or app in `X-OV-Subject`); a |
| 656 | first-party service relaying a signed-out visitor by the address it forwards; a service or app acting |
| 657 | as itself (moderation, AI output, Pulse) by its principal; a signed-out browser by address. A |
| 658 | first-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 | |
| 674 | Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the signed Events |
| 675 | deliveries at `/internal/events` (they carry token cutoffs and account deletions). Pages keep their |
| 676 | per-address form limits. `test/actor-limits.test.js`. |
| 677 | |
| 678 | ## SEO |
| 679 | |
| 680 | Discovery 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 |
| 685 | pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere. |
| 686 | Every paste page is `noindex` (link-shared content, SEO-29), so no paste is in the sitemap and |
| 687 | paste changes never ping IndexNow (a ping for a noindex page is two contradictory signals). |
| 688 | Unlisted/private pastes, search result pages and members/staff spaces are `noindex` too. The |
| 689 | sitemap lists home, `/pastes`, `/forum`, `/pulse`, public spaces and their latest threads |
| 690 | (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers |
| 691 | and 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 |
| 693 | in full (no NSFW, no burn-after-read, nothing members-only). |
| 694 | |
| 695 | Cache headers come from `openvibe-shared/cache-policy`: a static file at its current `?v=` hash |
| 696 | is immutable for a year, any other static file is 5 minutes with a day of stale-while-revalidate, |
| 697 | pages (rendered for the person reading them), raw and download are `private, no-store`, and |
| 698 | robots/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 | |
| 704 | Copy `.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 | ``` |
| 743 | npm install |
| 744 | npm start # http://127.0.0.1:4200 |
| 745 | npm test # mocks Live, Network and Media in-process; no network needed |
| 746 | npm 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 |
| 750 | this one, from `test/fixtures/n-1/`: every call `community.js`, the templates' forms and the |
| 751 | openvibe-sdk community client make, and every link, script and form of the pages that release served, |
| 752 | each answered compatibly (status, JSON, the response fields the client reads); then this release must |
| 753 | keep every migration that release ran byte for byte and only add. After each deploy, record the |
| 754 | release 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 |
| 759 | network. 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 |
| 762 | every read path and nobody acts on someone else's ids (`security-private`, `security-idor`); SSRF, |
| 763 | secrets, open redirects (`security-ssrf`, `security-secrets`, `open-redirect`); Pulse and the Events |
| 764 | consumer (`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 |
| 767 | shutdown (`shutdown`); and the previous release against this one (`n-1`). |
| 768 | |
| 769 | ## Security |
| 770 | |
| 771 | Reporting 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) |
| 790 | deploy/systemd/openvibe-community.service # → /etc/systemd/system/, User=ubuntu, port 4200 |
| 791 | deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, TLS from |
| 792 | # /etc/letsencrypt/live/openvibe.community/ |
| 793 | ``` |
| 794 | |
| 795 | Production deploys with `sudo ovhost deploy community` on the host (strategy `git-checkout`: fetch, |
| 796 | fast-forward `/opt/openvibe.community`, install on a lockfile change, restart, wait for `/api/ready`). |
| 797 | The 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 |
| 799 | release migrates it at boot. After a deploy, record the N-1 fixtures (`npm run n-1:record`). |
| 800 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 801 | restart; afterwards `sudo ovhost rollback community --to <sha>`. Migrations only add tables and columns. |
| 802 | |
| 803 | The schema |
| 804 | is created idempotently at boot in every mode (comments, the forum, Pulse and the relay live |
| 805 | in Community's database whichever service owns pastes; the three seed spaces are inserted |
| 806 | once). The database lives in the unit's `StateDirectory` (`/var/lib/openvibe-community`), so |
| 807 | the code tree stays read-only. |
| 808 | |
| 809 | Flipping 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 | |
| 814 | The 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 |
| 817 | to appear. |
| 818 | |
| 819 | ## Layout |
| 820 | |
| 821 | ``` |
| 822 | server/ |
| 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 |
| 864 | public/ 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) |
| 866 | deploy/ systemd unit, nginx vhost |
| 867 | test/ run.js + *.test.js (mock Network and Media with a real RS256 key) |
| 868 | docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits |
| 869 | ``` |
| 870 | |
| 871 | ## What is next |
| 872 | |
| 873 | Spaces per streamer, game and project (with membership), the Discord relay's production round |
| 874 | trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for |
| 875 | comment threads arriving through Events, and dropping the read-only `paste_comments` table a |
| 876 | release 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 |