openvibers/openvibe.community Commits
Community is the only paste authority, moves between pages without a reload, and tells search engines when a page changes (plans T10 J6 and T11). The PASTES_AUTHORITY=live path is gone: the Live paste proxy (server/pastes/proxy.js), the Live client (server/live-client.js), the switch and its config, the local capability fallback and the C-24 references; migration 0002 drops the unread pastes.legacy_media_id/legacy_user_id and paste_comments.legacy_media_id (legacy_id_map stays: identity resolution uses it). openvibe-shared v2.2.0 boost with the release marker; IndexNow with INDEXNOW_KEY (public posts and pastes appearing, changing or going away); a home-page performance budget. test/proxy.test.js proves nothing reads the switch or proxies /api/pastes and the columns are gone. 47/47 (verified twice).
Changes
@@@ -26,14 +26,13 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4200/auth/callback# Cookies — production default is Secure; set false for plain-http local dev#COOKIE_SECURE=false--# OpenVibe.Live — every /api/pastes/* call is proxied here (internal URL);--# Live maps the visitor's Network JWT onto its own account ids before it talks to Media.--OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000++# OpenVibe.Live — author links, avatars and legal pages (no paste traffic).++OV_LIVE_URL=https://openvibe.live--# OpenVibe.Media — public host for raw paste text and screenshots++# OpenVibe.Media — public host for screenshot bytesOV_MEDIA_URL=https://openvibe.media--# Media's internal address: new screenshot uploads (community authority) go to its file store++# Media's internal address: new screenshot uploads go to its file store# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100@@@ -52,14 +51,12 @@ OV_CHAT_URL=https://openvibe.chat# VIP_CACHE_DENY_TTL_MS=10000# VIP_CACHE_UNAVAILABLE_TTL_MS=2000--# Who owns pastes:--# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live--# community this site's own database is the authority (native API, pages, raw text)--# With 'community' the OAuth client above also needs identity.subject.resolve (author names).--PASTES_AUTHORITY=live++# Pastes are Community's own (native API, pages, raw text); the OAuth client above also needs++# identity.subject.resolve (author names).++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh community: the runtime role through--# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). Comments, the--# forum, Pulse and the relay's bookkeeping live there in every mode; pastes too with 'community'. Without++# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). Pastes, comments,++# the forum, Pulse and the relay's bookkeeping live there. Without# DATABASE_URL, development uses an embedded PGlite database in data/pglite.DATABASE_URL=DATABASE_DIRECT_URL=@@@ -15,7 +15,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.## Owns--- pastes (authority since 2026-09-22, `PASTES_AUTHORITY=community`), their versions, likes and++- pastes (the only authority since 2026-09-22), their versions, likes andcomments- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,categories), Pulse (the network's public activity) and the Discord relay@@@ -34,43 +34,38 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve`, the `community.profile` module)- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)--- OpenVibe.Live only when `PASTES_AUTHORITY=live` (the rollback mode), and for old VOD/clip comment imports++- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.0 (events outbox, per-actor limits),-- `openvibe-shared` v1.28.0, pinned by release tarball++ `openvibe-shared` v2.2.0, pinned by release tarball## How it fits the network--Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns them:----- **`live`** (the code default, now the rollback mode): the table below.--- **`community`** (production since 2026-09-22, about 23:35 UTC): Community's own database is-- the authority — see [Community as the paste authority](#community-as-the-paste-authority).-- The import brought 889 + 2 pastes, 60 comments and 48 likes; 5 rows (1 paste, 4 comments,-- `ambiguous_owner`) wait in `import_hold`. Live and Tools forward paste writes here, and Media-- answers old paste URLs with a 301 to this site. Comments, the forum and Pulse are deployed but-- hold almost nothing yet (10 wiki comment threads, 0 forum threads), and the Discord relay is-- off. A restore drill passed on 2026-09-23.++Pastes moved to Community in roadmap Wave 5; since 2026-09-22 Community's own database is their only++authority (there is no switch any more — `live-client.js` and the `/api/pastes` proxy were deleted) —++see [Community as the paste authority](#community-as-the-paste-authority). The import brought++889 + 2 pastes, 60 comments and 48 likes; 5 rows (1 paste, 4 comments, `ambiguous_owner`) wait in++`import_hold`. Live and Tools forward paste writes here, and Media answers old paste URLs with a 301 to++this site. Comments, the forum and Pulse are deployed but hold almost nothing yet (10 wiki comment++threads, 0 forum threads), and the Discord relay is off. A restore drill passed on 2026-09-23.| Concern | Where it lives | How Community reaches it || --- | --- | --- |--| Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |--| Paste API + account mapping | **OpenVibe.Live** (`/api/pastes/*`) | `OV_LIVE_INTERNAL_URL` (server-side) |++| Paste storage + API | **Community's PostgreSQL** (`pastes`, `server/pastes/*`) | own database |++| Screenshot / attachment bytes | **OpenVibe.Media** (`/api/v1/community/files`, objects v2) | service token (`media.object.upload`) || Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |--| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |++| Raw text, screenshots | Community's own routes; bytes on Media's public host | served locally, or a 302 to the stored image || Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page || 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`) || 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) |--Why go through Live and not straight to Media: the visitor's JWT names the account by its--**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different--numbers for the same person. Live resolves the visitor against its own accounts before it--writes, which only Live can do, so Community proxies `/api/pastes/*` to Live and forwards the--visitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,--anonymous-post limits, ownership and author names are therefore identical to Live's.++Community resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`),++which is exactly what Community stores, so there is no second identity to map and no proxy. Creation,++listing, anonymous-post limits, ownership and author names all come from Community's own rules++(`server/pastes/service.js`).### Community as the paste authority--With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API++Community is the only paste authority: `/api/pastes/*` is the native API(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes andresponse shapes the browser clients already use, and every page, `/p/:slug/raw`,`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which@@@ -140,7 +135,7 @@ API and machine endpoints:| Route | What || --- | --- |--| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) |++| `ANY /api/pastes/*` | The native API — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete and `/:slug/versions` || `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) || `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) || `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |@@@ -159,8 +154,7 @@ API and machine endpoints:**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 onlywhen the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (theNetwork signing key; without it nobody can sign in or write as a signed-in viewer or service),--`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`--(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready++`media` (screenshot and file uploads) is optional: a failure keeps the site ready(comments, forum, Pulse and public reads still work) and is listed in `degraded` with`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP@@@ -248,8 +242,8 @@ item's comments.**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,--`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is C-24's--`scripts/migrate-screenshot-refs.js` (applied 2026-09-25: no paste keeps a legacy media reference).++`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is the++screenshot-reference migration (`scripts/migrate-screenshot-refs.js`, applied 2026-09-25: no paste keeps a legacy media reference).Embedding it — server-side, from another product's backend (the usual way; the person is theone your own session says it is):@@@ -501,8 +495,8 @@ blocks made before it existed need a replay from OpenVibe.Events.## Capabilities--Community checks service tokens against these capabilities (manifests in--`docs/capabilities-proposal/`, same shape as OpenVibe.Contracts' `manifests/capabilities`):++Community checks service tokens against these capabilities (all released in openvibe-contracts,++same shape as its `manifests/capabilities`):| Id | Status | Used for || --- | --- | --- |@@@ -580,11 +574,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| `OV_OAUTH_CLIENT_ID` | `community` | Registered on the Network || `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in || `OV_OAUTH_REDIRECT_URI` | `https://openvibe.community/auth/callback` | Must match the registration |--| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` || `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages || `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |--| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |--| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |++| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots || `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`) || `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes || `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 |@@@ -604,6 +596,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links || `VIP_TIMEOUT_MS` | `2000` | One VIP call || `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) |++| `INDEXNOW_KEY` | unset (IndexNow off) | IndexNow key: served at `/<key>.txt`, and a public, indexable page appearing, changing or going away pings the engines |## Run@@@ -697,12 +690,12 @@ server/identity/network.js Network resolve-batch client + subject_projection cachemedia/files.js new screenshot bytes → Media's file store (service token)media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding-- live-client.js server-side reads of Live's /api/pastes for rendered pages ('live')-- pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address) ('live')-- pastes/api.js native /api/pastes/* ('community')++ live-client.js (removed: Community is the only paste authority)++ pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)++ pastes/api.js native /api/pastes/*pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapespastes/store.js pure SQL over pastes / versions / likes / comments / projections-- pastes/source.js where pages read pastes from (Live or the store)++ pastes/source.js where pages read pastes from (the store)pastes/catalog.js recent public pastes: trending, related, language filtercomments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)@@@ -712,7 +705,7 @@ server/relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (createsfrom Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS-- identity/capabilities.js capability checks incl. the proposed ids; discussion staff/moderators++ identity/capabilities.js capability checks; discussion staff/moderatorsidentity/authors.js author display from subject_projection; the AI labelvotes.js race-safe up/down votes (comments, threads)limits.js per-person write limits@@@ -725,10 +718,9 @@ server/render/highlight.js highlight.js wrapper, language list, download extensionsseo.js robots, sitemap, RSS, JSON-LD builderspublic/ css/community.css, js/community.js, favicon.svg, og-default.png--(openvibe-shared is the pinned OpenVibe.Shared v1.25.0 release, installed by npm)++(openvibe-shared is the pinned OpenVibe.Shared v2.2.0 release, installed by npm)deploy/ systemd unit, nginx vhost--test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)--docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)++test/ run.js + *.test.js (mock Network and Media with a real RS256 key)docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits```@@@ -23,7 +23,7 @@"community.pulse.read"],"capabilitiesRegistered": true,-- "pastesAuthority": "community in production since 2026-09-22 (~23:35 UTC); live is the rollback mode",++ "pastes": "Community is the only paste authority (since 2026-09-22, ~23:35 UTC)","deployed": true,"deployment": "Public at https://openvibe.community: deployed with `sudo ovhost deploy community`, /opt/openvibe.community, unit openvibe-community on 127.0.0.1:4200 behind the openvibe.community nginx vhost, principal community. The paste import brought 889 + 2 pastes, 60 comments and 48 likes (5 rows held). The Discord relay is off. Restore drill passed 2026-09-23.","features": [@@@ -1,27 +0,0 @@--{-- "id": "community.comment.moderate",-- "version": "1.0",-- "owner": "community",-- "status": "active",-- "visibility": "internal",-- "description": "Moderation of discussions: set a comment thread's visibility (public, hidden, locked — e.g. when the owner service hides the entity), delete any comment or forum post, pin and lock forum threads, read hidden threads, administer the Discord relay. Acting as itself (no X-OV-Subject) the service is the moderator; acting for a person it must vouch with X-OV-Staff: 1 that the person is staff.",-- "permissions": [-- "community:moderate"-- ],-- "resourceConstraints": [-- "none"-- ],-- "quotaClass": "community-moderate",-- "events": [-- "community.comment.moderated",-- "community.thread.moderated"-- ],-- "implementedBy": [-- "PUT /api/v1/comments/threads/:id/visibility",-- "DELETE /api/v1/comments/:commentId",-- "PUT /api/v1/spaces/:space/threads/:slug/state",-- "DELETE /api/v1/spaces/:space/threads/:slug",-- "PUT|DELETE /api/v1/posts/:id",-- "GET|POST|PUT /api/v1/relay/*"-- ]--}@@@ -1,29 +0,0 @@--{-- "id": "community.comment.write",-- "version": "1.0",-- "owner": "community",-- "status": "active",-- "visibility": "first-party",-- "description": "Typed comment threads as a service: resolve the thread of any EntityRef (browsers are limited to an allowlist of types), and comment, vote and delete one's own comments as the acting subject (X-OV-Subject), or comment as origin \"ai\" (never attributed to a person, roadmap 33). Ownership and limits are checked by Community.",-- "inputSchema": "common.entity-ref@1",-- "outputSchema": "common.entity-ref@1",-- "permissions": [-- "community:write"-- ],-- "resourceConstraints": [-- "owner"-- ],-- "quotaClass": "community-write",-- "events": [-- "community.comment.created",-- "community.comment.deleted",-- "community.comment.voted"-- ],-- "implementedBy": [-- "POST /api/v1/comments/threads/resolve",-- "GET /api/v1/comments/threads/:id",-- "POST /api/v1/comments/threads/:id/comments",-- "DELETE /api/v1/comments/:commentId",-- "POST /api/v1/comments/:commentId/votes"-- ]--}@@@ -1,27 +0,0 @@--{-- "id": "community.post.create",-- "version": "1.1",-- "owner": "community",-- "status": "active",-- "visibility": "public",-- "description": "Forum writes as a service: create threads and replies in a space, vote on threads, and edit or delete one's own posts, as the acting subject (X-OV-Subject) or as origin \"ai\" (no author, labelled as AI). Space visibility (public, members, staff), locks and limits are checked by Community.",-- "outputSchema": "common.entity-ref@1",-- "permissions": [-- "community:write"-- ],-- "resourceConstraints": [-- "space_member"-- ],-- "quotaClass": "community-write",-- "events": [-- "community.thread.created",-- "community.post.created"-- ],-- "implementedBy": [-- "POST /api/v1/spaces/:space/threads",-- "POST /api/v1/spaces/:space/threads/:slug/posts",-- "POST /api/v1/spaces/:space/threads/:slug/votes",-- "PUT|DELETE /api/v1/posts/:id",-- "DELETE /api/v1/spaces/:space/threads/:slug"-- ]--}@@@ -1,23 +0,0 @@--{-- "id": "community.pulse.write",-- "version": "1.0",-- "owner": "community",-- "status": "active",-- "visibility": "first-party",-- "description": "Publish public activity of the calling service's own entities to Pulse (EntityRef + title + url + origin user|ai|system + occurred_at), or retract it. Only visibility public is accepted; the ref's service must be the caller's. Provenance is kept: the first record's origin, actor (X-OV-Subject, never for ai/system) and time stand.",-- "inputSchema": "common.entity-ref@1",-- "permissions": [-- "community:write"-- ],-- "resourceConstraints": [-- "owner"-- ],-- "quotaClass": "community-write",-- "events": [-- "community.pulse.item_recorded"-- ],-- "implementedBy": [-- "POST /api/v1/pulse/items",-- "DELETE /api/v1/pulse/items/:service/:type/:id"-- ]--}nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: migrate++-- T10 J6: Community is the only paste authority. The Live proxy that filled these columns is gone and++-- nothing reads them any more: pastes.legacy_media_id (a Media paste id), pastes.legacy_user_id (Live's++-- numeric account id) and paste_comments.legacy_media_id. The drop is a plain migrate (not a contract):++-- no code path reads them, and there is no N-1 release that needs them back, so there is nothing for the++-- SDK's 7-day contract window to protect. legacy_id_map is untouched — it is live and still backs++-- identity subject resolution.++ALTER TABLE pastes DROP COLUMN IF EXISTS legacy_media_id;++ALTER TABLE pastes DROP COLUMN IF EXISTS legacy_user_id;++ALTER TABLE paste_comments DROP COLUMN IF EXISTS legacy_media_id;@@@ -19,7 +19,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},"devDependencies": {@@@ -1236,9 +1236,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.29.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",-- "integrity": "sha512-w5daRSzujokYT1/Q3fu0KdcSZUBEv1pm66H8vIAI2SY0xtOaLqgfkXJpfIz1NgvJuyK8dhXBbLjM8Ow9RZ5x+A==",++ "version": "2.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",++ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -2,7 +2,7 @@"name": "openvibe-community","version": "1.0.0","private": true,-- "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",++ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -30,7 +30,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},"engines": {@@@ -59,102 +59,119 @@.catch(function (err) { t.disabled = false; alert(err.message); });}});-- var shot = $('.shot.nsfw');-- if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } });-- var codeWrap = $('[data-code]');-- if (codeWrap) {-- var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';-- wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); });-- var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);-- }-- // ── Browse: filters + pagination without a full reload ──────-- var browseForm = $('form[data-browse]');-- if (browseForm && window.fetch && history.pushState) {-- var results = $('[data-results]');-- function load(url, push) {-- results.classList.add('loading');-- fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {-- var doc = new DOMParser().parseFromString(html, 'text/html');-- var next = doc.querySelector('[data-results]');-- if (next) results.innerHTML = next.innerHTML;-- var head = doc.querySelector('.page-head'); var mine = $('.page-head'); if (head && mine) mine.innerHTML = head.innerHTML;-- document.title = doc.title;-- if (push) history.pushState({ browse: url }, '', url);-- results.classList.remove('loading');-- }).catch(function () { location.href = url; });++ // ── Per-page wiring ─────────────────────────────────────────++ // Runs once on load and again after openvibe-shared/boost swaps <main> (it fires ov:boost:load on++ // document), so the widgets of the page that just arrived are wired too. Each run replaces the++ // previous run's listeners (AbortController) and closes the previous event stream.++ var wired = null;++ var pulseEs = null;++ function wirePage() {++ if (wired) wired.abort();++ wired = new AbortController();++ var signal = wired.signal;++++ var shot = $('.shot.nsfw');++ if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });++ var codeWrap = $('[data-code]');++ if (codeWrap) {++ var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';++ wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });++ var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);}-- browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); });-- $$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }); });-- results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); });-- window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); });-- }-- // ── New paste: tabs + API submit (text → JSON, screenshot → multipart) ──-- var form = $('form[data-new-paste]');-- if (form && window.fetch && window.FormData) {-- var status = $('[data-status]', form);-- var tabs = $$('.tab', form); var mode = 'text';-- tabs.forEach(function (t) { t.hidden = false; });-- function setMode(m) {-- mode = m;-- tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });-- $$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });-- var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;-- if (content) content.required = m === 'text';-- if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }-- if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';++ // ── Browse: filters + pagination without a full reload ──────++ var browseForm = $('form[data-browse]');++ if (browseForm && window.fetch && history.pushState) {++ var results = $('[data-results]');++ function load(url, push) {++ results.classList.add('loading');++ fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {++ var doc = new DOMParser().parseFromString(html, 'text/html');++ var next = doc.querySelector('[data-results]');++ if (next) results.innerHTML = next.innerHTML;++ var head = doc.querySelector('.page-head'); var mine = $('.page-head'); if (head && mine) mine.innerHTML = head.innerHTML;++ document.title = doc.title;++ if (push) history.pushState({ browse: url }, '', url);++ results.classList.remove('loading');++ }).catch(function () { location.href = url; });++ }++ browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); }, { signal });++ $$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }, { signal }); });++ results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); }, { signal });++ window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); }, { signal });}-- tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }); });-- setMode('text');-- // Dropping or pasting an image anywhere on the form switches to the screenshot mode.-- function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }-- form.addEventListener('dragover', function (e) { e.preventDefault(); });-- form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } });-- document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } });-- form.addEventListener('submit', function (e) {-- e.preventDefault();-- var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';-- var el = form.elements; var p;-- var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };-- if (mode === 'image') {-- var fd = new FormData();-- fd.append('screenshot', el.screenshot.files[0]);-- fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);-- if (common.is_nsfw) fd.append('is_nsfw', '1');-- p = api('/api/pastes/screenshot', { method: 'POST', body: fd });-- } else {-- p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });++ // ── New paste: tabs + API submit (text → JSON, screenshot → multipart) ──++ var form = $('form[data-new-paste]');++ if (form && window.fetch && window.FormData) {++ var status = $('[data-status]', form);++ var tabs = $$('.tab', form); var mode = 'text';++ tabs.forEach(function (t) { t.hidden = false; });++ function setMode(m) {++ mode = m;++ tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });++ $$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });++ var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;++ if (content) content.required = m === 'text';++ if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }++ if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';}-- p.then(function (out) {-- var slug = out && (out.slug || (out.paste && out.paste.slug));-- if (!slug) throw new Error('No paste came back');-- status.textContent = 'Done — opening your paste…';-- location.href = '/p/' + encodeURIComponent(slug);-- }).catch(function (err) {-- btn.disabled = false; status.className = 'form-status error';-- status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;-- });-- });-- }++ tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }, { signal }); });++ setMode('text');++ // Dropping or pasting an image anywhere on the form switches to the screenshot mode.++ function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }++ form.addEventListener('dragover', function (e) { e.preventDefault(); }, { signal });++ form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } }, { signal });++ document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } }, { signal });++++ form.addEventListener('submit', function (e) {++ e.preventDefault();++ var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';++ var el = form.elements; var p;++ var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };++ if (mode === 'image') {++ var fd = new FormData();++ fd.append('screenshot', el.screenshot.files[0]);++ fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);++ if (common.is_nsfw) fd.append('is_nsfw', '1');++ p = api('/api/pastes/screenshot', { method: 'POST', body: fd });++ } else {++ p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });++ }++ p.then(function (out) {++ var slug = out && (out.slug || (out.paste && out.paste.slug));++ if (!slug) throw new Error('No paste came back');++ status.textContent = 'Done — opening your paste…';++ location.href = '/p/' + encodeURIComponent(slug);++ }).catch(function (err) {++ btn.disabled = false; status.className = 'form-status error';++ status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;++ });++ }, { signal });++ }-- // Pulse follows OpenVibe.Events realtime (roadmap WS-F task 1): on the first page, public activity-- // from here and the other sites arrives over events.openvibe.network/realtime/stream, and a bar-- // offers to show it (the page itself stays server-rendered, so a reload is the whole update).-- var pulse = document.querySelector('[data-pulse-live]');-- if (pulse && typeof EventSource !== 'undefined') {-- var topics = ['community.paste.created', 'community.thread.created', 'community.post.created', 'live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published'];-- var bar = pulse.querySelector('.pulse-new'), show = pulse.querySelector('[data-pulse-show]'), fresh = 0;-- var es = new EventSource('https://events.openvibe.network/realtime/stream?topics=' + encodeURIComponent(topics.join(',')));-- es.onmessage = function (m) {-- var d; try { d = JSON.parse(m.data); } catch (e) { return; }-- if (!d || !d.event || topics.indexOf(d.event.event_type) < 0) return;-- fresh++;-- show.textContent = fresh === 1 ? 'Show 1 new item' : 'Show ' + fresh + ' new items';-- bar.hidden = false;-- };-- show.addEventListener('click', function () { es.close(); location.reload(); });-- window.addEventListener('pagehide', function () { es.close(); });++ // Pulse follows OpenVibe.Events realtime (roadmap WS-F task 1): on the first page, public activity++ // from here and the other sites arrives over events.openvibe.network/realtime/stream, and a bar++ // offers to show it (the page itself stays server-rendered, so a reload is the whole update).++ if (pulseEs) { pulseEs.close(); pulseEs = null; }++ var pulse = document.querySelector('[data-pulse-live]');++ if (pulse && typeof EventSource !== 'undefined') {++ var topics = ['community.paste.created', 'community.thread.created', 'community.post.created', 'live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published'];++ var bar = pulse.querySelector('.pulse-new'), show = pulse.querySelector('[data-pulse-show]'), fresh = 0;++ var es = new EventSource('https://events.openvibe.network/realtime/stream?topics=' + encodeURIComponent(topics.join(',')));++ pulseEs = es;++ es.onmessage = function (m) {++ var d; try { d = JSON.parse(m.data); } catch (e) { return; }++ if (!d || !d.event || topics.indexOf(d.event.event_type) < 0) return;++ fresh++;++ show.textContent = fresh === 1 ? 'Show 1 new item' : 'Show ' + fresh + ' new items';++ bar.hidden = false;++ };++ show.addEventListener('click', function () { es.close(); location.reload(); }, { signal });++ window.addEventListener('pagehide', function () { es.close(); }, { signal });++ }}++ wirePage();++ document.addEventListener('ov:boost:load', wirePage);})();@@@ -6,7 +6,7 @@* Express app factory (server/index.js listens; tests build their own instance).** Pages (server-rendered) API / machine-- * GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below++ * GET / home ALL /api/pastes/* the native paste API (pastes/api.js)* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)@@@ -18,13 +18,9 @@* GET /pulse the network's public activity* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)*-- * Comments, the forum, Pulse and the relay live in Community's database in every mode.-- *-- * PASTES_AUTHORITY (config.pastesAuthority):-- * 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through-- * Live's API, raw text and screenshots bounce to OpenVibe.Media.-- * 'community' this site's own database is the authority: the native API-- * (pastes/api.js), pages, raw text and screenshots all come from the store.++ * Comments, the forum, Pulse and the relay live in Community's database, alongside the pastes++ * Community itself is the only authority for: the native API (pastes/api.js), pages, raw text++ * and screenshots all come from the store.*/const path = require('path');const express = require('express');@@@ -33,14 +29,12 @@ const cookieParser = require('cookie-parser');const rateLimit = require('express-rate-limit');const config = require('./config');--const live = require('./live-client');const catalog = require('./pastes/catalog');const source = require('./pastes/source');const seo = require('./seo');const pages = require('./render/pages');const { assetVersion } = require('./render/layout');--const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');--const { createPastesProxy } = require('./pastes/proxy');++const { createAuthClient, createAuthRoutes } = require('./auth/routes');const { getDb } = require('./db');const { createNetworkIdentity } = require('./identity/network');const { createViewerResolver } = require('./identity/viewer');@@@ -62,6 +56,7 @@ const { createRelayApi } = require('./relay/api');const { createActorLimits } = require('./actor-limits');const { pulsePage } = require('./render/pulse');const { extensionFor } = require('./render/highlight');++const { createIndexNow } = require('openvibe-shared/indexnow');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;@@@ -73,6 +68,7 @@ async function createApp(opts = {}) {app.set('trust proxy', config.trustProxy);// What this server runs (ADR-016); the shared navbar's release-watch polls it.const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers// direct loopback callers only (Track O). Request metrics only: no content counts.const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });@@@ -154,19 +150,23 @@ async function createApp(opts = {}) {const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });// A space's chat room on OpenVibe.Chat (chat-rooms.js): attached with the person's own token.const chatRooms = opts.chatRooms || require('./chat-rooms').createChatRooms({ config });-- const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits });-- const community = config.pastesAuthority === 'community';-- const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY; unset → off++ // (nothing mounted, nothing sent). The key file is served at /<key>.txt and the paste and forum++ // services ping the engines when a public, indexable page appears, changes or goes away.++ const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });++ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });++ const comments = createCommentService({ db, network, limits: opts.commentLimits });seo.useForum(forum);-- Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments });++ Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, indexnow });if (opts.startRelay !== false) relay.start();-- // ── Paste authority ──────────────────────────────────────-- if (community) {++ // ── Pastes: Community's own store is the only authority ──++ {const { createMediaFiles } = require('./media/files');const { createPasteService } = require('./pastes/service');-- // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when signed in;-- // C-24), or the v1 community file store (legacy:community:file:<key>) without Community's service principal.++ // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when++ // signed in), or the v1 community file store (legacy:community:file:<key>) without++ // Community's service principal.const files = createMediaFiles({ config });const media = opts.media || {tokens: files.tokens,@@@ -176,11 +176,9 @@ async function createApp(opts = {}) {return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };},};-- const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse });++ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse, indexnow });source.use(service);app.locals.pastes = service;-- } else {-- source.use(null);}// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).const anonWriteLimiter = rateLimit({@@@ -201,10 +199,9 @@ async function createApp(opts = {}) {app.locals.valkey = valkey;const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });-- // ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──++ // ── /api/pastes — the native paste API ───────────────────app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));-- if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));-- else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));++ app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));// ── /api/v1: comments, forum, Pulse, relay admin ─────────// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.@@@ -231,8 +228,8 @@ async function createApp(opts = {}) {// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin// sendBeacon, no auth) into /metrics as release_client_updates_total.release.mount(app, { registry: metrics.registry });-- // Readiness reports what is actually served: 503 only without the database; Network key,-- // Live (live mode) and Media (community mode) failures degrade (server/observability.js).++ // Readiness reports what is actually served: 503 only without the database; the Network key++ // and Media failures degrade (server/observability.js).const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });app.get('/api/ready', readiness.handler);@@@ -255,10 +252,12 @@ async function createApp(opts = {}) {app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt()));app.get('/sitemap.xml', seo.sitemapHandler);app.get('/feed.xml', wrap(seo.feedHandler));++ // GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).++ if (indexnow.enabled) app.use(indexnow.keyFile);// ── Pages ────────────────────────────────────────────────-- // Pages are for browsers: in community mode the viewer (subject, staff) is resolved too.-- const withUser = community ? viewers.middleware({ services: false }) : optionalAuth(auth);++ // Pages are for browsers: the viewer (subject, staff) is resolved here too.++ const withUser = viewers.middleware({ services: false });const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);@@@ -309,37 +308,30 @@ async function createApp(opts = {}) {html(res, pages.pastePage({ paste, related, user: req.user }));}));-- if (community) {-- // The store is the authority: raw text is served here, the screenshot link is the stored-- // Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)-- const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');-- app.get('/p/:slug/raw', withUser, async (req, res) => {-- if (!SLUG_RE.test(req.params.slug)) return notFound(res);-- try {-- const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));-- if (out.redirect) return res.redirect(302, out.redirect);-- res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');-- res.type('text/plain; charset=utf-8').send(out.content);-- } catch (err) {-- if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');-- if (err.status === 404) return notFound(res);-- throw err;-- }-- });-- app.get('/p/:slug/screenshot', withUser, async (req, res) => {-- if (!SLUG_RE.test(req.params.slug)) return notFound(res);-- try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }-- catch (err) {-- if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');-- if (err.status === 404) return notFound(res);-- throw err;-- }-- });-- } else {-- // Raw text and screenshots are public on OpenVibe.Media — bounce there.-- app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));-- app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));-- }++ // Raw text is served here, the screenshot link is the stored Media URL.++ const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');++ app.get('/p/:slug/raw', withUser, async (req, res) => {++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);++ try {++ const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));++ if (out.redirect) return res.redirect(302, out.redirect);++ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');++ res.type('text/plain; charset=utf-8').send(out.content);++ } catch (err) {++ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');++ if (err.status === 404) return notFound(res);++ throw err;++ }++ });++ app.get('/p/:slug/screenshot', withUser, async (req, res) => {++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);++ try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }++ catch (err) {++ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');++ if (err.status === 404) return notFound(res);++ throw err;++ }++ });app.get('/p/:slug/download', withUser, wrap(async (req, res) => {const { slug } = req.params;@@@ -347,7 +339,7 @@ async function createApp(opts = {}) {let paste;try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }-- if (paste.type === 'screenshot') return res.redirect(302, community ? (paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`) : live.screenshotUrl(slug));++ if (paste.type === 'screenshot') return res.redirect(302, paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`);res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);res.set('Cache-Control', 'private, no-cache');res.type('text/plain; charset=utf-8').send(String(paste.content || ''));@@@ -361,10 +353,9 @@ async function createApp(opts = {}) {html(res, pages.newPage({ user: req.user, fork }));}));-- // No-JS fallback: a plain form post becomes the same JSON create the API path uses.-- // In community mode the anonymous-write budget applies here too (in 'live' mode Live enforces it).-- const formLimiter = community ? anonWriteLimiter : (_req, _res, next) => next();-- app.post('/new', withUser, formLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {++ // No-JS fallback: a plain form post becomes the same JSON create the API path uses, under the++ // same anonymous-write budget.++ app.post('/new', withUser, anonWriteLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {const b = req.body || {};const values = {title: String(b.title || '').slice(0, 200),@@@ -422,16 +413,10 @@ async function createApp(opts = {}) {});// eslint-disable-next-line no-unused-varsapp.use((err, req, res, _next) => {-- const upstream = err && err.name === 'LiveApiError';-- if (!upstream) console.error('[App]', err && err.stack ? err.stack : err);-- else console.warn('[App] upstream:', err.status, err.message);++ console.error('[App]', err && err.stack ? err.stack : err);if (res.headersSent) return;-- if (req.path.startsWith('/api/')) return res.status(upstream ? 502 : 500).json({ error: upstream ? 'Paste service unavailable' : 'Internal error' });-- html(res, pages.errorPage({-- status: upstream ? 502 : 500,-- title: upstream ? 'The paste service is taking a break' : 'Something went wrong',-- message: upstream ? 'OpenVibe.Live did not answer. Pastes come back the moment it does — try again in a minute.' : 'This one is on us. Please try again.',-- }), upstream ? 502 : 500);++ if (req.path.startsWith('/api/')) return res.status(500).json({ error: 'Internal error' });++ html(res, pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }), 500);});return app;@@@ -65,7 +65,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;const PAGE = 30;const REPLY_PAGE = 20;--function createCommentService({ db, network = null, pastesLocal = false, limits = {} } = {}) {++function createCommentService({ db, network = null, limits = {} } = {}) {const authors = createAuthors({ db, network });const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });@@@ -144,7 +144,6 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsasync function checkCommunityRef(ref) {if (ref.service !== 'community') return;if (ref.type === 'paste') {-- if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check hereconst p = await pasteStore.getBySlug(db, ref.id);if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');} else if (ref.type === 'post') {@@@ -159,7 +158,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */async function entityOwner(t) {if (t.ref_service !== 'community') return null;-- if (t.ref_type === 'paste' && pastesLocal) { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }++ if (t.ref_type === 'paste') { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = await db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }return null;}@@@ -43,11 +43,6 @@ module.exports = {secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,},-- // OpenVibe.Live — backs every paste read and write. The pastes themselves are stored in-- // OpenVibe.Media, but we go through Live rather than straight to Media: Live owns the-- // accounts these pastes belong to, so it is the only service that can turn a signed-in-- // visitor (a Network JWT) into the user id Media files the write under.-- liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),@@@ -56,12 +51,9 @@ module.exports = {// Media's internal address: new screenshot uploads go to its file store (community app).mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),-- // Who answers /api/pastes/* and the paste pages:-- // 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.-- // 'community' — this site's own database is the authority (server/pastes/api.js).-- pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',-- // PostgreSQL (ADR-035) for Community's own data: comments, the forum, Pulse, the relay's bookkeeping, and pastes-- // once authority=community. DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,++ // PostgreSQL (ADR-035) for Community's own data: pastes (Community is their only authority),++ // comments, the forum, Pulse and the relay's bookkeeping. DATABASE_URL serves (PgBouncer);++ // DATABASE_DIRECT_URL migrates (owner). Without them,// development uses an embedded PGlite database in data/pglite.db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },// Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.@@@ -86,6 +78,11 @@ module.exports = {unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,},++ // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set, the key file is served at++ // /<key>.txt and a public, indexable page appearing, changing or going away pings the engines.++ // Unset: off — nothing is mounted and nothing is sent.++ indexnow: { key: process.env.INDEXNOW_KEY || '' },++// OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Community asks Chat, with the// signed-in person's own Network token, whether they manage the room; the space page links it.chat: {@@@ -3,9 +3,9 @@/*** Community's own database: PostgreSQL (ADR-035, roadmap WS-X2). The schema is migrations/NNNN_*.sql, applied at boot.*-- * Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit++ * Community is the only authority for pastes: pastes, their edit* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,-- * posts), the Discord relay's bookkeeping and Pulse live here in every mode. People are referenced by Network subject ids++ * posts), the Discord relay's bookkeeping and Pulse live here too. People are referenced by Network subject ids* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache* of what the Network says about them.*@@@ -97,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;const BODY_MAX = 40_000;const THREADS_PER_DAY = 20;--function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {} } = {}) {++function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {const authors = createAuthors({ db, network });const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });@@@ -109,6 +109,20 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;const hook = async (fn) => { try { await fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };++ // IndexNow (openvibe-shared/indexnow): a public, indexable space or thread page appearing, changing++ // or going away pings the engines with its path (and the space's, when a thread lands or leaves) and++ // the sitemap. Members-only (gated) spaces and threads are not in the sitemap, so they never ping.++ const site = String(config.baseUrl || '').replace(/\/$/, '');++ const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);++ const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };++ const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}/s/${s.slug}`, `${site}/sitemap.xml`]); };++ const pingThread = (space, t, { withSpace = false } = {}) => {++ if (!indexableSpace(space) || !t || t.members_only_owner) return;++ const urls = [`${site}/s/${space.slug}/t/${t.slug}`, `${site}/sitemap.xml`];++ if (withSpace) urls.push(`${site}/s/${space.slug}`);++ ping(urls);++ };++const moderator = (v) => discussionModerator(v);const person = (v) => !!(v && v.subject);@@@ -351,6 +365,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi});if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));++ pingThread(await store.getSpaceById(db, thread.space_id), thread, { withSpace: true });return { ok: true, id: thread.id, deleted: 'thread' };}@@@ -473,6 +488,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst fields = await spaceFields(body, space);const next = await store.updateSpace(db, space.id, fields);const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;++ pingSpace(space);++ pingSpace(row);return { space: await shapeSpace(row, await membersOnly(row.members_only_owner)) };},@@@ -490,6 +507,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.slug === slug);++ pingSpace(row);return { space: await shapeSpace(row) };},@@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viattached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`).run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });++ pingSpace(space);return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };},@@@ -534,6 +553,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viawait db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';++ pingSpace(space);return { detached: true, chat };},@@@ -639,6 +659,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (pulse) await hook(async () => await pulse.threadCreated(created, target));if (relay) await hook(async () => await relay.enqueueThread(created, target));const projections = await authors.projectionsFor([created.author_subject]);++ pingThread(target, created, { withSpace: true });return { thread: await shapeThread(created, target, v, projections, null) };},@@@ -680,7 +701,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;-- return { category: shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position })) };++ const category = shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position }));++ pingSpace(space);++ return { category };},/** Delete a category — moderators. Its threads stay, without a category. */@@@ -688,6 +711,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');const space = await spaceFor(v, spaceSlug);if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');++ pingSpace(space);return { ok: true };},@@@ -700,6 +724,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);const projections = await authors.projectionsFor([next.author_subject]);++ pingThread(space, next);return { thread: await shapeThread(next, space, v, projections, null) };},@@@ -711,6 +736,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');const next = await store.setThreadStatus(db, thread.id, body.status);const projections = await authors.projectionsFor([next.author_subject]);++ pingThread(space, next);return { thread: await shapeThread(next, space, v, projections, null) };},@@@ -789,6 +815,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (pulse) await hook(async () => await pulse.threadCreated(thread, space));if (relay) await hook(async () => await relay.enqueueThread(thread, space));const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);++ pingThread(space, thread, { withSpace: true });return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };},@@@ -815,6 +842,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi// Where the new post lands: its page in the thread (posts are numbered in id order).const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);++ pingThread(space, thread);return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };},@@@ -828,6 +856,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));const projections = await authors.projectionsFor([next.author_subject]);++ pingThread(space, thread);return { post: shapePost(next, v, projections) };},@@@ -842,7 +871,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */async deletePost(v, postId) {-- const { post, thread } = await postFor(v, postId);++ const { post, thread, space } = await postFor(v, postId);if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');if (post.is_opening) return await removeThread(v, thread);await db.tx(async () => {@@@ -851,6 +880,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi});if (pulse) await hook(async () => await pulse.postGone(post.id));if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));++ pingThread(space, thread);return { ok: true, id: post.id };},@@@ -889,6 +919,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn r;});const projections = await authors.projectionsFor([next.author_subject]);++ pingThread(space, next);return { thread: await shapeThread(next, space, v, projections, null) };},@@@ -908,6 +939,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst next = await store.setThreadMembersOnly(db, thread.id, owner);if (owner) await hideGated([thread.id]);const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);++ pingThread(space, thread);++ pingThread(space, next);return { thread: await shapeThread(next, space, v, projections, null) };},@@@ -919,6 +952,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst owner = gateOwner(v, requested === undefined ? null : requested, null);const next = await store.setSpaceMembersOnly(db, space.id, owner);if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));++ pingSpace(space);++ pingSpace(next);return { space: await shapeSpace(next, await membersOnly(next.members_only_owner)) };},@@@ -89,7 +89,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);if (others) {await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,-- ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);++ ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);add(retained, 'tombstones', 1);} else {await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);@@@ -1,31 +1,12 @@'use strict';/**-- * Capability checks for service tokens, including the ids Community introduces before the-- * contracts library knows them.-- *-- * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in-- * its manifests yet. The ids below are proposed in docs/capabilities-proposal/ and go into a-- * contracts release; until then a grant is decided locally with the library's own matching rule-- * (the exact id, or a `prefix.*` grant covering it). An id the library does know always goes-- * through the library, so the day the release lands nothing changes here.++ * Capability checks for service tokens, through openvibe-contracts' manifests.*/const { capabilities } = require('openvibe-contracts');--const PROPOSED = new Set([-- 'community.comment.write',-- 'community.comment.moderate',-- 'community.pulse.write',-- 'community.post.create',--]);--/** → { allowed, code, reason } like capabilities.check(). */function checkCapability(claims, capabilityId) {-- if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {-- return capabilities.grants(claims && claims.cap, capabilityId)-- ? { allowed: true, code: null, reason: null }-- : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };-- }return capabilities.check(claims, capabilityId);}@@@ -53,4 +34,4 @@ function discussionModerator(viewer) {return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'community.comment.moderate'));}--module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator, PROPOSED };++module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator };@@@ -25,9 +25,7 @@ const { createApp } = require('./app');} catch (err) { console.warn('[Roadmap] not synced:', err.message); }const server = app.listen(config.port, config.host, () => {console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);-- console.log(config.pastesAuthority === 'community'-- ? `[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`-- : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);++ console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);});server.keepAliveTimeout = 65_000;// Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).ddeleted file mode 100644@@@ -1,95 +0,0 @@--'use strict';----/**-- * OpenVibe.Live client — server-side reads of the paste API for rendered pages.-- *-- * Everything paste-shaped goes through Live's /api/pastes/* (see config.liveInternalUrl for-- * why). The visitor's Network JWT and real client address are forwarded on every call, so a-- * signed-in visitor sees their own private/unlisted pastes and Live's per-address rate limits-- * and view counting see the visitor, not this proxy.-- */--const config = require('./config');----class LiveApiError extends Error {-- constructor(status, body) {-- super((body && body.error) || `Live API ${status}`);-- this.name = 'LiveApiError';-- this.status = status;-- this.body = body;-- }--}----function _qs(query) {-- const params = new URLSearchParams();-- for (const [k, v] of Object.entries(query || {})) {-- if (v === undefined || v === null || v === '') continue;-- params.set(k, String(v));-- }-- const s = params.toString();-- return s ? `?${s}` : '';--}----/**-- * @param {string} apiPath e.g. '/pastes?limit=10' (relative to /api)-- * @param {object} [opts] { method, query, body, token, ip, timeoutMs }-- */--async function request(apiPath, { method = 'GET', query, body, token, ip, timeoutMs = 10_000 } = {}) {-- const url = `${config.liveInternalUrl}/api${apiPath}${_qs(query)}`;-- const headers = { Accept: 'application/json' };-- if (token) headers.Authorization = `Bearer ${token}`;-- if (ip) headers['X-Forwarded-For'] = ip;-- const opts = { method, headers, signal: AbortSignal.timeout(timeoutMs) };-- if (body !== undefined) {-- headers['Content-Type'] = 'application/json';-- opts.body = JSON.stringify(body);-- }-- let res, text;-- try {-- res = await fetch(url, opts);-- text = await res.text();-- } catch (err) {-- // Unreachable / timed out: surface as an upstream error so pages show the outage-- // message instead of a generic 500.-- throw new LiveApiError(503, { error: err.name === 'TimeoutError' ? 'Paste service timed out' : 'Paste service unreachable' });-- }-- let data = null;-- try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }-- if (!res.ok) throw new LiveApiError(res.status, data);-- return data;--}----// ── Pastes ───────────────────────────────────────────────────--/** Public listing: { pastes, total, limit, offset }. */--async function listPastes(query, ctx = {}) {-- return await request('/pastes', { query, token: ctx.token, ip: ctx.ip });--}----/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */--async function getPaste(slug, ctx = {}) {-- const out = await request(`/pastes/${encodeURIComponent(slug)}`, {-- query: ctx.noView ? { no_view: 1 } : undefined, token: ctx.token, ip: ctx.ip,-- });-- return out && out.paste ? out.paste : out;--}----/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */--async function listByUser(username, query, ctx = {}) {-- return await request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });--}----/** Create a text paste on the visitor's behalf (no-JS form fallback). */--async function createPaste(body, ctx = {}) {-- return await request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });--}----// ── Public URL builders (OpenVibe.Media serves the bytes) ────--function rawUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/raw`; }--function screenshotUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }--/** Absolute-ize a Media-relative URL (screenshot_url from the API). */--function mediaPublicUrl(u) {-- if (!u) return null;-- if (/^https?:\/\//i.test(u)) return u;-- return `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`;--}----module.exports = { LiveApiError, request, listPastes, getPaste, listByUser, createPaste, rawUrl, screenshotUrl, mediaPublicUrl };@@@ -1,7 +1,7 @@'use strict';/**-- * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2) and screenshot pastes (C-24). Community keeps a++ * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2) and screenshot pastes. Community keeps a* `med_` reference and the public URL; the bytes live in Media under the `community` tenant, owned by* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,* namespace community):@@@ -6,10 +6,7 @@* network_jwks optional the Network signing key has loaded. Without it public pages and reads* still work, but nobody can sign in or write as a signed-in viewer or* service (those answer 503), so it degrades rather than fails-- * live optional in PASTES_AUTHORITY=live mode: Live answers /api/health (paste pages and-- * /api/pastes read through it; comments, forum and Pulse do not)-- * media optional in PASTES_AUTHORITY=community mode: Media answers /healthz-- * (screenshot and file uploads; paste text is local)++ * media optional Media answers /healthz (screenshot and file uploads; paste text is local)** Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,* threads) are deliberately not metrics.@@@ -49,17 +46,13 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nu},},];-- if (config.pastesAuthority === 'community') {-- checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });-- } else {-- checks.push({ name: 'live', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.liveInternalUrl}/api/health`, fetchImpl) });-- }++ checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });return createReadiness({service: 'community',release,checks,details: async (body) => {-- const out = { pastes_authority: config.pastesAuthority };++ const out = {};if (relay && relay.enabled && body.checks.db.status === 'ok') {// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.const st = await relay.status();@@@ -1,10 +1,10 @@'use strict';/**-- * /api/pastes/* — Community's native paste API (PASTES_AUTHORITY=community).++ * /api/pastes/* — Community's native paste API.*-- * Same paths, bodies, status codes and response shapes the browser clients already use against-- * OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.++ * Same paths, bodies, status codes and response shapes the browser clients already use; the rules++ * live in service.js.** GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)* sort=newest (default) | oldest | top (views, a like worth five views);@@@ -150,7 +150,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run(async (req) => await service.deleteComment(req.viewer, slug(req), req.params.commentId)));-- // Avatars belong to the Network account; Live's proxy set a Live-local avatar here.++ // Avatars belong to the Network account, not to a paste.router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));router.use((req, res) => res.status(404).json({ error: 'Not found' }));@@@ -3,19 +3,17 @@/*** Catalog — the site's view over recent PUBLIC pastes.*-- * Live's list endpoint (Media behind it) knows newest/oldest, a text search and a per-user-- * filter; it does not sort by views or filter by language. This keeps the latest window of-- * public pastes (Media's page cap) in memory for a short while and answers trending,-- * related, language counts and the views/language browse modes from it. Everything here is-- * fetched anonymously, so nothing unlisted or private can ever appear on a shared page.++ * The store knows newest/oldest, a text search and a per-user filter; it does not sort by views or++ * filter by language. This keeps the latest window of public pastes in memory for a short while++ * and answers trending, related, language counts and the views/language browse modes from it.++ * Everything here is read anonymously, so nothing unlisted or private can ever appear on a shared page.*-- * With the community authority (source.local) the store answers and the window is kept only-- * LOCAL_TTL_MS — long enough that one page's latest/trending/languages share a single query.++ * The window is kept only LOCAL_TTL_MS — long enough that one page's latest/trending/languages++ * share a single query.*/const source = require('./source');--const WINDOW = 200; // Media's maximum page size--const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live++const WINDOW = 200; // page cap for one window queryconst LOCAL_TTL_MS = 2000;const PER_PAGE = 24;@@@ -25,14 +23,14 @@ function _textOnlyPublic(rows) {return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));}--/** The latest public pastes (cached). Never throws — an unreachable Live yields the stale list or []. */++/** The latest public pastes (cached). Never throws — an unavailable store yields the stale list or []. */async function recent() {const now = Date.now();if (_recent.promise) return _recent.promise;-- if (now - _recent.at < (source.local ? LOCAL_TTL_MS : TTL_MS)) return _recent.pastes;++ if (now - _recent.at < LOCAL_TTL_MS) return _recent.pastes;_recent.promise = source.listPastes({ limit: WINDOW, offset: 0 }).then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })-- .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });++ .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - LOCAL_TTL_MS + 5000; return _recent.pastes; });return _recent.promise;}@@@ -74,7 +72,7 @@ const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p./*** Browse: { q, sort: 'new'|'views', lang, page } → { pastes, total, page, pages, perPage, ... }.-- * Newest with no language filter pages straight through Live (its total is exact); the views++ * Newest with no language filter pages straight through the store (its total is exact); the views* sort and the language filter are answered from the recent window.*/async function browse(query = {}, ctx = {}) {ddeleted file mode 100644@@@ -1,86 +0,0 @@--'use strict';----/**-- * /api/pastes/* → OpenVibe.Live /api/pastes/* (transparent, server-side)-- *-- * The browser talks to /api/pastes/* on this site; we proxy to the same API the streaming-- * site's own paste pages use. This deliberately does NOT call Media's tenant API directly:-- * the visitor's JWT names the account by its NETWORK id, while the user_id Media stores for-- * these pastes is Live's own — different numbers for the same person, so a write filed here-- * straight into Media would land under whichever unrelated Live account holds that number.-- * Live resolves the visitor against its own accounts before writing, which only Live can do,-- * and this inherits its author names, anonymous-post limits and permission checks rather-- * than reimplementing them.-- *-- * Mounted BEFORE the body parsers: the request body is streamed upstream byte-for-byte-- * (JSON, multipart screenshot uploads, anything), with the visitor's token as a Bearer header-- * and the real client address in X-Forwarded-For.-- */--const config = require('../config');--const { extractToken } = require('../auth/routes');----const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'cookie', 'authorization', 'content-length']);--const BODYLESS = new Set(['GET', 'HEAD', 'OPTIONS']);----function createPastesProxy(opts = {}) {-- const upstreamBase = () => `${opts.liveUrl || config.liveInternalUrl}/api/pastes`;---- return async function pastesProxy(req, res) {-- const target = `${upstreamBase()}${req.url === '/' ? '' : req.url}`;-- // The path is the visitor's: "/api/pastes/../../internal/x" (or %2e%2e) would resolve to another of-- // Live's routes, with the visitor's token; an encoded slash or backslash is one for Live once decoded.-- // Only Live's paste API is proxied.-- const base = new URL(upstreamBase()).pathname;-- let resolved = null;-- try { resolved = new URL(target).pathname; } catch { /* not a URL: refused below */ }-- if (!resolved || (resolved !== base && !resolved.startsWith(`${base}/`)) || /%(2f|5c)/i.test(resolved)) return res.status(404).json({ error: 'Not found' });-- const headers = {};-- for (const [k, v] of Object.entries(req.headers)) {-- if (HOP_BY_HOP.has(k) || v == null) continue;-- headers[k] = Array.isArray(v) ? v.join(', ') : v;-- }-- // Forward the visitor's JWT — Live verifies it and maps it to a local account.-- const userToken = extractToken(req);-- if (userToken) headers.authorization = `Bearer ${userToken}`;-- // Live rate-limits, bans and counts views by client address. Without this every visitor-- // arriving through this site would share one bucket and one identity.-- if (req.ip) headers['x-forwarded-for'] = req.ip;-- headers['x-forwarded-host'] = req.get('host') || '';-- headers.accept = req.get('accept') || 'application/json';---- const fetchOpts = { method: req.method, headers, redirect: 'manual' };-- if (!BODYLESS.has(req.method)) {-- if (req.headers['content-length']) headers['content-length'] = req.headers['content-length'];-- fetchOpts.body = req; // stream the body through untouched-- fetchOpts.duplex = 'half';-- }-- const ctrl = new AbortController();-- const timer = setTimeout(() => ctrl.abort(), BODYLESS.has(req.method) ? 20_000 : 120_000);-- fetchOpts.signal = ctrl.signal;-- const onClientGone = () => { try { ctrl.abort(); } catch { /* */ } };-- res.on('close', onClientGone);---- try {-- const upstream = await fetch(target, fetchOpts);-- res.status(upstream.status);-- for (const [k, v] of upstream.headers) {-- if (k === 'content-type' || k === 'cache-control' || k === 'location' || k === 'retry-after'-- || k === 'ratelimit-limit' || k === 'ratelimit-remaining' || k === 'ratelimit-reset') {-- res.setHeader(k, v);-- }-- }-- const raw = Buffer.from(await upstream.arrayBuffer());-- res.send(raw);-- } catch (err) {-- if (!res.headersSent) {-- console.warn('[PasteProxy]', err.name === 'AbortError' ? 'upstream timed out' : err.message);-- res.status(502).json({ error: 'Could not reach the paste service' });-- }-- } finally {-- clearTimeout(timer);-- res.off('close', onClientGone);-- }-- };--}----module.exports = { createPastesProxy };@@@ -3,9 +3,8 @@/*** Paste service — the rules of the paste system, on top of store.js.*-- * This reproduces what OpenVibe.Media's paste API did behind OpenVibe.Live's /api/pastes proxy-- * (status codes, error texts, response shapes, limits), with identity reworked around Network-- * subjects:++ * This is the paste API Community's own pages and browser clients use: the same status codes,++ * error texts, response shapes and limits, with identity reworked around Network subjects:* - a "limited" caller is one acting as a person (a signed-in browser, or a service naming* X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as* they applied to Media's user callers. Anonymous writes are limited per address in api.js.@@@ -65,10 +64,22 @@ function sinceParam(value) {return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;}--function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {++function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null } = {}) {const L = { ...DEFAULT_LIMITS, ...limits };// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };++ // IndexNow (openvibe-shared/indexnow): a public, indexable paste page appearing, changing or going away++ // pings the engines with its path and the sitemap. Private, unlisted, NSFW and burn-after-read pages are++ // not in the sitemap, so they never ping.++ const site = String(config.baseUrl || '').replace(/\/$/, '');++ const indexable = (p) => !!(p && p.visibility === 'public' && !Number(p.is_nsfw) && !Number(p.burn_after_read));++ const pingIndexNow = (...rows) => {++ if (!indexnow || !indexnow.enabled) return;++ const slugs = new Set();++ for (const p of rows) if (indexable(p)) slugs.add(p.slug);++ if (!slugs.size) return;++ indexnow.pingSoon([...slugs].map((s) => `${site}/p/${s}`).concat(`${site}/sitemap.xml`));++ };const visitorSecret = process.env.VIEW_HASH_SECRET|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');@@@ -287,6 +298,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limasync function created(row, extra = {}) {await tell((p) => p.pasteCreated(row));++ pingIndexNow(row);return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };}@@@ -467,6 +479,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn r;});await tell((pl) => pl.pasteChanged(row));++ pingIndexNow(p, row);return { paste: await shapeOne(row, v) };},@@@ -480,6 +493,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });});await tell((pl) => pl.pasteGone(p.slug));++ pingIndexNow(p);return { success: true };},@@@ -626,6 +640,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (action === 'delete') await store.softDelete(db, p.id);else await store.setVisibility(db, p.id, action);if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));++ pingIndexNow(action === 'public' ? { ...p, visibility: 'public' } : p);done++;}if (done) await events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });@@@ -654,6 +669,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limawait events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });return r;});++ pingIndexNow(row);return { paste: await shapeOne(row, v) };},@@@ -664,6 +680,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);const row = await store.setAi(db, p.id, summary, tags);++ pingIndexNow(row);return { ok: true, paste: await shapeOne(row, v) };},};@@@ -1,19 +1,16 @@'use strict';/**-- * Where server-rendered pages read pastes from.++ * Where server-rendered pages read pastes from: Community's own store, through the same service++ * the API uses, so a page and the API can never disagree about visibility or view counts.*-- * 'live' authority → live-client.js (OpenVibe.Live's API, Media behind it) — the default.-- * 'community' authority → this site's own store, through the same service the API uses, so a-- * page and the API can never disagree about visibility or view counts.-- *-- * Both expose the live-client call shapes (listPastes / getPaste / listByUser / createPaste);-- * `ctx` carries the visitor ({ token, ip, userAgent, viewer, noView }).++ * app.js installs the service once at boot (source.use(service)); the call shapes++ * (listPastes / getPaste / listByUser / createPaste) are stable, and `ctx` carries the visitor++ * ({ token, ip, userAgent, viewer, noView }).*/--const live = require('../live-client');const { ANONYMOUS } = require('../identity/viewer');--let current = { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };++let current = null;function localSource(service) {const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;@@@ -27,15 +24,15 @@ function localSource(service) {};}--/** Switch to the store (community authority); passing nothing goes back to Live. */++/** Install the store (the only paste authority). */function use(service) {-- current = service ? localSource(service) : { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };++ current = localSource(service);return current;}module.exports = {use,-- get local() { return current.local; },++ get local() { return !!(current && current.local); },listPastes: (...a) => current.listPastes(...a),getPaste: (...a) => current.getPaste(...a),listByUser: (...a) => current.listByUser(...a),@@@ -178,7 +178,7 @@ async function listVersions(db, pasteId) {return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);}--/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */++/** Soft delete: the row stays (its slug stays reserved), its content and image link do not. */async function softDelete(db, id) {return await db.tx(async () => {const cur = await getById(db, id);@@@ -31,6 +31,11 @@ function assetVersion(rel) {}function asset(rel) { return `/${rel}?v=${assetVersion(rel)}`; }++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only++// between pages of the same release, and does a normal load across a deploy.++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);function jsonLdScript(objects) {@@@ -55,7 +60,7 @@ function navbarInit(opts) {history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',-- loginUrl: `/auth/login?next=${encodeURIComponent(opts.canonicalPath || '/')}`,++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session toonotificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)};@@@ -134,6 +139,8 @@ ${jsonLdScript(o.jsonLd)}<script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script><script src="${asset('js/community.js')}" defer></script>++<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script></head><body class="${escapeHtml(o.bodyClass || '')}"><div id="navbar-mount"></div>@@@ -152,4 +159,4 @@ document.addEventListener('DOMContentLoaded', function () {</html>`;}--module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };++module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };@@@ -7,7 +7,6 @@*/const config = require('../config');const ovServe = require('openvibe-shared/serve');--const live = require('../live-client');const seo = require('../seo');const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');const frame = require('openvibe-shared/frame');@@@ -16,6 +15,15 @@ const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = rconst NETWORK_URL = 'https://openvibe.network';// ── Small helpers ────────────────────────────────────────────++/** Absolute-ize a Media-relative screenshot URL. */++function mediaAbs(u) {++ if (!u) return null;++ if (/^https?:\/\//i.test(u)) return u;++ return `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`;++}++/** A screenshot's Media address when the stored row has no screenshot_url. */++function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }++function timeAgo(v) {const iso = seo.isoDate(v);if (!iso) return '';@@@ -78,7 +86,7 @@ function pagerHref(state, page) {function pasteCard(p) {const isShot = p.type === 'screenshot';const preview = isShot-- ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(live.mediaPublicUrl(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')++ ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(mediaAbs(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : ''): `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">@@@ -236,9 +244,9 @@ function pastePage({ paste: p, related, user }) {const isShot = p.type === 'screenshot';const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');const description = pasteDescription(p);-- const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;++ const shot = isShot ? mediaAbs(p.screenshot_url) || screenshotFallback(p.slug) : null;const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);-- // Community-authority pastes name their owner by Network subject; Live-proxied ones by name.++ // Pastes name their owner by Network subject; the projection cache supplies the display fields.const isOwner = !!(user && (p.is_owner === true|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));@@@ -196,14 +196,5 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString});await t.close();---- // The forum lives in Community's database in every mode, including PASTES_AUTHORITY=live.-- const live = await boot();-- await check('forum pages work with PASTES_AUTHORITY=live too', async () => {-- const r = await live.get('/s/general');-- assert.strictEqual(r.status, 200);-- has(r.text, '<h1>General</h1>');-- });-- await live.close();done();})();@@@ -6,9 +6,10 @@ const mockNetwork = require('./mock-network');const mockMedia = require('./mock-media');/**-- * opts.authority 'live' (default) or 'community' — the latter runs the native paste API, with Network and-- * Media mocks behind it. Every boot gets a migrated database of its own (./db.js: PGlite, or the-- * PostgreSQL containers under npm run test:pg, with Valkey for the limit counters).++ * opts.authority accepted and ignored: Community is the only paste authority, so every boot runs the++ * native paste API with Network and Media mocks behind it. Every boot gets a migrated++ * database of its own (./db.js: PGlite, or the PostgreSQL containers under npm run++ * test:pg, with Valkey for the limit counters).* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET@@@ -20,7 +21,6 @@ async function boot(opts = {}) {const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });process.env.NODE_ENV = 'test';process.env.BASE_URL = 'https://openvibe.community';-- process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;process.env.OV_LIVE_URL = 'https://openvibe.live';process.env.OV_MEDIA_URL = 'https://openvibe.media';process.env.OV_NETWORK_URL = netSrv.url;@@@ -31,7 +31,6 @@ async function boot(opts = {}) {process.env.COOKIE_SECURE = 'true';process.env.TRUST_PROXY = '1';process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;-- process.env.PASTES_AUTHORITY = opts.authority || 'live';// test:pg: the Valkey container too, under a prefix of its own.if (process.env.COMMUNITY_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;@@@ -42,7 +41,7 @@ async function boot(opts = {}) {const { createApp } = require('../../server/app');const appOpts = { ...(opts.appOpts || {}) };appOpts.db = appOpts.db || await require('./db').testDb();-- if (opts.authority === 'community') appOpts.pasteLimits = opts.pasteLimits;++ appOpts.pasteLimits = opts.pasteLimits;const app = await createApp(appOpts);const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing++ * sent). With a key, the key file is served at /<key>.txt as text/plain and publishing an indexable++ * page pings the engines with the page's path and the sitemap. Drafts (unlisted/private), NSFW and++ * burn-after-read pages are never in the sitemap, so they never ping.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++const KEY = 'k'.repeat(32);++const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });++++(async () => {++ delete process.env.INDEXNOW_KEY;++ const off = await boot({ authority: 'community', env: { INDEXNOW_KEY: '' }, pasteLimits: { cooldownSeconds: 0 } });++ await check('without a key IndexNow is off: no key route and nothing sent', async () => {++ assert.strictEqual(off.app.locals.indexnow.enabled, false);++ const res = await off.get(`/${KEY}.txt`);++ assert.strictEqual(res.status, 404, res.text);++ });++ await off.close();++++ const on = await boot({ authority: 'community', env: { INDEXNOW_KEY: KEY } });++ await check('with a key the key file answers text/plain with the key', async () => {++ assert.strictEqual(on.app.locals.indexnow.enabled, true);++ const res = await on.get(`/${KEY}.txt`);++ assert.strictEqual(res.status, 200, res.text);++ assert.match(res.headers.get('content-type'), /text\/plain/);++ assert.strictEqual(res.text, KEY);++ });++ await on.close();++++ // A spy in place of the module's HTTP send: records every pingSoon batch.++ const pings = [];++ const spy = {++ enabled: true,++ keyFile: (_req, _res, next) => next(),++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },++ ping: async () => ({ sent: 0, status: 0 }),++ flush: async () => ({ sent: 0, status: 0 }),++ };++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, env: { INDEXNOW_KEY: '' }, appOpts: { indexnow: spy } });++++ await check('a draft (unlisted) never pings', async () => {++ const r = await t.get('/api/pastes', json({ content: 'a draft', visibility: 'unlisted' }));++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(pings, []);++ });++++ await check('a publish pings the paste path and the sitemap', async () => {++ const r = await t.get('/api/pastes', json({ content: 'a published paste' }));++ assert.strictEqual(r.status, 201, r.text);++ const slug = r.json().slug;++ assert.ok(pings.includes(`https://openvibe.community/p/${slug}`), JSON.stringify(pings));++ assert.ok(pings.includes('https://openvibe.community/sitemap.xml'), JSON.stringify(pings));++ });++++ await check('an NSFW paste never pings', async () => {++ pings.length = 0;++ const r = await t.get('/api/pastes', json({ content: 'nsfw', is_nsfw: true }));++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(pings, []);++ });++++ // A signed-in owner (through a service token acting as them) unpublishes their own public paste.++ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const svc = (cap) => t.network.signService({ cap });++++ await check('unpublishing a public paste pings it again', async () => {++ pings.length = 0;++ const created = await t.get('/api/pastes', { ...json({ content: 'mine' }), headers: { 'content-type': 'application/json', authorization: `Bearer ${svc(['community.paste.create'])}`, 'x-ov-subject': alex.subject_id } });++ assert.strictEqual(created.status, 201, created.text);++ const slug = created.json().slug;++ pings.length = 0;++ const updated = await t.get(`/api/pastes/${slug}`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc(['community.paste.write'])}`, 'x-ov-subject': alex.subject_id }, body: JSON.stringify({ visibility: 'unlisted' }) });++ assert.strictEqual(updated.status, 200, updated.text);++ assert.ok(pings.includes(`https://openvibe.community/p/${slug}`), JSON.stringify(pings));++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -11,16 +11,13 @@ const path = require('path');(async () => {const dir = process.cwd();console.warn = () => {};-- const mockLive = require(path.join(dir, 'test', 'helpers', 'mock-live'));const mockNetwork = require(path.join(dir, 'test', 'helpers', 'mock-network'));const mockMedia = require(path.join(dir, 'test', 'helpers', 'mock-media'));-- const liveSrv = await mockLive.start();const net = await mockNetwork.start();const mediaSrv = await mockMedia.start({ publicPem: net.publicPem, issuer: net.url });Object.assign(process.env, {NODE_ENV: 'test',BASE_URL: 'https://openvibe.community',-- OV_LIVE_INTERNAL_URL: liveSrv.url,OV_LIVE_URL: 'https://openvibe.live',OV_MEDIA_URL: 'https://openvibe.media',OV_NETWORK_URL: net.url,@@@ -31,7 +28,6 @@ const path = require('path');COOKIE_SECURE: 'true',TRUST_PROXY: '1',OV_MEDIA_INTERNAL_URL: mediaSrv.url,-- PASTES_AUTHORITY: 'community',});const { createApp } = require(path.join(dir, 'server', 'app'));// Every release this test boots is on PostgreSQL (ADR-035; server/db.js initDb): give it a migrated@@@ -15,18 +15,17 @@ function raw(base, path, headers = {}) {// Let the boot-time key fetch land.await t.app.locals.auth.ensureKey();-- await check('live mode: ready, with db required and Network key and Live optional', async () => {++ await check('ready, with db required and the Network key and Media optional', async () => {const r = await t.get('/api/ready');assert.strictEqual(r.status, 200, r.text);const b = r.json();assert.strictEqual(b.ready, true);assert.strictEqual(b.status, 'ready');assert.strictEqual(b.service, 'community');-- assert.strictEqual(b.pastes_authority, 'live');-- assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'live']);++ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'media']);assert.strictEqual(b.checks.db.required, true);assert.strictEqual(b.checks.network_jwks.required, false);-- assert.strictEqual(b.checks.live.required, false);++ assert.strictEqual(b.checks.media.required, false);for (const [name, c] of Object.entries(b.checks)) {assert.strictEqual(c.status, name === 'valkey' && !process.env.VALKEY_URL ? 'skipped' : 'ok', name); // Valkey under test:pg onlyassert.strictEqual(typeof c.latency_ms, 'number');@@@ -64,28 +63,28 @@ function raw(base, path, headers = {}) {assert.ok(/release_client_updates_total\{outcome="reloaded",reason="user"\} 2/.test(m.body), m.body.split('\n').filter((l) => l.includes('release_client')).join('\n'));});-- await check('Live down: still ready (comments, forum and Pulse are served), live degraded', async () => {-- await t.live.close();-- await new Promise((r) => setTimeout(r, 20));-- // The Live probe is cached for 15 s; a fresh app instance sees the outage immediately.-- const { createCommunityReadiness } = require('../server/observability');-- const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });-- const b = await rd.run();-- assert.strictEqual(b.ready, true);-- assert.strictEqual(b.status, 'degraded');-- assert.deepStrictEqual(b.degraded, ['live']);-- });--await check('Network key not loaded: degraded, not down', async () => {const { createCommunityReadiness } = require('../server/observability');const auth = { client: { publicKey: null }, ensureKey: async () => null };-- const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, pastesAuthority: 'community', mediaInternalUrl: t.media.url } }).run();++ const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, mediaInternalUrl: t.media.url } }).run();assert.strictEqual(b.ready, true);assert.deepStrictEqual(b.degraded, ['network_jwks']);-- assert.strictEqual(b.checks.media.status, 'ok', 'community mode probes Media instead of Live');++ assert.strictEqual(b.checks.media.status, 'ok', 'Media is the optional dependency');assert.match(b.checks.network_jwks.error, /not loaded/);});++ await check('Media down: still ready (pastes, comments, forum and Pulse are served), media degraded', async () => {++ await t.media.close();++ await new Promise((r) => setTimeout(r, 20));++ // The Media probe is cached for 15 s; a fresh app instance sees the outage immediately.++ const { createCommunityReadiness } = require('../server/observability');++ const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });++ const b = await rd.run();++ assert.strictEqual(b.ready, true);++ assert.strictEqual(b.status, 'degraded');++ assert.deepStrictEqual(b.degraded, ['media']);++ });++await check('database unusable: 503 not_ready with db failed', async () => {t.app.locals.db.close();const r = await t.get('/api/ready');@@@ -1,6 +1,6 @@'use strict';/**-- * Native /api/pastes (PASTES_AUTHORITY=community): who may call what.++ * Native /api/pastes (Community is the only paste authority): who may call what.* Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /* X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via* the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.@@@ -248,7 +248,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(out.slug, p.slug);assert.strictEqual(p.type, 'screenshot');assert.strictEqual(p.owner_subject, alex.subject_id);-- assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot (C-24)');++ assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot');assert.strictEqual(up.owner, alex.subject_id, 'owned by the person');assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);assert.strictEqual(p.title, 'My desk');@@@ -1,83 +1,76 @@'use strict';--/** /api/pastes/* is a transparent proxy to Live that forwards the visitor's identity and address. */++/**++ * T10 J6 — Community is the only paste authority. The removed proposal is that nothing reads the++ * old authority switch and nothing proxies /api/pastes any more:++ * - no source file under server/ mentions the switch, the Live paste client or the proxy;++ * - the config exposes no authority/host for a Live paste API;++ * - /api/pastes is the native store API (a real list, a native 404), and the Live mock is never called;++ * - the legacy_media_id / legacy_user_id columns are gone from the paste tables.++ */const assert = require('assert');--const { Readable } = require('stream');++const fs = require('fs');++const path = require('path');const { boot, check, done } = require('./helpers/app');--(async () => {-- const t = await boot();---- await check('GET forwards path, query, the ov_token cookie as Bearer and the client address', async () => {-- const r = await t.get('/api/pastes/echo/thing?limit=5&x=y', { cookies: ['ov_token=cookie-jwt'], headers: { 'x-forwarded-for': '203.0.113.9' } });-- assert.strictEqual(r.status, 200);-- const e = r.json();-- assert.strictEqual(e.path, '/api/pastes/echo/thing');-- assert.deepStrictEqual(e.query, { limit: '5', x: 'y' });-- assert.strictEqual(e.headers.authorization, 'Bearer cookie-jwt');-- assert.strictEqual(e.headers['x-forwarded-for'], '203.0.113.9');-- assert.strictEqual(e.headers.cookie, undefined, 'cookies never leave this site');-- });++const SERVER = path.join(__dirname, '..', 'server');++const REMOVED = [++ /PASTES_AUTHORITY/,++ /OV_LIVE_INTERNAL_URL/,++ /pastesAuthority/,++ /liveInternalUrl/,++ /live-client/,++ /pastes\/proxy/,++ /createPastesProxy/,++ /LiveApiError/,++];-- await check('Authorization header wins over the cookie; anonymous calls carry no Authorization', async () => {-- const r = await t.get('/api/pastes/echo/x', { cookies: ['ov_token=cookie-jwt'], headers: { authorization: 'Bearer header-jwt' } });-- assert.strictEqual(r.json().headers.authorization, 'Bearer header-jwt');-- const anon = await t.get('/api/pastes/echo/x');-- assert.strictEqual(anon.json().headers.authorization, undefined);-- });++function sourceFiles(dir, out = []) {++ for (const name of fs.readdirSync(dir)) {++ const p = path.join(dir, name);++ if (fs.statSync(p).isDirectory()) sourceFiles(p, out);++ else if (p.endsWith('.js')) out.push(p);++ }++ return out;++}-- await check('JSON POST body reaches Live byte-for-byte with its content-type', async () => {-- const body = JSON.stringify({ title: 't', content: 'hello', language: 'text' });-- const r = await t.get('/api/pastes', { method: 'POST', body, headers: { 'content-type': 'application/json' }, cookies: ['ov_token=good-token'] });-- assert.strictEqual(r.status, 201);-- assert.strictEqual(r.json().slug, 'new-paste-99');-- const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();-- assert.strictEqual(call.body.toString(), body);-- assert.strictEqual(call.headers['content-type'], 'application/json');-- assert.strictEqual(call.headers.authorization, 'Bearer good-token');++(async () => {++ await check('no server/ source mentions the removed paste-authority switch, Live client or proxy', () => {++ const hits = [];++ for (const file of sourceFiles(SERVER)) {++ const text = fs.readFileSync(file, 'utf8');++ for (const re of REMOVED) if (re.test(text)) hits.push(`${path.relative(path.join(__dirname, '..'), file)}: ${re}`);++ }++ assert.deepStrictEqual(hits, []);++ assert.strictEqual(fs.existsSync(path.join(SERVER, 'pastes', 'api.js')), true, 'the native paste API is there');});-- await check('multipart screenshot upload streams through untouched', async () => {-- const boundary = 'xxBOUNDARYxx';-- const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3, 255, 254]);-- const body = Buffer.concat([-- Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="title"\r\n\r\nShot\r\n--${boundary}\r\nContent-Disposition: form-data; name="screenshot"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`),-- png, Buffer.from(`\r\n--${boundary}--\r\n`),-- ]);-- const r = await t.get('/api/pastes/screenshot', { method: 'POST', body: Readable.from([body]), headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': String(body.length) } });-- assert.strictEqual(r.status, 201);-- assert.strictEqual(r.json().received, body.length);-- const call = t.live.calls.filter((c) => c.path === '/api/pastes/screenshot').pop();-- assert.ok(call.body.equals(body), 'body identical');-- assert.strictEqual(call.headers['content-type'], `multipart/form-data; boundary=${boundary}`);++ await check('config exposes no paste-authority switch or Live paste host', () => {++ const config = require('../server/config');++ assert.ok(!('pastesAuthority' in config), 'pastesAuthority is gone');++ assert.ok(!('liveInternalUrl' in config), 'liveInternalUrl is gone');});-- await check('upstream status codes and error bodies pass through (404, 429)', async () => {-- const nf = await t.get('/api/pastes/does-not-exist');-- assert.strictEqual(nf.status, 404);-- assert.deepStrictEqual(nf.json(), { error: 'Paste not found' });-- const rl = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ title: 'slow down', content: 'x' }), headers: { 'content-type': 'application/json' } });-- assert.strictEqual(rl.status, 429);-- assert.strictEqual(rl.json().cooldown, 30);-- });++ const t = await boot({ pasteLimits: { cooldownSeconds: 0 } });-- await check('DELETE and the copy/like sub-routes are forwarded with the method intact', async () => {-- const del = await t.get('/api/pastes/amber-fox-42', { method: 'DELETE', cookies: ['ov_token=good-token'] });-- assert.strictEqual(del.json().method, 'DELETE');-- const copy = await t.get('/api/pastes/amber-fox-42/copy', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });-- assert.strictEqual(copy.json().path, '/api/pastes/amber-fox-42/copy');++ await check('/api/pastes is the native API (a list and a native 404), never a proxy to Live', async () => {++ const list = await t.get('/api/pastes?limit=1');++ assert.strictEqual(list.status, 200);++ const body = list.json();++ assert.ok(Array.isArray(body.pastes), 'the native list shape { pastes, total }');++ assert.strictEqual(typeof body.total, 'number');++ const missing = await t.get('/api/pastes/does-not-exist');++ assert.strictEqual(missing.status, 404);++ assert.ok(missing.json().error, 'a native error body, never a proxied response');++ assert.strictEqual(t.live.calls.length, 0, 'Live is never called');});-- await check('an unreachable Live answers 502 JSON instead of hanging', async () => {-- const { createApp } = require('../server/app');-- const http = require('http');-- const db = await require('./helpers/db').testDb();-- const app = await createApp({ liveUrl: 'http://127.0.0.1:1', db });-- const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });-- const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);-- assert.strictEqual(res.status, 502);-- assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });-- await new Promise((r) => srv.close(r));-- await db.close();++ await check('the paste tables no longer carry the legacy_media_id / legacy_user_id columns', async () => {++ const cols = await t.db.prepare(`SELECT table_name, column_name FROM information_schema.columns++ WHERE table_schema = current_schema() AND column_name IN ('legacy_media_id', 'legacy_user_id')`).all();++ assert.deepStrictEqual(cols, []);++ const kept = await t.db.prepare(`SELECT 1 AS ok FROM information_schema.columns++ WHERE table_schema = current_schema() AND table_name = 'legacy_id_map' AND column_name = 'source_id'`).get();++ assert.ok(kept, 'legacy_id_map (identity resolution) is intact');});await t.close();@@@ -2,9 +2,9 @@/*** SSRF (roadmap WS-R task 5). Community fetches no URL a user chooses: no link previews, unfurls,* avatar or image imports, user webhooks or screenshots of URLs. Every outbound call goes to a base URL-- * from the owner's environment (Network, Live, Media, Events, VIP, Chat, Search, a Discord webhook-- * variable the owner allowlisted, Discord's gateway), so there is no egress guard to test. Three things-- * keep it that way:++ * from the owner's environment (Network, Media, Events, VIP, Chat, Search, a Discord webhook++ * variable the owner allowlisted, Discord's gateway), so there is no egress guard to test. Two++ * things keep it that way:** 1. A ratchet over server/: every outbound call site (fetch, http(s).request/get, WebSocket, net/tls,* dns, child_process, and the SDK clients that make requests) is counted per file and must match@@@ -13,8 +13,6 @@* 2. Everywhere a person or a service can hand Community a URL (a Pulse item's url, links and image* syntax in posts and comments, paste content, a screenshot's page_url, a chat room reference, a relay* mapping), a canary server on loopback must never be called.-- * 3. PASTES_AUTHORITY=live proxies /api/pastes/* to Live with the visitor's path: dot segments-- * ("/api/pastes/../../internal/x", also %2e%2e) and encoded slashes must not reach any other Live route.*/const assert = require('assert');const fs = require('fs');@@@ -31,11 +29,9 @@ const INVENTORY = {'server/identity/network.js': [2, 'Network /internal/identity/resolve-batch with a service token'],'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],-- 'server/live-client.js': [1, 'OV_LIVE_INTERNAL_URL/api + fixed paths; slugs encoded (callers check SLUG_RE)'],'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],-- 'server/observability.js': [1, '/api/ready probes of the configured Live or Media health URL'],-- 'server/pastes/proxy.js': [1, 'OV_LIVE_INTERNAL_URL/api/pastes + the visitor\'s path, which must stay under /api/pastes (tested below)'],++ 'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],@@@ -66,20 +62,6 @@ function inventory() {return out;}--/** GET a raw path (no client-side normalisation of dot segments). */--function rawGet(base, rawPath) {-- const { port } = new URL(base);-- return new Promise((resolve, reject) => {-- const req = http.request({ host: '127.0.0.1', port, path: rawPath, method: 'GET' }, (res) => {-- let body = '';-- res.on('data', (c) => { body += c; });-- res.on('end', () => resolve({ status: res.statusCode, body }));-- });-- req.on('error', reject);-- req.end();-- });--}--(async () => {await check('every outbound call site in server/ is in the classified inventory (a new one must be classified here)', async () => {const found = inventory();@@@ -92,33 +74,6 @@ function rawGet(base, rawPath) {assert.deepStrictEqual(problems, []);});-- // ── live mode: the /api/pastes proxy ──-- {-- const t = await boot({ appOpts: { startRelay: false } });-- await check('PASTES_AUTHORITY=live: the /api/pastes proxy never leaves Live\'s paste API (dot segments, %2e%2e, encoded slashes)', async () => {-- const bad = ['/api/pastes/../../internal/x', '/api/pastes/../x', '/api/pastes/%2e%2e/%2e%2e/internal/x', '/api/pastes/%2E%2E/%2E%2E/metrics', '/api/pastes/.%2e/.%2e/internal/x',-- '/api/pastes/..%2f..%2finternal/x', '/api/pastes/..%2F..%2Finternal/x', '/api/pastes/..%5c..%5cinternal', '/api/pastes/amber-fox-42/../../../internal/x',-- '/api/pastes/./../../internal/x?next=1'];-- for (const p of bad) {-- const before = t.live.calls.length;-- const r = await rawGet(t.base, p);-- const reached = t.live.calls.slice(before).map((c) => c.path);-- assert.ok(reached.every((x) => x === '/api/pastes' || x.startsWith('/api/pastes/')), `${p} reached Live at ${reached.join(', ')}`);-- assert.ok(!reached.some((x) => /internal|metrics|%2f|%5c/i.test(x)), `${p} reached Live at ${reached.join(', ')}`);-- assert.strictEqual(r.status, 404, `${p}: ${r.status}`);-- }-- // Positive controls: the paste API itself still goes through.-- let before = t.live.calls.length;-- assert.strictEqual((await rawGet(t.base, '/api/pastes/amber-fox-42')).status, 200);-- assert.deepStrictEqual(t.live.calls.slice(before).map((c) => c.path), ['/api/pastes/amber-fox-42']);-- before = t.live.calls.length;-- assert.strictEqual((await rawGet(t.base, '/api/pastes?limit=1')).status, 200);-- const listed = t.live.calls.slice(before).map((c) => c.path);-- assert.ok(listed.length === 1 && /^\/api\/pastes\/?$/.test(listed[0]), listed.join(', '));-- });-- await t.close();-- }--// ── community mode: URLs people and services hand in are never fetched ──const hits = [];const canary = await new Promise((resolve) => {@@@ -5,6 +5,14 @@ const { boot, check, done } = require('./helpers/app');(async () => {const t = await boot();++ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = t.network.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const ins = t.db.prepare(`INSERT INTO pastes (slug, owner_subject, type, title, content, language, visibility, screenshot_url, views, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);++ await ins.run('amber-fox-42', alex.subject_id, 'paste', 'Hello world in JavaScript', 'const x = 1;\n', 'javascript', 'public', null, 120, '2026-09-15 10:00:00', '2026-09-15 10:00:00');++ await ins.run('fair-moon-23', sam.subject_id, 'screenshot', 'Desktop shot', 'my desktop', 'text', 'public', 'https://openvibe.media/o/med_FAIRMOON', 40, '2026-09-11 08:00:00', '2026-09-11 08:00:00');++ await ins.run('dark-owl-55', alex.subject_id, 'paste', 'secret notes', 'unlisted', 'text', 'unlisted', null, 2, '2026-09-12 08:00:00', '2026-09-12 08:00:00');++ await ins.run('grim-vale-61', alex.subject_id, 'paste', 'private thing', 'private', 'text', 'private', null, 0, '2026-09-10 08:00:00', '2026-09-10 08:00:00');await check('robots.txt allows crawling, blocks the private routes, points at the sitemap', async () => {const r = await t.get('/robots.txt');@@@ -1,7 +1,7 @@'use strict';/**-- * Server-rendered pages with PASTES_AUTHORITY=community: everything reads from Community's own-- * store (never from OpenVibe.Live), with the same visibility rules as the API.++ * Server-rendered pages read from Community's own store (never from OpenVibe.Live), with the++ * same visibility rules as the API.*/const assert = require('assert');const { boot, check, done } = require('./helpers/app');@@@ -1,11 +1,23 @@'use strict';--/** Server-rendered pages: home, browse, paste (SEO head + body), my, new, errors. */++/** Server-rendered pages: home, browse, paste (SEO head + body), my, new, errors — read from Community's own store. */const assert = require('assert');const { boot, check, done } = require('./helpers/app');(async () => {-- const t = await boot();++ const t = await boot({ pasteLimits: { cooldownSeconds: 0 } });const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);++ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: '/data/avatars/alex.png' });++ const sam = t.network.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ await t.db.prepare('INSERT INTO subject_projection (subject_id, username, display_name, avatar_url) VALUES (?, ?, ?, ?)').run(alex.subject_id, 'alex', 'Alex', '/data/avatars/alex.png');++ await t.db.prepare('INSERT INTO subject_projection (subject_id, username, display_name, avatar_url) VALUES (?, ?, ?, ?)').run(sam.subject_id, 'sam', 'Sam', null);++ const ins = t.db.prepare(`INSERT INTO pastes (slug, owner_subject, type, title, content, language, visibility, screenshot_url, views, likes, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);++ await ins.run('amber-fox-42', alex.subject_id, 'paste', 'Hello world in JavaScript', 'const greet = (name) => {\n return `Hello, ${name}!`;\n};\nconsole.log(greet("OpenVibe"));\n', 'javascript', 'public', null, 120, 3, '2026-09-15 10:00:00', '2026-09-15 10:00:00');++ await ins.run('blue-lake-17', sam.subject_id, 'paste', 'nginx snippet', 'server {\n listen 80;\n}\n', 'nginx', 'public', null, 900, 0, '2026-09-14 09:00:00', '2026-09-14 09:00:00');++ await ins.run('cold-ridge-88', alex.subject_id, 'paste', 'python helper', 'def add(a, b):\n return a + b\n', 'python', 'public', null, 15, 1, '2026-09-13 08:00:00', '2026-09-13 08:00:00');++ await ins.run('dark-owl-55', alex.subject_id, 'paste', 'secret notes', 'unlisted <script>alert(1)</script>', 'text', 'unlisted', null, 2, 0, '2026-09-12 08:00:00', '2026-09-12 08:00:00');++ await ins.run('fair-moon-23', sam.subject_id, 'screenshot', 'Desktop shot', 'my desktop', 'text', 'public', 'https://openvibe.media/o/med_FAIRMOON', 40, 0, '2026-09-11 08:00:00', '2026-09-11 08:00:00');++ await ins.run('grim-vale-61', alex.subject_id, 'paste', 'private thing', 'private', 'text', 'private', null, 0, 0, '2026-09-10 08:00:00', '2026-09-10 08:00:00');await check('home renders hero, latest + trending pastes, CTA and roadmap with full SEO head', async () => {const r = await t.get('/');@@@ -36,13 +48,26 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');assert.strictEqual(cfg.navbar.history.type, 'page');assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');++ assert.strictEqual(cfg.navbar.loginUrl, '/auth/login?next={path}', 'sign-in returns to the current page (boost moves between pages)');assert.strictEqual(cfg.footer.mount, '#ov-footer');assert.strictEqual(cfg.footer.variant, 'full');// Verbiage: never "free"/"no cost".assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');});++ await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {++ for (const url of ['/', '/pastes', '/new', '/p/amber-fox-42']) {++ const r = await t.get(url);++ assert.strictEqual(r.status, 200, url);++ assert.match(r.text, /<meta name="ov-boost" content="community@[^"]+">/, `${url}: the boost marker`);++ assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[^"]+" data-main="#main" defer><\/script>/, `${url}: the boost script, main = #main`);++ assert.ok(r.text.includes('"loginUrl":"/auth/login?next={path}"'), `${url}: the navbar's login template`);++ assert.ok(r.text.includes('<main id="main"'), `${url}: the swappable main`);++ }++ });++await check('paste page: SEO head, Article JSON-LD with author/datePublished, highlighted body, actions, related', async () => {++ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;const r = await t.get('/p/amber-fox-42');assert.strictEqual(r.status, 200);has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');@@@ -63,14 +88,12 @@ const { boot, check, done } = require('./helpers/app');has(r.text, 'data-copy-content="amber-fox-42"');has(r.text, 'twitter.com/intent/tweet');has(r.text, '<h2>More like this</h2>');-- has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative Live avatar made absolute');++ has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative avatar made absolute against Live');const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);assert.strictEqual(cfg.navbar.history.type, 'paste');assert.strictEqual(cfg.navbar.history.title, 'Hello world in JavaScript');assert.strictEqual(cfg.footer.variant, 'compact');-- // The view was counted by Live (the SSR fetch had no no_view flag).-- const call = t.live.calls.find((c) => c.path === '/api/pastes/amber-fox-42');-- assert.strictEqual(call.query.no_view, undefined);++ assert.ok((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views >= before, 'views are counted (deduped per visitor)');});await check('unlisted paste is noindex and its content is escaped', async () => {@@@ -81,41 +104,40 @@ const { boot, check, done } = require('./helpers/app');assert.ok(!r.text.includes('<script>alert(1)</script>'));});-- await check('screenshot paste uses the Media image as og:image and ImageObject JSON-LD', async () => {++ await check('screenshot paste uses the stored Media image as og:image and ImageObject JSON-LD', async () => {const r = await t.get('/p/fair-moon-23');-- has(r.text, '<meta property="og:image" content="https://openvibe.media/p/fair-moon-23/screenshot">');++ has(r.text, '<meta property="og:image" content="https://openvibe.media/o/med_FAIRMOON">');has(r.text, '<meta name="twitter:card" content="summary_large_image">');has(r.text, '"@type":"ImageObject"');-- has(r.text, '<img src="https://openvibe.media/p/fair-moon-23/screenshot"');++ has(r.text, '<img src="https://openvibe.media/o/med_FAIRMOON"');});await check('private paste 404s for anonymous visitors and renders for its owner', async () => {const anon = await t.get('/p/grim-vale-61');assert.strictEqual(anon.status, 404);has(anon.text, 'Paste not found');-- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });++ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });const owner = await t.get('/p/grim-vale-61', { cookies: [`ov_token=${token}`] });assert.strictEqual(owner.status, 200);has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');-- const call = t.live.calls.filter((c) => c.path === '/api/pastes/grim-vale-61').pop();-- assert.strictEqual(call.headers.authorization, `Bearer ${token}`, 'visitor token forwarded to Live');});-- await check('raw / screenshot bounce to Media; download serves the text as an attachment without a view', async () => {++ await check('raw serves the text from the store; screenshot redirects to the stored image; download is an attachment without a view', async () => {const raw = await t.get('/p/amber-fox-42/raw');-- assert.strictEqual(raw.status, 302);-- assert.strictEqual(raw.headers.get('location'), 'https://openvibe.media/p/amber-fox-42/raw');++ assert.strictEqual(raw.status, 200);++ assert.ok(raw.text.startsWith('const greet'));const shot = await t.get('/p/fair-moon-23/screenshot');-- assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/p/fair-moon-23/screenshot');++ assert.strictEqual(shot.status, 302);++ assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/o/med_FAIRMOON');++ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;const dl = await t.get('/p/amber-fox-42/download');assert.strictEqual(dl.status, 200);assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="amber-fox-42.js"');assert.ok(dl.text.startsWith('const greet'));-- const call = t.live.calls.filter((c) => c.path === '/api/pastes/amber-fox-42').pop();-- assert.strictEqual(call.query.no_view, '1');++ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views, before, 'download does not count a view');});-- await check('browse: search passes through to Live, views sort and language filter work, search pages are noindex', async () => {++ await check('browse: search, views sort and language filter over the store, search pages are noindex', async () => {const all = await t.get('/pastes');assert.strictEqual(all.status, 200);has(all.text, '<link rel="canonical" href="https://openvibe.community/pastes">');@@@ -124,8 +146,6 @@ const { boot, check, done } = require('./helpers/app');has(q.text, '<meta name="robots" content="noindex,follow">');has(q.text, 'href="/p/blue-lake-17"');assert.ok(!q.text.includes('href="/p/amber-fox-42"'));-- const call = t.live.calls.filter((c) => c.path === '/api/pastes' && c.query.search).pop();-- assert.strictEqual(call.query.search, 'nginx');const views = await t.get('/pastes?sort=views');const res = views.text.slice(views.text.indexOf('id="results"'));assert.ok(res.indexOf('blue-lake-17') < res.indexOf('amber-fox-42') && res.indexOf('amber-fox-42') < res.indexOf('cold-ridge-88'));@@@ -141,38 +161,35 @@ const { boot, check, done } = require('./helpers/app');const anon = await t.get('/my');assert.strictEqual(anon.status, 302);assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fmy');-- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });++ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });assert.strictEqual(mine.status, 200);has(mine.text, '<meta name="robots" content="noindex,nofollow">');for (const slug of ['amber-fox-42', 'cold-ridge-88', 'dark-owl-55', 'grim-vale-61']) has(mine.text, `href="/p/${slug}"`);assert.ok(!mine.text.includes('blue-lake-17'));-- const call = t.live.calls.filter((c) => c.path === '/api/pastes/by-user/alex').pop();-- assert.strictEqual(call.headers.authorization, `Bearer ${token}`);});-- await check('/new renders the form (private option only when signed in) and the no-JS post creates through Live', async () => {++ await check('/new renders the form (private option only when signed in) and the no-JS post creates in the store', async () => {const anon = await t.get('/new');assert.strictEqual(anon.status, 200);has(anon.text, 'name="content"');assert.ok(!anon.text.includes('value="private"'));-- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });++ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });has(signed.text, 'value="private"');has(signed.text, 'Fork of Hello world in JavaScript');const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });assert.strictEqual(post.status, 303);-- assert.strictEqual(post.headers.get('location'), '/p/new-paste-99');-- const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();-- assert.strictEqual(call.headers.authorization, `Bearer ${token}`);-- assert.deepStrictEqual(JSON.parse(call.body.toString()).content, 'print(1)');++ const slug = post.headers.get('location').replace('/p/', '');++ assert.ok(/^[A-Za-z0-9_-]+$/.test(slug), `a generated slug, got ${slug}`);++ const row = await t.db.prepare('SELECT owner_subject, content FROM pastes WHERE slug = ?').get(slug);++ assert.deepStrictEqual(row, { owner_subject: alex.subject_id, content: 'print(1)' });const empty = await t.get('/new', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });assert.strictEqual(empty.status, 400);has(empty.text, 'content is empty');-- const limited = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'slow down', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });-- assert.strictEqual(limited.status, 429);-- has(limited.text, 'Please wait 30s');++ const anonPost = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'from anon', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });++ assert.strictEqual(anonPost.status, 303, 'anonymous posts create in the store too');});await check('404 page and API 404 JSON; health', async () => {
@@ -26,14 +26,13 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4200/auth/callback
@@ -26,14 +26,13 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4200/auth/callback
# Cookies — production default is Secure; set false for plain-http local dev
# Cookies — production default is Secure; set false for plain-http local dev
#COOKIE_SECURE=false
#COOKIE_SECURE=false
-# OpenVibe.Live — every /api/pastes/* call is proxied here (internal URL);
-# Live maps the visitor's Network JWT onto its own account ids before it talks to Media.
-OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
+# OpenVibe.Live — author links, avatars and legal pages (no paste traffic).
+
OV_LIVE_URL=https://openvibe.live
OV_LIVE_URL=https://openvibe.live
-# OpenVibe.Media — public host for raw paste text and screenshots
+# OpenVibe.Media — public host for screenshot bytes
OV_MEDIA_URL=https://openvibe.media
OV_MEDIA_URL=https://openvibe.media
-# Media's internal address: new screenshot uploads (community authority) go to its file store
+# Media's internal address: new screenshot uploads go to its file store
# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
@@ -52,14 +51,12 @@ OV_CHAT_URL=https://openvibe.chat
@@ -52,14 +51,12 @@ OV_CHAT_URL=https://openvibe.chat
# VIP_CACHE_DENY_TTL_MS=10000
# VIP_CACHE_DENY_TTL_MS=10000
# VIP_CACHE_UNAVAILABLE_TTL_MS=2000
# VIP_CACHE_UNAVAILABLE_TTL_MS=2000
-# Who owns pastes:
-# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live
-# community this site's own database is the authority (native API, pages, raw text)
-# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
-PASTES_AUTHORITY=live
+# Pastes are Community's own (native API, pages, raw text); the OAuth client above also needs
+# identity.subject.resolve (author names).
+
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh community: the runtime role through
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh community: the runtime role through
-# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). Comments, the
-# forum, Pulse and the relay's bookkeeping live there in every mode; pastes too with 'community'. Without
+# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). Pastes, comments,
+# the forum, Pulse and the relay's bookkeeping live there. Without
# DATABASE_URL, development uses an embedded PGlite database in data/pglite.
# DATABASE_URL, development uses an embedded PGlite database in data/pglite.
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
@@ -15,7 +15,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -15,7 +15,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
## Owns
## Owns
-- pastes (authority since 2026-09-22, `PASTES_AUTHORITY=community`), their versions, likes and
+- pastes (the only authority since 2026-09-22), their versions, likes and
comments
comments
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
categories), Pulse (the network's public activity) and the Discord relay
categories), Pulse (the network's public activity) and the Discord relay
@@ -34,43 +34,38 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -34,43 +34,38 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve`, the `community.profile` module)
- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve`, the `community.profile` module)
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
-- OpenVibe.Live only when `PASTES_AUTHORITY=live` (the rollback mode), and for old VOD/clip comment imports
+- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.0 (events outbox, per-actor limits),
- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.0 (events outbox, per-actor limits),
- `openvibe-shared` v1.28.0, pinned by release tarball
+ `openvibe-shared` v2.2.0, pinned by release tarball
## How it fits the network
## How it fits the network
-Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns them:
-
-- **`live`** (the code default, now the rollback mode): the table below.
-- **`community`** (production since 2026-09-22, about 23:35 UTC): Community's own database is
- the authority — see [Community as the paste authority](#community-as-the-paste-authority).
- The import brought 889 + 2 pastes, 60 comments and 48 likes; 5 rows (1 paste, 4 comments,
- `ambiguous_owner`) wait in `import_hold`. Live and Tools forward paste writes here, and Media
- answers old paste URLs with a 301 to this site. Comments, the forum and Pulse are deployed but
- hold almost nothing yet (10 wiki comment threads, 0 forum threads), and the Discord relay is
- off. A restore drill passed on 2026-09-23.
+Pastes moved to Community in roadmap Wave 5; since 2026-09-22 Community's own database is their only
+authority (there is no switch any more — `live-client.js` and the `/api/pastes` proxy were deleted) —
+see [Community as the paste authority](#community-as-the-paste-authority). The import brought
+889 + 2 pastes, 60 comments and 48 likes; 5 rows (1 paste, 4 comments, `ambiguous_owner`) wait in
+`import_hold`. Live and Tools forward paste writes here, and Media answers old paste URLs with a 301 to
+this site. Comments, the forum and Pulse are deployed but hold almost nothing yet (10 wiki comment
+threads, 0 forum threads), and the Discord relay is off. A restore drill passed on 2026-09-23.
| Concern | Where it lives | How Community reaches it |
| Concern | Where it lives | How Community reaches it |
| --- | --- | --- |
| --- | --- | --- |
-| Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |
-| Paste API + account mapping | **OpenVibe.Live** (`/api/pastes/*`) | `OV_LIVE_INTERNAL_URL` (server-side) |
+| Paste storage + API | **Community's PostgreSQL** (`pastes`, `server/pastes/*`) | own database |
+| Screenshot / attachment bytes | **OpenVibe.Media** (`/api/v1/community/files`, objects v2) | service token (`media.object.upload`) |
| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |
| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |
-| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |
+| Raw text, screenshots | Community's own routes; bytes on Media's public host | served locally, or a 302 to the stored image |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
| 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`) |
| 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`) |
| 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) |
| 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) |
-Why go through Live and not straight to Media: the visitor's JWT names the account by its
-**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different
-numbers for the same person. Live resolves the visitor against its own accounts before it
-writes, which only Live can do, so Community proxies `/api/pastes/*` to Live and forwards the
-visitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,
-anonymous-post limits, ownership and author names are therefore identical to Live's.
+Community resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`),
+which is exactly what Community stores, so there is no second identity to map and no proxy. Creation,
+listing, anonymous-post limits, ownership and author names all come from Community's own rules
+(`server/pastes/service.js`).
### Community as the paste authority
### Community as the paste authority
-With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
+Community is the only paste authority: `/api/pastes/*` is the native API
(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
response shapes the browser clients already use, and every page, `/p/:slug/raw`,
response shapes the browser clients already use, and every page, `/p/:slug/raw`,
`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
@@ -140,7 +135,7 @@ API and machine endpoints:
@@ -140,7 +135,7 @@ API and machine endpoints:
| Route | What |
| Route | What |
| --- | --- |
| --- | --- |
-| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) |
+| `ANY /api/pastes/*` | The native API — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete and `/:slug/versions` |
| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
| `/api/v1/comments/*` | Typed comment threads — see [Comments API](#comments-api) |
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
@@ -159,8 +154,7 @@ API and machine endpoints:
@@ -159,8 +154,7 @@ API and machine endpoints:
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
-`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`
-(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready
+`media` (screenshot and file uploads) is optional: a failure keeps the site ready
(comments, forum, Pulse and public reads still work) and is listed in `degraded` with
(comments, forum, Pulse and public reads still work) and is listed in `degraded` with
`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.
`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.
Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP
Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP
@@ -248,8 +242,8 @@ item's comments.
@@ -248,8 +242,8 @@ item's comments.
**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;
**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;
held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,
held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,
-`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is C-24's
-`scripts/migrate-screenshot-refs.js` (applied 2026-09-25: no paste keeps a legacy media reference).
+`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is the
+screenshot-reference migration (`scripts/migrate-screenshot-refs.js`, applied 2026-09-25: no paste keeps a legacy media reference).
Embedding it — server-side, from another product's backend (the usual way; the person is the
Embedding it — server-side, from another product's backend (the usual way; the person is the
one your own session says it is):
one your own session says it is):
@@ -501,8 +495,8 @@ blocks made before it existed need a replay from OpenVibe.Events.
@@ -501,8 +495,8 @@ blocks made before it existed need a replay from OpenVibe.Events.
## Capabilities
## Capabilities
-Community checks service tokens against these capabilities (manifests in
-`docs/capabilities-proposal/`, same shape as OpenVibe.Contracts' `manifests/capabilities`):
+Community checks service tokens against these capabilities (all released in openvibe-contracts,
+same shape as its `manifests/capabilities`):
| Id | Status | Used for |
| Id | Status | Used for |
| --- | --- | --- |
| --- | --- | --- |
@@ -580,11 +574,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -580,11 +574,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_OAUTH_CLIENT_ID` | `community` | Registered on the Network |
| `OV_OAUTH_CLIENT_ID` | `community` | Registered on the Network |
| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |
| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |
| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.community/auth/callback` | Must match the registration |
| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.community/auth/callback` | Must match the registration |
-| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` |
| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
-| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
-| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
+| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots |
| `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`) |
| `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`) |
| `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes |
| `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes |
| `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 |
| `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 |
@@ -604,6 +596,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -604,6 +596,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
| `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) |
| `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) |
+| `INDEXNOW_KEY` | unset (IndexNow off) | IndexNow key: served at `/<key>.txt`, and a public, indexable page appearing, changing or going away pings the engines |
## Run
## Run
@@ -697,12 +690,12 @@ server/
@@ -697,12 +690,12 @@ server/
identity/network.js Network resolve-batch client + subject_projection cache
identity/network.js Network resolve-batch client + subject_projection cache
media/files.js new screenshot bytes → Media's file store (service token)
media/files.js new screenshot bytes → Media's file store (service token)
media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding
media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding
- live-client.js server-side reads of Live's /api/pastes for rendered pages ('live')
- pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address) ('live')
- pastes/api.js native /api/pastes/* ('community')
+ live-client.js (removed: Community is the only paste authority)
+ pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)
+ pastes/api.js native /api/pastes/*
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
pastes/store.js pure SQL over pastes / versions / likes / comments / projections
pastes/store.js pure SQL over pastes / versions / likes / comments / projections
- pastes/source.js where pages read pastes from (Live or the store)
+ pastes/source.js where pages read pastes from (the store)
pastes/catalog.js recent public pastes: trending, related, language filter
pastes/catalog.js recent public pastes: trending, related, language filter
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
@@ -712,7 +705,7 @@ server/
@@ -712,7 +705,7 @@ server/
relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (creates
relay/ Discord relay: discord.js (queue, sender, backoff, message map), events-worker.js (creates
from Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)
from Events), discord-gateway.js + inbound.js (replies from Discord), api (/api/v1/relay)
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
- identity/capabilities.js capability checks incl. the proposed ids; discussion staff/moderators
+ identity/capabilities.js capability checks; discussion staff/moderators
identity/authors.js author display from subject_projection; the AI label
identity/authors.js author display from subject_projection; the AI label
votes.js race-safe up/down votes (comments, threads)
votes.js race-safe up/down votes (comments, threads)
limits.js per-person write limits
limits.js per-person write limits
@@ -725,10 +718,9 @@ server/
@@ -725,10 +718,9 @@ server/
render/highlight.js highlight.js wrapper, language list, download extensions
render/highlight.js highlight.js wrapper, language list, download extensions
seo.js robots, sitemap, RSS, JSON-LD builders
seo.js robots, sitemap, RSS, JSON-LD builders
public/ css/community.css, js/community.js, favicon.svg, og-default.png
public/ css/community.css, js/community.js, favicon.svg, og-default.png
-(openvibe-shared is the pinned OpenVibe.Shared v1.25.0 release, installed by npm)
+(openvibe-shared is the pinned OpenVibe.Shared v2.2.0 release, installed by npm)
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
-test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
-docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
+test/ run.js + *.test.js (mock Network and Media with a real RS256 key)
docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits
docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits
```
```
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"community.pulse.read"
"community.pulse.read"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
- "pastesAuthority": "community in production since 2026-09-22 (~23:35 UTC); live is the rollback mode",
+ "pastes": "Community is the only paste authority (since 2026-09-22, ~23:35 UTC)",
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.community: deployed with `sudo ovhost deploy community`, /opt/openvibe.community, unit openvibe-community on 127.0.0.1:4200 behind the openvibe.community nginx vhost, principal community. The paste import brought 889 + 2 pastes, 60 comments and 48 likes (5 rows held). The Discord relay is off. Restore drill passed 2026-09-23.",
"deployment": "Public at https://openvibe.community: deployed with `sudo ovhost deploy community`, /opt/openvibe.community, unit openvibe-community on 127.0.0.1:4200 behind the openvibe.community nginx vhost, principal community. The paste import brought 889 + 2 pastes, 60 comments and 48 likes (5 rows held). The Discord relay is off. Restore drill passed 2026-09-23.",
"features": [
"features": [
@@ -1,27 +0,0 @@
@@ -1,27 +0,0 @@
-{
- "id": "community.comment.moderate",
- "version": "1.0",
- "owner": "community",
- "status": "active",
- "visibility": "internal",
- "description": "Moderation of discussions: set a comment thread's visibility (public, hidden, locked — e.g. when the owner service hides the entity), delete any comment or forum post, pin and lock forum threads, read hidden threads, administer the Discord relay. Acting as itself (no X-OV-Subject) the service is the moderator; acting for a person it must vouch with X-OV-Staff: 1 that the person is staff.",
- "permissions": [
- "community:moderate"
- ],
- "resourceConstraints": [
- "none"
- ],
- "quotaClass": "community-moderate",
- "events": [
- "community.comment.moderated",
- "community.thread.moderated"
- ],
- "implementedBy": [
- "PUT /api/v1/comments/threads/:id/visibility",
- "DELETE /api/v1/comments/:commentId",
- "PUT /api/v1/spaces/:space/threads/:slug/state",
- "DELETE /api/v1/spaces/:space/threads/:slug",
- "PUT|DELETE /api/v1/posts/:id",
- "GET|POST|PUT /api/v1/relay/*"
- ]
-}
@@ -1,29 +0,0 @@
@@ -1,29 +0,0 @@
-{
- "id": "community.comment.write",
- "version": "1.0",
- "owner": "community",
- "status": "active",
- "visibility": "first-party",
- "description": "Typed comment threads as a service: resolve the thread of any EntityRef (browsers are limited to an allowlist of types), and comment, vote and delete one's own comments as the acting subject (X-OV-Subject), or comment as origin \"ai\" (never attributed to a person, roadmap 33). Ownership and limits are checked by Community.",
- "inputSchema": "common.entity-ref@1",
- "outputSchema": "common.entity-ref@1",
- "permissions": [
- "community:write"
- ],
- "resourceConstraints": [
- "owner"
- ],
- "quotaClass": "community-write",
- "events": [
- "community.comment.created",
- "community.comment.deleted",
- "community.comment.voted"
- ],
- "implementedBy": [
- "POST /api/v1/comments/threads/resolve",
- "GET /api/v1/comments/threads/:id",
- "POST /api/v1/comments/threads/:id/comments",
- "DELETE /api/v1/comments/:commentId",
- "POST /api/v1/comments/:commentId/votes"
- ]
-}
@@ -1,27 +0,0 @@
@@ -1,27 +0,0 @@
-{
- "id": "community.post.create",
- "version": "1.1",
- "owner": "community",
- "status": "active",
- "visibility": "public",
- "description": "Forum writes as a service: create threads and replies in a space, vote on threads, and edit or delete one's own posts, as the acting subject (X-OV-Subject) or as origin \"ai\" (no author, labelled as AI). Space visibility (public, members, staff), locks and limits are checked by Community.",
- "outputSchema": "common.entity-ref@1",
- "permissions": [
- "community:write"
- ],
- "resourceConstraints": [
- "space_member"
- ],
- "quotaClass": "community-write",
- "events": [
- "community.thread.created",
- "community.post.created"
- ],
- "implementedBy": [
- "POST /api/v1/spaces/:space/threads",
- "POST /api/v1/spaces/:space/threads/:slug/posts",
- "POST /api/v1/spaces/:space/threads/:slug/votes",
- "PUT|DELETE /api/v1/posts/:id",
- "DELETE /api/v1/spaces/:space/threads/:slug"
- ]
-}
@@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
-{
- "id": "community.pulse.write",
- "version": "1.0",
- "owner": "community",
- "status": "active",
- "visibility": "first-party",
- "description": "Publish public activity of the calling service's own entities to Pulse (EntityRef + title + url + origin user|ai|system + occurred_at), or retract it. Only visibility public is accepted; the ref's service must be the caller's. Provenance is kept: the first record's origin, actor (X-OV-Subject, never for ai/system) and time stand.",
- "inputSchema": "common.entity-ref@1",
- "permissions": [
- "community:write"
- ],
- "resourceConstraints": [
- "owner"
- ],
- "quotaClass": "community-write",
- "events": [
- "community.pulse.item_recorded"
- ],
- "implementedBy": [
- "POST /api/v1/pulse/items",
- "DELETE /api/v1/pulse/items/:service/:type/:id"
- ]
-}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: migrate
+-- T10 J6: Community is the only paste authority. The Live proxy that filled these columns is gone and
+-- nothing reads them any more: pastes.legacy_media_id (a Media paste id), pastes.legacy_user_id (Live's
+-- numeric account id) and paste_comments.legacy_media_id. The drop is a plain migrate (not a contract):
+-- no code path reads them, and there is no N-1 release that needs them back, so there is nothing for the
+-- SDK's 7-day contract window to protect. legacy_id_map is untouched — it is live and still backs
+-- identity subject resolution.
+ALTER TABLE pastes DROP COLUMN IF EXISTS legacy_media_id;
+ALTER TABLE pastes DROP COLUMN IF EXISTS legacy_user_id;
+ALTER TABLE paste_comments DROP COLUMN IF EXISTS legacy_media_id;
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1236,9 +1236,9 @@
@@ -1236,9 +1236,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.29.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
- "integrity": "sha512-w5daRSzujokYT1/Q3fu0KdcSZUBEv1pm66H8vIAI2SY0xtOaLqgfkXJpfIz1NgvJuyK8dhXBbLjM8Ow9RZ5x+A==",
+ "version": "2.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
+ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-community",
"name": "openvibe-community",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"private": true,
- "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
+ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -59,102 +59,119 @@
@@ -59,102 +59,119 @@
.catch(function (err) { t.disabled = false; alert(err.message); });
.catch(function (err) { t.disabled = false; alert(err.message); });
}
}
});
});
- var shot = $('.shot.nsfw');
- if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } });
- var codeWrap = $('[data-code]');
- if (codeWrap) {
- var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';
- wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); });
- var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
- }
- // ── Browse: filters + pagination without a full reload ──────
- var browseForm = $('form[data-browse]');
- if (browseForm && window.fetch && history.pushState) {
- var results = $('[data-results]');
- function load(url, push) {
- results.classList.add('loading');
- fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
- var doc = new DOMParser().parseFromString(html, 'text/html');
- var next = doc.querySelector('[data-results]');
- if (next) results.innerHTML = next.innerHTML;
- var head = doc.querySelector('.page-head'); var mine = $('.page-head'); if (head && mine) mine.innerHTML = head.innerHTML;
- document.title = doc.title;
- if (push) history.pushState({ browse: url }, '', url);
- results.classList.remove('loading');
- }).catch(function () { location.href = url; });
+ // ── Per-page wiring ─────────────────────────────────────────
+ // Runs once on load and again after openvibe-shared/boost swaps <main> (it fires ov:boost:load on
+ // document), so the widgets of the page that just arrived are wired too. Each run replaces the
+ // previous run's listeners (AbortController) and closes the previous event stream.
+ var wired = null;
+ var pulseEs = null;
+ function wirePage() {
+ if (wired) wired.abort();
+ wired = new AbortController();
+ var signal = wired.signal;
+
+ var shot = $('.shot.nsfw');
+ if (shot) shot.addEventListener('click', function (e) { if (!shot.classList.contains('revealed')) { e.preventDefault(); shot.classList.add('revealed'); } }, { signal });
+ var codeWrap = $('[data-code]');
+ if (codeWrap) {
+ var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-text-width" aria-hidden="true"></i> Wrap';
+ wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });
+ var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
}
}
- browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); });
- $$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }); });
- results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); });
- window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); });
- }
- // ── New paste: tabs + API submit (text → JSON, screenshot → multipart) ──
- var form = $('form[data-new-paste]');
- if (form && window.fetch && window.FormData) {
- var status = $('[data-status]', form);
- var tabs = $$('.tab', form); var mode = 'text';
- tabs.forEach(function (t) { t.hidden = false; });
- function setMode(m) {
- mode = m;
- tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });
- $$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });
- var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;
- if (content) content.required = m === 'text';
- if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }
- if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
+ // ── Browse: filters + pagination without a full reload ──────
+ var browseForm = $('form[data-browse]');
+ if (browseForm && window.fetch && history.pushState) {
+ var results = $('[data-results]');
+ function load(url, push) {
+ results.classList.add('loading');
+ fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
+ var doc = new DOMParser().parseFromString(html, 'text/html');
+ var next = doc.querySelector('[data-results]');
+ if (next) results.innerHTML = next.innerHTML;
+ var head = doc.querySelector('.page-head'); var mine = $('.page-head'); if (head && mine) mine.innerHTML = head.innerHTML;
+ document.title = doc.title;
+ if (push) history.pushState({ browse: url }, '', url);
+ results.classList.remove('loading');
+ }).catch(function () { location.href = url; });
+ }
+ browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); }, { signal });
+ $$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }, { signal }); });
+ results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); }, { signal });
+ window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); }, { signal });
}
}
- tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }); });
- setMode('text');
- // Dropping or pasting an image anywhere on the form switches to the screenshot mode.
- function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
- form.addEventListener('dragover', function (e) { e.preventDefault(); });
- form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } });
- document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } });
- form.addEventListener('submit', function (e) {
- e.preventDefault();
- var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';
- var el = form.elements; var p;
- var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
- if (mode === 'image') {
- var fd = new FormData();
- fd.append('screenshot', el.screenshot.files[0]);
- fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
- if (common.is_nsfw) fd.append('is_nsfw', '1');
- p = api('/api/pastes/screenshot', { method: 'POST', body: fd });
- } else {
- p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
+ // ── New paste: tabs + API submit (text → JSON, screenshot → multipart) ──
+ var form = $('form[data-new-paste]');
+ if (form && window.fetch && window.FormData) {
+ var status = $('[data-status]', form);
+ var tabs = $$('.tab', form); var mode = 'text';
+ tabs.forEach(function (t) { t.hidden = false; });
+ function setMode(m) {
+ mode = m;
+ tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });
+ $$('[data-pane]', form).forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== m; });
+ var content = form.elements.content, file = form.elements.screenshot, lang = form.elements.language;
+ if (content) content.required = m === 'text';
+ if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }
+ if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
}
}
- p.then(function (out) {
- var slug = out && (out.slug || (out.paste && out.paste.slug));
- if (!slug) throw new Error('No paste came back');
- status.textContent = 'Done — opening your paste…';
- location.href = '/p/' + encodeURIComponent(slug);
- }).catch(function (err) {
- btn.disabled = false; status.className = 'form-status error';
- status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
- });
- });
- }
+ tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }, { signal }); });
+ setMode('text');
+ // Dropping or pasting an image anywhere on the form switches to the screenshot mode.
+ function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
+ form.addEventListener('dragover', function (e) { e.preventDefault(); }, { signal });
+ form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } }, { signal });
+ document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } }, { signal });
+
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';
+ var el = form.elements; var p;
+ var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
+ if (mode === 'image') {
+ var fd = new FormData();
+ fd.append('screenshot', el.screenshot.files[0]);
+ fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
+ if (common.is_nsfw) fd.append('is_nsfw', '1');
+ p = api('/api/pastes/screenshot', { method: 'POST', body: fd });
+ } else {
+ p = api('/api/pastes', { method: 'POST', json: Object.assign({ content: el.content.value, language: el.language.value }, common) });
+ }
+ p.then(function (out) {
+ var slug = out && (out.slug || (out.paste && out.paste.slug));
+ if (!slug) throw new Error('No paste came back');
+ status.textContent = 'Done — opening your paste…';
+ location.href = '/p/' + encodeURIComponent(slug);
+ }).catch(function (err) {
+ btn.disabled = false; status.className = 'form-status error';
+ status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
+ });
+ }, { signal });
+ }
- // Pulse follows OpenVibe.Events realtime (roadmap WS-F task 1): on the first page, public activity
- // from here and the other sites arrives over events.openvibe.network/realtime/stream, and a bar
- // offers to show it (the page itself stays server-rendered, so a reload is the whole update).
- var pulse = document.querySelector('[data-pulse-live]');
- if (pulse && typeof EventSource !== 'undefined') {
- var topics = ['community.paste.created', 'community.thread.created', 'community.post.created', 'live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published'];
- var bar = pulse.querySelector('.pulse-new'), show = pulse.querySelector('[data-pulse-show]'), fresh = 0;
- var es = new EventSource('https://events.openvibe.network/realtime/stream?topics=' + encodeURIComponent(topics.join(',')));
- es.onmessage = function (m) {
- var d; try { d = JSON.parse(m.data); } catch (e) { return; }
- if (!d || !d.event || topics.indexOf(d.event.event_type) < 0) return;
- fresh++;
- show.textContent = fresh === 1 ? 'Show 1 new item' : 'Show ' + fresh + ' new items';
- bar.hidden = false;
- };
- show.addEventListener('click', function () { es.close(); location.reload(); });
- window.addEventListener('pagehide', function () { es.close(); });
+ // Pulse follows OpenVibe.Events realtime (roadmap WS-F task 1): on the first page, public activity
+ // from here and the other sites arrives over events.openvibe.network/realtime/stream, and a bar
+ // offers to show it (the page itself stays server-rendered, so a reload is the whole update).
+ if (pulseEs) { pulseEs.close(); pulseEs = null; }
+ var pulse = document.querySelector('[data-pulse-live]');
+ if (pulse && typeof EventSource !== 'undefined') {
+ var topics = ['community.paste.created', 'community.thread.created', 'community.post.created', 'live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published'];
+ var bar = pulse.querySelector('.pulse-new'), show = pulse.querySelector('[data-pulse-show]'), fresh = 0;
+ var es = new EventSource('https://events.openvibe.network/realtime/stream?topics=' + encodeURIComponent(topics.join(',')));
+ pulseEs = es;
+ es.onmessage = function (m) {
+ var d; try { d = JSON.parse(m.data); } catch (e) { return; }
+ if (!d || !d.event || topics.indexOf(d.event.event_type) < 0) return;
+ fresh++;
+ show.textContent = fresh === 1 ? 'Show 1 new item' : 'Show ' + fresh + ' new items';
+ bar.hidden = false;
+ };
+ show.addEventListener('click', function () { es.close(); location.reload(); }, { signal });
+ window.addEventListener('pagehide', function () { es.close(); }, { signal });
+ }
}
}
+ wirePage();
+ document.addEventListener('ov:boost:load', wirePage);
})();
})();
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* Express app factory (server/index.js listens; tests build their own instance).
* Express app factory (server/index.js listens; tests build their own instance).
*
*
* Pages (server-rendered) API / machine
* Pages (server-rendered) API / machine
- * GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below
+ * GET / home ALL /api/pastes/* the native paste API (pastes/api.js)
* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)
* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
@@ -18,13 +18,9 @@
@@ -18,13 +18,9 @@
* GET /pulse the network's public activity
* GET /pulse the network's public activity
* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
*
*
- * Comments, the forum, Pulse and the relay live in Community's database in every mode.
- *
- * PASTES_AUTHORITY (config.pastesAuthority):
- * 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
- * Live's API, raw text and screenshots bounce to OpenVibe.Media.
- * 'community' this site's own database is the authority: the native API
- * (pastes/api.js), pages, raw text and screenshots all come from the store.
+ * Comments, the forum, Pulse and the relay live in Community's database, alongside the pastes
+ * Community itself is the only authority for: the native API (pastes/api.js), pages, raw text
+ * and screenshots all come from the store.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -33,14 +29,12 @@ const cookieParser = require('cookie-parser');
@@ -33,14 +29,12 @@ const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
-const live = require('./live-client');
const catalog = require('./pastes/catalog');
const catalog = require('./pastes/catalog');
const source = require('./pastes/source');
const source = require('./pastes/source');
const seo = require('./seo');
const seo = require('./seo');
const pages = require('./render/pages');
const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
-const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
-const { createPastesProxy } = require('./pastes/proxy');
+const { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { getDb } = require('./db');
const { getDb } = require('./db');
const { createNetworkIdentity } = require('./identity/network');
const { createNetworkIdentity } = require('./identity/network');
const { createViewerResolver } = require('./identity/viewer');
const { createViewerResolver } = require('./identity/viewer');
@@ -62,6 +56,7 @@ const { createRelayApi } = require('./relay/api');
@@ -62,6 +56,7 @@ const { createRelayApi } = require('./relay/api');
const { createActorLimits } = require('./actor-limits');
const { createActorLimits } = require('./actor-limits');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
+const { createIndexNow } = require('openvibe-shared/indexnow');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
@@ -73,6 +68,7 @@ async function createApp(opts = {}) {
@@ -73,6 +68,7 @@ async function createApp(opts = {}) {
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
// What this server runs (ADR-016); the shared navbar's release-watch polls it.
// What this server runs (ADR-016); the shared navbar's release-watch polls it.
const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// direct loopback callers only (Track O). Request metrics only: no content counts.
// direct loopback callers only (Track O). Request metrics only: no content counts.
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
@@ -154,19 +150,23 @@ async function createApp(opts = {}) {
@@ -154,19 +150,23 @@ async function createApp(opts = {}) {
const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });
const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });
// A space's chat room on OpenVibe.Chat (chat-rooms.js): attached with the person's own token.
// A space's chat room on OpenVibe.Chat (chat-rooms.js): attached with the person's own token.
const chatRooms = opts.chatRooms || require('./chat-rooms').createChatRooms({ config });
const chatRooms = opts.chatRooms || require('./chat-rooms').createChatRooms({ config });
- const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits });
- const community = config.pastesAuthority === 'community';
- const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
+ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY; unset → off
+ // (nothing mounted, nothing sent). The key file is served at /<key>.txt and the paste and forum
+ // services ping the engines when a public, indexable page appears, changes or goes away.
+ const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });
+ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
+ const comments = createCommentService({ db, network, limits: opts.commentLimits });
seo.useForum(forum);
seo.useForum(forum);
- Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments });
+ Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, indexnow });
if (opts.startRelay !== false) relay.start();
if (opts.startRelay !== false) relay.start();
- // ── Paste authority ──────────────────────────────────────
- if (community) {
+ // ── Pastes: Community's own store is the only authority ──
+ {
const { createMediaFiles } = require('./media/files');
const { createMediaFiles } = require('./media/files');
const { createPasteService } = require('./pastes/service');
const { createPasteService } = require('./pastes/service');
- // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when signed in;
- // C-24), or the v1 community file store (legacy:community:file:<key>) without Community's service principal.
+ // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when
+ // signed in), or the v1 community file store (legacy:community:file:<key>) without
+ // Community's service principal.
const files = createMediaFiles({ config });
const files = createMediaFiles({ config });
const media = opts.media || {
const media = opts.media || {
tokens: files.tokens,
tokens: files.tokens,
@@ -176,11 +176,9 @@ async function createApp(opts = {}) {
@@ -176,11 +176,9 @@ async function createApp(opts = {}) {
return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
},
},
};
};
- const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse });
+ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse, indexnow });
source.use(service);
source.use(service);
app.locals.pastes = service;
app.locals.pastes = service;
- } else {
- source.use(null);
}
}
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
const anonWriteLimiter = rateLimit({
@@ -201,10 +199,9 @@ async function createApp(opts = {}) {
@@ -201,10 +199,9 @@ async function createApp(opts = {}) {
app.locals.valkey = valkey;
app.locals.valkey = valkey;
const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });
const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });
- // ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
+ // ── /api/pastes — the native paste API ───────────────────
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
- if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));
- else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
+ app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.
// Opening a thread writes a row: browsers get 300 resolves per 10 minutes per address.
@@ -231,8 +228,8 @@ async function createApp(opts = {}) {
@@ -231,8 +228,8 @@ async function createApp(opts = {}) {
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// sendBeacon, no auth) into /metrics as release_client_updates_total.
// sendBeacon, no auth) into /metrics as release_client_updates_total.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- // Readiness reports what is actually served: 503 only without the database; Network key,
- // Live (live mode) and Media (community mode) failures degrade (server/observability.js).
+ // Readiness reports what is actually served: 503 only without the database; the Network key
+ // and Media failures degrade (server/observability.js).
const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });
const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
@@ -255,10 +252,12 @@ async function createApp(opts = {}) {
@@ -255,10 +252,12 @@ async function createApp(opts = {}) {
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt()));
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt()));
app.get('/sitemap.xml', seo.sitemapHandler);
app.get('/sitemap.xml', seo.sitemapHandler);
app.get('/feed.xml', wrap(seo.feedHandler));
app.get('/feed.xml', wrap(seo.feedHandler));
+ // GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).
+ if (indexnow.enabled) app.use(indexnow.keyFile);
// ── Pages ────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────
- // Pages are for browsers: in community mode the viewer (subject, staff) is resolved too.
- const withUser = community ? viewers.middleware({ services: false }) : optionalAuth(auth);
+ // Pages are for browsers: the viewer (subject, staff) is resolved here too.
+ const withUser = viewers.middleware({ services: false });
const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
@@ -309,37 +308,30 @@ async function createApp(opts = {}) {
@@ -309,37 +308,30 @@ async function createApp(opts = {}) {
html(res, pages.pastePage({ paste, related, user: req.user }));
html(res, pages.pastePage({ paste, related, user: req.user }));
}));
}));
- if (community) {
- // The store is the authority: raw text is served here, the screenshot link is the stored
- // Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)
- const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
- app.get('/p/:slug/raw', withUser, async (req, res) => {
- if (!SLUG_RE.test(req.params.slug)) return notFound(res);
- try {
- const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
- if (out.redirect) return res.redirect(302, out.redirect);
- res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
- res.type('text/plain; charset=utf-8').send(out.content);
- } catch (err) {
- if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
- if (err.status === 404) return notFound(res);
- throw err;
- }
- });
- app.get('/p/:slug/screenshot', withUser, async (req, res) => {
- if (!SLUG_RE.test(req.params.slug)) return notFound(res);
- try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
- catch (err) {
- if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
- if (err.status === 404) return notFound(res);
- throw err;
- }
- });
- } else {
- // Raw text and screenshots are public on OpenVibe.Media — bounce there.
- app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
- app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
- }
+ // Raw text is served here, the screenshot link is the stored Media URL.
+ const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
+ app.get('/p/:slug/raw', withUser, async (req, res) => {
+ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
+ try {
+ const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
+ if (out.redirect) return res.redirect(302, out.redirect);
+ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
+ res.type('text/plain; charset=utf-8').send(out.content);
+ } catch (err) {
+ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
+ if (err.status === 404) return notFound(res);
+ throw err;
+ }
+ });
+ app.get('/p/:slug/screenshot', withUser, async (req, res) => {
+ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
+ try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
+ catch (err) {
+ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
+ if (err.status === 404) return notFound(res);
+ throw err;
+ }
+ });
app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
const { slug } = req.params;
const { slug } = req.params;
@@ -347,7 +339,7 @@ async function createApp(opts = {}) {
@@ -347,7 +339,7 @@ async function createApp(opts = {}) {
let paste;
let paste;
try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
- if (paste.type === 'screenshot') return res.redirect(302, community ? (paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`) : live.screenshotUrl(slug));
+ if (paste.type === 'screenshot') return res.redirect(302, paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', 'private, no-cache');
res.set('Cache-Control', 'private, no-cache');
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
@@ -361,10 +353,9 @@ async function createApp(opts = {}) {
@@ -361,10 +353,9 @@ async function createApp(opts = {}) {
html(res, pages.newPage({ user: req.user, fork }));
html(res, pages.newPage({ user: req.user, fork }));
}));
}));
- // No-JS fallback: a plain form post becomes the same JSON create the API path uses.
- // In community mode the anonymous-write budget applies here too (in 'live' mode Live enforces it).
- const formLimiter = community ? anonWriteLimiter : (_req, _res, next) => next();
- app.post('/new', withUser, formLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
+ // No-JS fallback: a plain form post becomes the same JSON create the API path uses, under the
+ // same anonymous-write budget.
+ app.post('/new', withUser, anonWriteLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const values = {
const values = {
title: String(b.title || '').slice(0, 200),
title: String(b.title || '').slice(0, 200),
@@ -422,16 +413,10 @@ async function createApp(opts = {}) {
@@ -422,16 +413,10 @@ async function createApp(opts = {}) {
});
});
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
- const upstream = err && err.name === 'LiveApiError';
- if (!upstream) console.error('[App]', err && err.stack ? err.stack : err);
- else console.warn('[App] upstream:', err.status, err.message);
+ console.error('[App]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (res.headersSent) return;
- if (req.path.startsWith('/api/')) return res.status(upstream ? 502 : 500).json({ error: upstream ? 'Paste service unavailable' : 'Internal error' });
- html(res, pages.errorPage({
- status: upstream ? 502 : 500,
- title: upstream ? 'The paste service is taking a break' : 'Something went wrong',
- message: upstream ? 'OpenVibe.Live did not answer. Pastes come back the moment it does — try again in a minute.' : 'This one is on us. Please try again.',
- }), upstream ? 502 : 500);
+ if (req.path.startsWith('/api/')) return res.status(500).json({ error: 'Internal error' });
+ html(res, pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }), 500);
});
});
return app;
return app;
@@ -65,7 +65,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -65,7 +65,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
const REPLY_PAGE = 20;
const REPLY_PAGE = 20;
-function createCommentService({ db, network = null, pastesLocal = false, limits = {} } = {}) {
+function createCommentService({ db, network = null, limits = {} } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@ -144,7 +144,6 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -144,7 +144,6 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
async function checkCommunityRef(ref) {
async function checkCommunityRef(ref) {
if (ref.service !== 'community') return;
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
if (ref.type === 'paste') {
- if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here
const p = await pasteStore.getBySlug(db, ref.id);
const p = await pasteStore.getBySlug(db, ref.id);
if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
} else if (ref.type === 'post') {
} else if (ref.type === 'post') {
@@ -159,7 +158,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -159,7 +158,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */
/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */
async function entityOwner(t) {
async function entityOwner(t) {
if (t.ref_service !== 'community') return null;
if (t.ref_service !== 'community') return null;
- if (t.ref_type === 'paste' && pastesLocal) { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }
+ if (t.ref_type === 'paste') { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }
if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = await db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }
if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = await db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }
return null;
return null;
}
}
@@ -43,11 +43,6 @@ module.exports = {
@@ -43,11 +43,6 @@ module.exports = {
secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
},
},
- // OpenVibe.Live — backs every paste read and write. The pastes themselves are stored in
- // OpenVibe.Media, but we go through Live rather than straight to Media: Live owns the
- // accounts these pastes belong to, so it is the only service that can turn a signed-in
- // visitor (a Network JWT) into the user id Media files the write under.
- liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
@@ -56,12 +51,9 @@ module.exports = {
@@ -56,12 +51,9 @@ module.exports = {
// Media's internal address: new screenshot uploads go to its file store (community app).
// Media's internal address: new screenshot uploads go to its file store (community app).
mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),
mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),
- // Who answers /api/pastes/* and the paste pages:
- // 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
- // 'community' — this site's own database is the authority (server/pastes/api.js).
- pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
- // PostgreSQL (ADR-035) for Community's own data: comments, the forum, Pulse, the relay's bookkeeping, and pastes
- // once authority=community. DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,
+ // PostgreSQL (ADR-035) for Community's own data: pastes (Community is their only authority),
+ // comments, the forum, Pulse and the relay's bookkeeping. DATABASE_URL serves (PgBouncer);
+ // DATABASE_DIRECT_URL migrates (owner). Without them,
// development uses an embedded PGlite database in data/pglite.
// development uses an embedded PGlite database in data/pglite.
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
// Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.
// Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.
@@ -86,6 +78,11 @@ module.exports = {
@@ -86,6 +78,11 @@ module.exports = {
unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
},
},
+ // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set, the key file is served at
+ // /<key>.txt and a public, indexable page appearing, changing or going away pings the engines.
+ // Unset: off — nothing is mounted and nothing is sent.
+ indexnow: { key: process.env.INDEXNOW_KEY || '' },
+
// OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Community asks Chat, with the
// OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Community asks Chat, with the
// signed-in person's own Network token, whether they manage the room; the space page links it.
// signed-in person's own Network token, whether they manage the room; the space page links it.
chat: {
chat: {
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
/**
/**
* Community's own database: PostgreSQL (ADR-035, roadmap WS-X2). The schema is migrations/NNNN_*.sql, applied at boot.
* Community's own database: PostgreSQL (ADR-035, roadmap WS-X2). The schema is migrations/NNNN_*.sql, applied at boot.
*
*
- * Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
+ * Community is the only authority for pastes: pastes, their edit
* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,
* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,
- * posts), the Discord relay's bookkeeping and Pulse live here in every mode. People are referenced by Network subject ids
+ * posts), the Discord relay's bookkeeping and Pulse live here too. People are referenced by Network subject ids
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* of what the Network says about them.
* of what the Network says about them.
*
*
@@ -97,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
@@ -97,7 +97,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
const THREADS_PER_DAY = 20;
-function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {} } = {}) {
+function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
@@ -109,6 +109,20 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -109,6 +109,20 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const hook = async (fn) => { try { await fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
const hook = async (fn) => { try { await fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
+ // IndexNow (openvibe-shared/indexnow): a public, indexable space or thread page appearing, changing
+ // or going away pings the engines with its path (and the space's, when a thread lands or leaves) and
+ // the sitemap. Members-only (gated) spaces and threads are not in the sitemap, so they never ping.
+ const site = String(config.baseUrl || '').replace(/\/$/, '');
+ const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);
+ const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };
+ const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}/s/${s.slug}`, `${site}/sitemap.xml`]); };
+ const pingThread = (space, t, { withSpace = false } = {}) => {
+ if (!indexableSpace(space) || !t || t.members_only_owner) return;
+ const urls = [`${site}/s/${space.slug}/t/${t.slug}`, `${site}/sitemap.xml`];
+ if (withSpace) urls.push(`${site}/s/${space.slug}`);
+ ping(urls);
+ };
+
const moderator = (v) => discussionModerator(v);
const moderator = (v) => discussionModerator(v);
const person = (v) => !!(v && v.subject);
const person = (v) => !!(v && v.subject);
@@ -351,6 +365,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -351,6 +365,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
});
});
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
+ pingThread(await store.getSpaceById(db, thread.space_id), thread, { withSpace: true });
return { ok: true, id: thread.id, deleted: 'thread' };
return { ok: true, id: thread.id, deleted: 'thread' };
}
}
@@ -473,6 +488,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -473,6 +488,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const fields = await spaceFields(body, space);
const fields = await spaceFields(body, space);
const next = await store.updateSpace(db, space.id, fields);
const next = await store.updateSpace(db, space.id, fields);
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
+ pingSpace(space);
+ pingSpace(row);
return { space: await shapeSpace(row, await membersOnly(row.members_only_owner)) };
return { space: await shapeSpace(row, await membersOnly(row.members_only_owner)) };
},
},
@@ -490,6 +507,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -490,6 +507,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });
parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.slug === slug);
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.slug === slug);
+ pingSpace(row);
return { space: await shapeSpace(row) };
return { space: await shapeSpace(row) };
},
},
@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`)
attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`)
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
+ pingSpace(space);
return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
},
},
@@ -534,6 +553,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -534,6 +553,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).
// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).
const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';
const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';
+ pingSpace(space);
return { detached: true, chat };
return { detached: true, chat };
},
},
@@ -639,6 +659,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -639,6 +659,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (pulse) await hook(async () => await pulse.threadCreated(created, target));
if (pulse) await hook(async () => await pulse.threadCreated(created, target));
if (relay) await hook(async () => await relay.enqueueThread(created, target));
if (relay) await hook(async () => await relay.enqueueThread(created, target));
const projections = await authors.projectionsFor([created.author_subject]);
const projections = await authors.projectionsFor([created.author_subject]);
+ pingThread(target, created, { withSpace: true });
return { thread: await shapeThread(created, target, v, projections, null) };
return { thread: await shapeThread(created, target, v, projections, null) };
},
},
@@ -680,7 +701,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -680,7 +701,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;
const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;
const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
- return { category: shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position })) };
+ const category = shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position }));
+ pingSpace(space);
+ return { category };
},
},
/** Delete a category — moderators. Its threads stay, without a category. */
/** Delete a category — moderators. Its threads stay, without a category. */
@@ -688,6 +711,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -688,6 +711,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
+ pingSpace(space);
return { ok: true };
return { ok: true };
},
},
@@ -700,6 +724,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -700,6 +724,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);
const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
+ pingThread(space, next);
return { thread: await shapeThread(next, space, v, projections, null) };
return { thread: await shapeThread(next, space, v, projections, null) };
},
},
@@ -711,6 +736,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -711,6 +736,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
const next = await store.setThreadStatus(db, thread.id, body.status);
const next = await store.setThreadStatus(db, thread.id, body.status);
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
+ pingThread(space, next);
return { thread: await shapeThread(next, space, v, projections, null) };
return { thread: await shapeThread(next, space, v, projections, null) };
},
},
@@ -789,6 +815,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -789,6 +815,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (pulse) await hook(async () => await pulse.threadCreated(thread, space));
if (pulse) await hook(async () => await pulse.threadCreated(thread, space));
if (relay) await hook(async () => await relay.enqueueThread(thread, space));
if (relay) await hook(async () => await relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
+ pingThread(space, thread, { withSpace: true });
return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };
return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };
},
},
@@ -815,6 +842,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -815,6 +842,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// Where the new post lands: its page in the thread (posts are numbered in id order).
// Where the new post lands: its page in the thread (posts are numbered in id order).
const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
+ pingThread(space, thread);
return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
},
},
@@ -828,6 +856,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -828,6 +856,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
+ pingThread(space, thread);
return { post: shapePost(next, v, projections) };
return { post: shapePost(next, v, projections) };
},
},
@@ -842,7 +871,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -842,7 +871,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
async deletePost(v, postId) {
async deletePost(v, postId) {
- const { post, thread } = await postFor(v, postId);
+ const { post, thread, space } = await postFor(v, postId);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (post.is_opening) return await removeThread(v, thread);
if (post.is_opening) return await removeThread(v, thread);
await db.tx(async () => {
await db.tx(async () => {
@@ -851,6 +880,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -851,6 +880,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
});
});
if (pulse) await hook(async () => await pulse.postGone(post.id));
if (pulse) await hook(async () => await pulse.postGone(post.id));
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));
if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));
+ pingThread(space, thread);
return { ok: true, id: post.id };
return { ok: true, id: post.id };
},
},
@@ -889,6 +919,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -889,6 +919,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return r;
return r;
});
});
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
+ pingThread(space, next);
return { thread: await shapeThread(next, space, v, projections, null) };
return { thread: await shapeThread(next, space, v, projections, null) };
},
},
@@ -908,6 +939,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -908,6 +939,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const next = await store.setThreadMembersOnly(db, thread.id, owner);
const next = await store.setThreadMembersOnly(db, thread.id, owner);
if (owner) await hideGated([thread.id]);
if (owner) await hideGated([thread.id]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
+ pingThread(space, thread);
+ pingThread(space, next);
return { thread: await shapeThread(next, space, v, projections, null) };
return { thread: await shapeThread(next, space, v, projections, null) };
},
},
@@ -919,6 +952,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -919,6 +952,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const owner = gateOwner(v, requested === undefined ? null : requested, null);
const owner = gateOwner(v, requested === undefined ? null : requested, null);
const next = await store.setSpaceMembersOnly(db, space.id, owner);
const next = await store.setSpaceMembersOnly(db, space.id, owner);
if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));
if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));
+ pingSpace(space);
+ pingSpace(next);
return { space: await shapeSpace(next, await membersOnly(next.members_only_owner)) };
return { space: await shapeSpace(next, await membersOnly(next.members_only_owner)) };
},
},
@@ -89,7 +89,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -89,7 +89,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
if (others) {
if (others) {
await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
- ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
+ ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
add(retained, 'tombstones', 1);
add(retained, 'tombstones', 1);
} else {
} else {
await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
@@ -1,31 +1,12 @@
@@ -1,31 +1,12 @@
'use strict';
'use strict';
/**
/**
- * Capability checks for service tokens, including the ids Community introduces before the
- * contracts library knows them.
- *
- * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in
- * its manifests yet. The ids below are proposed in docs/capabilities-proposal/ and go into a
- * contracts release; until then a grant is decided locally with the library's own matching rule
- * (the exact id, or a `prefix.*` grant covering it). An id the library does know always goes
- * through the library, so the day the release lands nothing changes here.
+ * Capability checks for service tokens, through openvibe-contracts' manifests.
*/
*/
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
-const PROPOSED = new Set([
- 'community.comment.write',
- 'community.comment.moderate',
- 'community.pulse.write',
- 'community.post.create',
-]);
-
/** → { allowed, code, reason } like capabilities.check(). */
/** → { allowed, code, reason } like capabilities.check(). */
function checkCapability(claims, capabilityId) {
function checkCapability(claims, capabilityId) {
- if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
- return capabilities.grants(claims && claims.cap, capabilityId)
- ? { allowed: true, code: null, reason: null }
- : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
- }
return capabilities.check(claims, capabilityId);
return capabilities.check(claims, capabilityId);
}
}
@@ -53,4 +34,4 @@ function discussionModerator(viewer) {
@@ -53,4 +34,4 @@ function discussionModerator(viewer) {
return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'community.comment.moderate'));
return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'community.comment.moderate'));
}
}
-module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator, PROPOSED };
+module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator };
@@ -25,9 +25,7 @@ const { createApp } = require('./app');
@@ -25,9 +25,7 @@ const { createApp } = require('./app');
} catch (err) { console.warn('[Roadmap] not synced:', err.message); }
} catch (err) { console.warn('[Roadmap] not synced:', err.message); }
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
- console.log(config.pastesAuthority === 'community'
- ? `[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`
- : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
+ console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
// Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).
// Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).
deleted file mode 100644
deleted file mode 100644
@@ -1,95 +0,0 @@
@@ -1,95 +0,0 @@
-'use strict';
-
-/**
- * OpenVibe.Live client — server-side reads of the paste API for rendered pages.
- *
- * Everything paste-shaped goes through Live's /api/pastes/* (see config.liveInternalUrl for
- * why). The visitor's Network JWT and real client address are forwarded on every call, so a
- * signed-in visitor sees their own private/unlisted pastes and Live's per-address rate limits
- * and view counting see the visitor, not this proxy.
- */
-const config = require('./config');
-
-class LiveApiError extends Error {
- constructor(status, body) {
- super((body && body.error) || `Live API ${status}`);
- this.name = 'LiveApiError';
- this.status = status;
- this.body = body;
- }
-}
-
-function _qs(query) {
- const params = new URLSearchParams();
- for (const [k, v] of Object.entries(query || {})) {
- if (v === undefined || v === null || v === '') continue;
- params.set(k, String(v));
- }
- const s = params.toString();
- return s ? `?${s}` : '';
-}
-
-/**
- * @param {string} apiPath e.g. '/pastes?limit=10' (relative to /api)
- * @param {object} [opts] { method, query, body, token, ip, timeoutMs }
- */
-async function request(apiPath, { method = 'GET', query, body, token, ip, timeoutMs = 10_000 } = {}) {
- const url = `${config.liveInternalUrl}/api${apiPath}${_qs(query)}`;
- const headers = { Accept: 'application/json' };
- if (token) headers.Authorization = `Bearer ${token}`;
- if (ip) headers['X-Forwarded-For'] = ip;
- const opts = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
- if (body !== undefined) {
- headers['Content-Type'] = 'application/json';
- opts.body = JSON.stringify(body);
- }
- let res, text;
- try {
- res = await fetch(url, opts);
- text = await res.text();
- } catch (err) {
- // Unreachable / timed out: surface as an upstream error so pages show the outage
- // message instead of a generic 500.
- throw new LiveApiError(503, { error: err.name === 'TimeoutError' ? 'Paste service timed out' : 'Paste service unreachable' });
- }
- let data = null;
- try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
- if (!res.ok) throw new LiveApiError(res.status, data);
- return data;
-}
-
-// ── Pastes ───────────────────────────────────────────────────
-/** Public listing: { pastes, total, limit, offset }. */
-async function listPastes(query, ctx = {}) {
- return await request('/pastes', { query, token: ctx.token, ip: ctx.ip });
-}
-
-/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */
-async function getPaste(slug, ctx = {}) {
- const out = await request(`/pastes/${encodeURIComponent(slug)}`, {
- query: ctx.noView ? { no_view: 1 } : undefined, token: ctx.token, ip: ctx.ip,
- });
- return out && out.paste ? out.paste : out;
-}
-
-/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */
-async function listByUser(username, query, ctx = {}) {
- return await request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });
-}
-
-/** Create a text paste on the visitor's behalf (no-JS form fallback). */
-async function createPaste(body, ctx = {}) {
- return await request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });
-}
-
-// ── Public URL builders (OpenVibe.Media serves the bytes) ────
-function rawUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/raw`; }
-function screenshotUrl(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
-/** Absolute-ize a Media-relative URL (screenshot_url from the API). */
-function mediaPublicUrl(u) {
- if (!u) return null;
- if (/^https?:\/\//i.test(u)) return u;
- return `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`;
-}
-
-module.exports = { LiveApiError, request, listPastes, getPaste, listByUser, createPaste, rawUrl, screenshotUrl, mediaPublicUrl };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2) and screenshot pastes (C-24). Community keeps a
+ * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2) and screenshot pastes. Community keeps a
* `med_` reference and the public URL; the bytes live in Media under the `community` tenant, owned by
* `med_` reference and the public URL; the bytes live in Media under the `community` tenant, owned by
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* namespace community):
* namespace community):
@@ -6,10 +6,7 @@
@@ -6,10 +6,7 @@
* network_jwks optional the Network signing key has loaded. Without it public pages and reads
* network_jwks optional the Network signing key has loaded. Without it public pages and reads
* still work, but nobody can sign in or write as a signed-in viewer or
* still work, but nobody can sign in or write as a signed-in viewer or
* service (those answer 503), so it degrades rather than fails
* service (those answer 503), so it degrades rather than fails
- * live optional in PASTES_AUTHORITY=live mode: Live answers /api/health (paste pages and
- * /api/pastes read through it; comments, forum and Pulse do not)
- * media optional in PASTES_AUTHORITY=community mode: Media answers /healthz
- * (screenshot and file uploads; paste text is local)
+ * media optional Media answers /healthz (screenshot and file uploads; paste text is local)
*
*
* Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,
* Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,
* threads) are deliberately not metrics.
* threads) are deliberately not metrics.
@@ -49,17 +46,13 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nu
@@ -49,17 +46,13 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nu
},
},
},
},
];
];
- if (config.pastesAuthority === 'community') {
- checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });
- } else {
- checks.push({ name: 'live', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.liveInternalUrl}/api/health`, fetchImpl) });
- }
+ checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });
return createReadiness({
return createReadiness({
service: 'community',
service: 'community',
release,
release,
checks,
checks,
details: async (body) => {
details: async (body) => {
- const out = { pastes_authority: config.pastesAuthority };
+ const out = {};
if (relay && relay.enabled && body.checks.db.status === 'ok') {
if (relay && relay.enabled && body.checks.db.status === 'ok') {
// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.
// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.
const st = await relay.status();
const st = await relay.status();
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
'use strict';
'use strict';
/**
/**
- * /api/pastes/* — Community's native paste API (PASTES_AUTHORITY=community).
+ * /api/pastes/* — Community's native paste API.
*
*
- * Same paths, bodies, status codes and response shapes the browser clients already use against
- * OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
+ * Same paths, bodies, status codes and response shapes the browser clients already use; the rules
+ * live in service.js.
*
*
* GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)
* GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)
* sort=newest (default) | oldest | top (views, a like worth five views);
* sort=newest (default) | oldest | top (views, a like worth five views);
@@ -150,7 +150,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -150,7 +150,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run(async (req) => await service.deleteComment(req.viewer, slug(req), req.params.commentId)));
router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run(async (req) => await service.deleteComment(req.viewer, slug(req), req.params.commentId)));
- // Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
+ // Avatars belong to the Network account, not to a paste.
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
router.use((req, res) => res.status(404).json({ error: 'Not found' }));
router.use((req, res) => res.status(404).json({ error: 'Not found' }));
@@ -3,19 +3,17 @@
@@ -3,19 +3,17 @@
/**
/**
* Catalog — the site's view over recent PUBLIC pastes.
* Catalog — the site's view over recent PUBLIC pastes.
*
*
- * Live's list endpoint (Media behind it) knows newest/oldest, a text search and a per-user
- * filter; it does not sort by views or filter by language. This keeps the latest window of
- * public pastes (Media's page cap) in memory for a short while and answers trending,
- * related, language counts and the views/language browse modes from it. Everything here is
- * fetched anonymously, so nothing unlisted or private can ever appear on a shared page.
+ * The store knows newest/oldest, a text search and a per-user filter; it does not sort by views or
+ * filter by language. This keeps the latest window of public pastes in memory for a short while
+ * and answers trending, related, language counts and the views/language browse modes from it.
+ * Everything here is read anonymously, so nothing unlisted or private can ever appear on a shared page.
*
*
- * With the community authority (source.local) the store answers and the window is kept only
- * LOCAL_TTL_MS — long enough that one page's latest/trending/languages share a single query.
+ * The window is kept only LOCAL_TTL_MS — long enough that one page's latest/trending/languages
+ * share a single query.
*/
*/
const source = require('./source');
const source = require('./source');
-const WINDOW = 200; // Media's maximum page size
-const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live
+const WINDOW = 200; // page cap for one window query
const LOCAL_TTL_MS = 2000;
const LOCAL_TTL_MS = 2000;
const PER_PAGE = 24;
const PER_PAGE = 24;
@@ -25,14 +23,14 @@ function _textOnlyPublic(rows) {
@@ -25,14 +23,14 @@ function _textOnlyPublic(rows) {
return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
}
}
-/** The latest public pastes (cached). Never throws — an unreachable Live yields the stale list or []. */
+/** The latest public pastes (cached). Never throws — an unavailable store yields the stale list or []. */
async function recent() {
async function recent() {
const now = Date.now();
const now = Date.now();
if (_recent.promise) return _recent.promise;
if (_recent.promise) return _recent.promise;
- if (now - _recent.at < (source.local ? LOCAL_TTL_MS : TTL_MS)) return _recent.pastes;
+ if (now - _recent.at < LOCAL_TTL_MS) return _recent.pastes;
_recent.promise = source.listPastes({ limit: WINDOW, offset: 0 })
_recent.promise = source.listPastes({ limit: WINDOW, offset: 0 })
.then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
.then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
- .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });
+ .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - LOCAL_TTL_MS + 5000; return _recent.pastes; });
return _recent.promise;
return _recent.promise;
}
}
@@ -74,7 +72,7 @@ const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p.
@@ -74,7 +72,7 @@ const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p.
/**
/**
* Browse: { q, sort: 'new'|'views', lang, page } → { pastes, total, page, pages, perPage, ... }.
* Browse: { q, sort: 'new'|'views', lang, page } → { pastes, total, page, pages, perPage, ... }.
- * Newest with no language filter pages straight through Live (its total is exact); the views
+ * Newest with no language filter pages straight through the store (its total is exact); the views
* sort and the language filter are answered from the recent window.
* sort and the language filter are answered from the recent window.
*/
*/
async function browse(query = {}, ctx = {}) {
async function browse(query = {}, ctx = {}) {
deleted file mode 100644
deleted file mode 100644
@@ -1,86 +0,0 @@
@@ -1,86 +0,0 @@
-'use strict';
-
-/**
- * /api/pastes/* → OpenVibe.Live /api/pastes/* (transparent, server-side)
- *
- * The browser talks to /api/pastes/* on this site; we proxy to the same API the streaming
- * site's own paste pages use. This deliberately does NOT call Media's tenant API directly:
- * the visitor's JWT names the account by its NETWORK id, while the user_id Media stores for
- * these pastes is Live's own — different numbers for the same person, so a write filed here
- * straight into Media would land under whichever unrelated Live account holds that number.
- * Live resolves the visitor against its own accounts before writing, which only Live can do,
- * and this inherits its author names, anonymous-post limits and permission checks rather
- * than reimplementing them.
- *
- * Mounted BEFORE the body parsers: the request body is streamed upstream byte-for-byte
- * (JSON, multipart screenshot uploads, anything), with the visitor's token as a Bearer header
- * and the real client address in X-Forwarded-For.
- */
-const config = require('../config');
-const { extractToken } = require('../auth/routes');
-
-const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'cookie', 'authorization', 'content-length']);
-const BODYLESS = new Set(['GET', 'HEAD', 'OPTIONS']);
-
-function createPastesProxy(opts = {}) {
- const upstreamBase = () => `${opts.liveUrl || config.liveInternalUrl}/api/pastes`;
-
- return async function pastesProxy(req, res) {
- const target = `${upstreamBase()}${req.url === '/' ? '' : req.url}`;
- // The path is the visitor's: "/api/pastes/../../internal/x" (or %2e%2e) would resolve to another of
- // Live's routes, with the visitor's token; an encoded slash or backslash is one for Live once decoded.
- // Only Live's paste API is proxied.
- const base = new URL(upstreamBase()).pathname;
- let resolved = null;
- try { resolved = new URL(target).pathname; } catch { /* not a URL: refused below */ }
- if (!resolved || (resolved !== base && !resolved.startsWith(`${base}/`)) || /%(2f|5c)/i.test(resolved)) return res.status(404).json({ error: 'Not found' });
- const headers = {};
- for (const [k, v] of Object.entries(req.headers)) {
- if (HOP_BY_HOP.has(k) || v == null) continue;
- headers[k] = Array.isArray(v) ? v.join(', ') : v;
- }
- // Forward the visitor's JWT — Live verifies it and maps it to a local account.
- const userToken = extractToken(req);
- if (userToken) headers.authorization = `Bearer ${userToken}`;
- // Live rate-limits, bans and counts views by client address. Without this every visitor
- // arriving through this site would share one bucket and one identity.
- if (req.ip) headers['x-forwarded-for'] = req.ip;
- headers['x-forwarded-host'] = req.get('host') || '';
- headers.accept = req.get('accept') || 'application/json';
-
- const fetchOpts = { method: req.method, headers, redirect: 'manual' };
- if (!BODYLESS.has(req.method)) {
- if (req.headers['content-length']) headers['content-length'] = req.headers['content-length'];
- fetchOpts.body = req; // stream the body through untouched
- fetchOpts.duplex = 'half';
- }
- const ctrl = new AbortController();
- const timer = setTimeout(() => ctrl.abort(), BODYLESS.has(req.method) ? 20_000 : 120_000);
- fetchOpts.signal = ctrl.signal;
- const onClientGone = () => { try { ctrl.abort(); } catch { /* */ } };
- res.on('close', onClientGone);
-
- try {
- const upstream = await fetch(target, fetchOpts);
- res.status(upstream.status);
- for (const [k, v] of upstream.headers) {
- if (k === 'content-type' || k === 'cache-control' || k === 'location' || k === 'retry-after'
- || k === 'ratelimit-limit' || k === 'ratelimit-remaining' || k === 'ratelimit-reset') {
- res.setHeader(k, v);
- }
- }
- const raw = Buffer.from(await upstream.arrayBuffer());
- res.send(raw);
- } catch (err) {
- if (!res.headersSent) {
- console.warn('[PasteProxy]', err.name === 'AbortError' ? 'upstream timed out' : err.message);
- res.status(502).json({ error: 'Could not reach the paste service' });
- }
- } finally {
- clearTimeout(timer);
- res.off('close', onClientGone);
- }
- };
-}
-
-module.exports = { createPastesProxy };
@@ -3,9 +3,8 @@
@@ -3,9 +3,8 @@
/**
/**
* Paste service — the rules of the paste system, on top of store.js.
* Paste service — the rules of the paste system, on top of store.js.
*
*
- * This reproduces what OpenVibe.Media's paste API did behind OpenVibe.Live's /api/pastes proxy
- * (status codes, error texts, response shapes, limits), with identity reworked around Network
- * subjects:
+ * This is the paste API Community's own pages and browser clients use: the same status codes,
+ * error texts, response shapes and limits, with identity reworked around Network subjects:
* - a "limited" caller is one acting as a person (a signed-in browser, or a service naming
* - a "limited" caller is one acting as a person (a signed-in browser, or a service naming
* X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as
* X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as
* they applied to Media's user callers. Anonymous writes are limited per address in api.js.
* they applied to Media's user callers. Anonymous writes are limited per address in api.js.
@@ -65,10 +64,22 @@ function sinceParam(value) {
@@ -65,10 +64,22 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
}
}
-function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
+function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
+ // IndexNow (openvibe-shared/indexnow): a public, indexable paste page appearing, changing or going away
+ // pings the engines with its path and the sitemap. Private, unlisted, NSFW and burn-after-read pages are
+ // not in the sitemap, so they never ping.
+ const site = String(config.baseUrl || '').replace(/\/$/, '');
+ const indexable = (p) => !!(p && p.visibility === 'public' && !Number(p.is_nsfw) && !Number(p.burn_after_read));
+ const pingIndexNow = (...rows) => {
+ if (!indexnow || !indexnow.enabled) return;
+ const slugs = new Set();
+ for (const p of rows) if (indexable(p)) slugs.add(p.slug);
+ if (!slugs.size) return;
+ indexnow.pingSoon([...slugs].map((s) => `${site}/p/${s}`).concat(`${site}/sitemap.xml`));
+ };
const visitorSecret = process.env.VIEW_HASH_SECRET
const visitorSecret = process.env.VIEW_HASH_SECRET
|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
@@ -287,6 +298,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -287,6 +298,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async function created(row, extra = {}) {
async function created(row, extra = {}) {
await tell((p) => p.pasteCreated(row));
await tell((p) => p.pasteCreated(row));
+ pingIndexNow(row);
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
}
}
@@ -467,6 +479,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -467,6 +479,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return r;
return r;
});
});
await tell((pl) => pl.pasteChanged(row));
await tell((pl) => pl.pasteChanged(row));
+ pingIndexNow(p, row);
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
@@ -480,6 +493,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -480,6 +493,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
});
});
await tell((pl) => pl.pasteGone(p.slug));
await tell((pl) => pl.pasteGone(p.slug));
+ pingIndexNow(p);
return { success: true };
return { success: true };
},
},
@@ -626,6 +640,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -626,6 +640,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (action === 'delete') await store.softDelete(db, p.id);
if (action === 'delete') await store.softDelete(db, p.id);
else await store.setVisibility(db, p.id, action);
else await store.setVisibility(db, p.id, action);
if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));
if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));
+ pingIndexNow(action === 'public' ? { ...p, visibility: 'public' } : p);
done++;
done++;
}
}
if (done) await events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
if (done) await events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
@@ -654,6 +669,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -654,6 +669,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
return r;
return r;
});
});
+ pingIndexNow(row);
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
@@ -664,6 +680,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -664,6 +680,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
const row = await store.setAi(db, p.id, summary, tags);
const row = await store.setAi(db, p.id, summary, tags);
+ pingIndexNow(row);
return { ok: true, paste: await shapeOne(row, v) };
return { ok: true, paste: await shapeOne(row, v) };
},
},
};
};
@@ -1,19 +1,16 @@
@@ -1,19 +1,16 @@
'use strict';
'use strict';
/**
/**
- * Where server-rendered pages read pastes from.
+ * Where server-rendered pages read pastes from: Community's own store, through the same service
+ * the API uses, so a page and the API can never disagree about visibility or view counts.
*
*
- * 'live' authority → live-client.js (OpenVibe.Live's API, Media behind it) — the default.
- * 'community' authority → this site's own store, through the same service the API uses, so a
- * page and the API can never disagree about visibility or view counts.
- *
- * Both expose the live-client call shapes (listPastes / getPaste / listByUser / createPaste);
- * `ctx` carries the visitor ({ token, ip, userAgent, viewer, noView }).
+ * app.js installs the service once at boot (source.use(service)); the call shapes
+ * (listPastes / getPaste / listByUser / createPaste) are stable, and `ctx` carries the visitor
+ * ({ token, ip, userAgent, viewer, noView }).
*/
*/
-const live = require('../live-client');
const { ANONYMOUS } = require('../identity/viewer');
const { ANONYMOUS } = require('../identity/viewer');
-let current = { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
+let current = null;
function localSource(service) {
function localSource(service) {
const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;
const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;
@@ -27,15 +24,15 @@ function localSource(service) {
@@ -27,15 +24,15 @@ function localSource(service) {
};
};
}
}
-/** Switch to the store (community authority); passing nothing goes back to Live. */
+/** Install the store (the only paste authority). */
function use(service) {
function use(service) {
- current = service ? localSource(service) : { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
+ current = localSource(service);
return current;
return current;
}
}
module.exports = {
module.exports = {
use,
use,
- get local() { return current.local; },
+ get local() { return !!(current && current.local); },
listPastes: (...a) => current.listPastes(...a),
listPastes: (...a) => current.listPastes(...a),
getPaste: (...a) => current.getPaste(...a),
getPaste: (...a) => current.getPaste(...a),
listByUser: (...a) => current.listByUser(...a),
listByUser: (...a) => current.listByUser(...a),
@@ -178,7 +178,7 @@ async function listVersions(db, pasteId) {
@@ -178,7 +178,7 @@ async function listVersions(db, pasteId) {
return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
}
}
-/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
+/** Soft delete: the row stays (its slug stays reserved), its content and image link do not. */
async function softDelete(db, id) {
async function softDelete(db, id) {
return await db.tx(async () => {
return await db.tx(async () => {
const cur = await getById(db, id);
const cur = await getById(db, id);
@@ -31,6 +31,11 @@ function assetVersion(rel) {
@@ -31,6 +31,11 @@ function assetVersion(rel) {
}
}
function asset(rel) { return `/${rel}?v=${assetVersion(rel)}`; }
function asset(rel) { return `/${rel}?v=${assetVersion(rel)}`; }
+// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
+// between pages of the same release, and does a normal load across a deploy.
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
function jsonLdScript(objects) {
function jsonLdScript(objects) {
@@ -55,7 +60,7 @@ function navbarInit(opts) {
@@ -55,7 +60,7 @@ function navbarInit(opts) {
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
- loginUrl: `/auth/login?next=${encodeURIComponent(opts.canonicalPath || '/')}`,
+ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
};
@@ -134,6 +139,8 @@ ${jsonLdScript(o.jsonLd)}
@@ -134,6 +139,8 @@ ${jsonLdScript(o.jsonLd)}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${asset('js/community.js')}" defer></script>
<script src="${asset('js/community.js')}" defer></script>
+<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
</head>
</head>
<body class="${escapeHtml(o.bodyClass || '')}">
<body class="${escapeHtml(o.bodyClass || '')}">
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
@@ -152,4 +159,4 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -152,4 +159,4 @@ document.addEventListener('DOMContentLoaded', function () {
</html>`;
</html>`;
}
}
-module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
+module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
@@ -7,7 +7,6 @@
@@ -7,7 +7,6 @@
*/
*/
const config = require('../config');
const config = require('../config');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
-const live = require('../live-client');
const seo = require('../seo');
const seo = require('../seo');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -16,6 +15,15 @@ const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = r
@@ -16,6 +15,15 @@ const { highlight, escapeHtml: esc, languageLabel, extensionFor, LANGUAGES } = r
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
// ── Small helpers ────────────────────────────────────────────
// ── Small helpers ────────────────────────────────────────────
+/** Absolute-ize a Media-relative screenshot URL. */
+function mediaAbs(u) {
+ if (!u) return null;
+ if (/^https?:\/\//i.test(u)) return u;
+ return `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`;
+}
+/** A screenshot's Media address when the stored row has no screenshot_url. */
+function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
+
function timeAgo(v) {
function timeAgo(v) {
const iso = seo.isoDate(v);
const iso = seo.isoDate(v);
if (!iso) return '';
if (!iso) return '';
@@ -78,7 +86,7 @@ function pagerHref(state, page) {
@@ -78,7 +86,7 @@ function pagerHref(state, page) {
function pasteCard(p) {
function pasteCard(p) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
const preview = isShot
const preview = isShot
- ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(live.mediaPublicUrl(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
+ ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(mediaAbs(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
: `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
: `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
@@ -236,9 +244,9 @@ function pastePage({ paste: p, related, user }) {
@@ -236,9 +244,9 @@ function pastePage({ paste: p, related, user }) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const description = pasteDescription(p);
const description = pasteDescription(p);
- const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;
+ const shot = isShot ? mediaAbs(p.screenshot_url) || screenshotFallback(p.slug) : null;
const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
- // Community-authority pastes name their owner by Network subject; Live-proxied ones by name.
+ // Pastes name their owner by Network subject; the projection cache supplies the display fields.
const isOwner = !!(user && (p.is_owner === true
const isOwner = !!(user && (p.is_owner === true
|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
|| (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
@@ -196,14 +196,5 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -196,14 +196,5 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
});
await t.close();
await t.close();
-
- // The forum lives in Community's database in every mode, including PASTES_AUTHORITY=live.
- const live = await boot();
- await check('forum pages work with PASTES_AUTHORITY=live too', async () => {
- const r = await live.get('/s/general');
- assert.strictEqual(r.status, 200);
- has(r.text, '<h1>General</h1>');
- });
- await live.close();
done();
done();
})();
})();
@@ -6,9 +6,10 @@ const mockNetwork = require('./mock-network');
@@ -6,9 +6,10 @@ const mockNetwork = require('./mock-network');
const mockMedia = require('./mock-media');
const mockMedia = require('./mock-media');
/**
/**
- * opts.authority 'live' (default) or 'community' — the latter runs the native paste API, with Network and
- * Media mocks behind it. Every boot gets a migrated database of its own (./db.js: PGlite, or the
- * PostgreSQL containers under npm run test:pg, with Valkey for the limit counters).
+ * opts.authority accepted and ignored: Community is the only paste authority, so every boot runs the
+ * native paste API with Network and Media mocks behind it. Every boot gets a migrated
+ * database of its own (./db.js: PGlite, or the PostgreSQL containers under npm run
+ * test:pg, with Valkey for the limit counters).
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
@@ -20,7 +21,6 @@ async function boot(opts = {}) {
@@ -20,7 +21,6 @@ async function boot(opts = {}) {
const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });
const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.BASE_URL = 'https://openvibe.community';
process.env.BASE_URL = 'https://openvibe.community';
- process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;
process.env.OV_LIVE_URL = 'https://openvibe.live';
process.env.OV_LIVE_URL = 'https://openvibe.live';
process.env.OV_MEDIA_URL = 'https://openvibe.media';
process.env.OV_MEDIA_URL = 'https://openvibe.media';
process.env.OV_NETWORK_URL = netSrv.url;
process.env.OV_NETWORK_URL = netSrv.url;
@@ -31,7 +31,6 @@ async function boot(opts = {}) {
@@ -31,7 +31,6 @@ async function boot(opts = {}) {
process.env.COOKIE_SECURE = 'true';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = '1';
process.env.TRUST_PROXY = '1';
process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
- process.env.PASTES_AUTHORITY = opts.authority || 'live';
// test:pg: the Valkey container too, under a prefix of its own.
// test:pg: the Valkey container too, under a prefix of its own.
if (process.env.COMMUNITY_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {
if (process.env.COMMUNITY_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {
process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;
process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;
@@ -42,7 +41,7 @@ async function boot(opts = {}) {
@@ -42,7 +41,7 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const appOpts = { ...(opts.appOpts || {}) };
const appOpts = { ...(opts.appOpts || {}) };
appOpts.db = appOpts.db || await require('./db').testDb();
appOpts.db = appOpts.db || await require('./db').testDb();
- if (opts.authority === 'community') appOpts.pasteLimits = opts.pasteLimits;
+ appOpts.pasteLimits = opts.pasteLimits;
const app = await createApp(appOpts);
const app = await createApp(appOpts);
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing
+ * sent). With a key, the key file is served at /<key>.txt as text/plain and publishing an indexable
+ * page pings the engines with the page's path and the sitemap. Drafts (unlisted/private), NSFW and
+ * burn-after-read pages are never in the sitemap, so they never ping.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+const KEY = 'k'.repeat(32);
+const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+
+(async () => {
+ delete process.env.INDEXNOW_KEY;
+ const off = await boot({ authority: 'community', env: { INDEXNOW_KEY: '' }, pasteLimits: { cooldownSeconds: 0 } });
+ await check('without a key IndexNow is off: no key route and nothing sent', async () => {
+ assert.strictEqual(off.app.locals.indexnow.enabled, false);
+ const res = await off.get(`/${KEY}.txt`);
+ assert.strictEqual(res.status, 404, res.text);
+ });
+ await off.close();
+
+ const on = await boot({ authority: 'community', env: { INDEXNOW_KEY: KEY } });
+ await check('with a key the key file answers text/plain with the key', async () => {
+ assert.strictEqual(on.app.locals.indexnow.enabled, true);
+ const res = await on.get(`/${KEY}.txt`);
+ assert.strictEqual(res.status, 200, res.text);
+ assert.match(res.headers.get('content-type'), /text\/plain/);
+ assert.strictEqual(res.text, KEY);
+ });
+ await on.close();
+
+ // A spy in place of the module's HTTP send: records every pingSoon batch.
+ const pings = [];
+ const spy = {
+ enabled: true,
+ keyFile: (_req, _res, next) => next(),
+ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
+ ping: async () => ({ sent: 0, status: 0 }),
+ flush: async () => ({ sent: 0, status: 0 }),
+ };
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, env: { INDEXNOW_KEY: '' }, appOpts: { indexnow: spy } });
+
+ await check('a draft (unlisted) never pings', async () => {
+ const r = await t.get('/api/pastes', json({ content: 'a draft', visibility: 'unlisted' }));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(pings, []);
+ });
+
+ await check('a publish pings the paste path and the sitemap', async () => {
+ const r = await t.get('/api/pastes', json({ content: 'a published paste' }));
+ assert.strictEqual(r.status, 201, r.text);
+ const slug = r.json().slug;
+ assert.ok(pings.includes(`https://openvibe.community/p/${slug}`), JSON.stringify(pings));
+ assert.ok(pings.includes('https://openvibe.community/sitemap.xml'), JSON.stringify(pings));
+ });
+
+ await check('an NSFW paste never pings', async () => {
+ pings.length = 0;
+ const r = await t.get('/api/pastes', json({ content: 'nsfw', is_nsfw: true }));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(pings, []);
+ });
+
+ // A signed-in owner (through a service token acting as them) unpublishes their own public paste.
+ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const svc = (cap) => t.network.signService({ cap });
+
+ await check('unpublishing a public paste pings it again', async () => {
+ pings.length = 0;
+ const created = await t.get('/api/pastes', { ...json({ content: 'mine' }), headers: { 'content-type': 'application/json', authorization: `Bearer ${svc(['community.paste.create'])}`, 'x-ov-subject': alex.subject_id } });
+ assert.strictEqual(created.status, 201, created.text);
+ const slug = created.json().slug;
+ pings.length = 0;
+ const updated = await t.get(`/api/pastes/${slug}`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc(['community.paste.write'])}`, 'x-ov-subject': alex.subject_id }, body: JSON.stringify({ visibility: 'unlisted' }) });
+ assert.strictEqual(updated.status, 200, updated.text);
+ assert.ok(pings.includes(`https://openvibe.community/p/${slug}`), JSON.stringify(pings));
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -11,16 +11,13 @@ const path = require('path');
@@ -11,16 +11,13 @@ const path = require('path');
(async () => {
(async () => {
const dir = process.cwd();
const dir = process.cwd();
console.warn = () => {};
console.warn = () => {};
- const mockLive = require(path.join(dir, 'test', 'helpers', 'mock-live'));
const mockNetwork = require(path.join(dir, 'test', 'helpers', 'mock-network'));
const mockNetwork = require(path.join(dir, 'test', 'helpers', 'mock-network'));
const mockMedia = require(path.join(dir, 'test', 'helpers', 'mock-media'));
const mockMedia = require(path.join(dir, 'test', 'helpers', 'mock-media'));
- const liveSrv = await mockLive.start();
const net = await mockNetwork.start();
const net = await mockNetwork.start();
const mediaSrv = await mockMedia.start({ publicPem: net.publicPem, issuer: net.url });
const mediaSrv = await mockMedia.start({ publicPem: net.publicPem, issuer: net.url });
Object.assign(process.env, {
Object.assign(process.env, {
NODE_ENV: 'test',
NODE_ENV: 'test',
BASE_URL: 'https://openvibe.community',
BASE_URL: 'https://openvibe.community',
- OV_LIVE_INTERNAL_URL: liveSrv.url,
OV_LIVE_URL: 'https://openvibe.live',
OV_LIVE_URL: 'https://openvibe.live',
OV_MEDIA_URL: 'https://openvibe.media',
OV_MEDIA_URL: 'https://openvibe.media',
OV_NETWORK_URL: net.url,
OV_NETWORK_URL: net.url,
@@ -31,7 +28,6 @@ const path = require('path');
@@ -31,7 +28,6 @@ const path = require('path');
COOKIE_SECURE: 'true',
COOKIE_SECURE: 'true',
TRUST_PROXY: '1',
TRUST_PROXY: '1',
OV_MEDIA_INTERNAL_URL: mediaSrv.url,
OV_MEDIA_INTERNAL_URL: mediaSrv.url,
- PASTES_AUTHORITY: 'community',
});
});
const { createApp } = require(path.join(dir, 'server', 'app'));
const { createApp } = require(path.join(dir, 'server', 'app'));
// Every release this test boots is on PostgreSQL (ADR-035; server/db.js initDb): give it a migrated
// Every release this test boots is on PostgreSQL (ADR-035; server/db.js initDb): give it a migrated
@@ -15,18 +15,17 @@ function raw(base, path, headers = {}) {
@@ -15,18 +15,17 @@ function raw(base, path, headers = {}) {
// Let the boot-time key fetch land.
// Let the boot-time key fetch land.
await t.app.locals.auth.ensureKey();
await t.app.locals.auth.ensureKey();
- await check('live mode: ready, with db required and Network key and Live optional', async () => {
+ await check('ready, with db required and the Network key and Media optional', async () => {
const r = await t.get('/api/ready');
const r = await t.get('/api/ready');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const b = r.json();
const b = r.json();
assert.strictEqual(b.ready, true);
assert.strictEqual(b.ready, true);
assert.strictEqual(b.status, 'ready');
assert.strictEqual(b.status, 'ready');
assert.strictEqual(b.service, 'community');
assert.strictEqual(b.service, 'community');
- assert.strictEqual(b.pastes_authority, 'live');
- assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'live']);
+ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'media']);
assert.strictEqual(b.checks.db.required, true);
assert.strictEqual(b.checks.db.required, true);
assert.strictEqual(b.checks.network_jwks.required, false);
assert.strictEqual(b.checks.network_jwks.required, false);
- assert.strictEqual(b.checks.live.required, false);
+ assert.strictEqual(b.checks.media.required, false);
for (const [name, c] of Object.entries(b.checks)) {
for (const [name, c] of Object.entries(b.checks)) {
assert.strictEqual(c.status, name === 'valkey' && !process.env.VALKEY_URL ? 'skipped' : 'ok', name); // Valkey under test:pg only
assert.strictEqual(c.status, name === 'valkey' && !process.env.VALKEY_URL ? 'skipped' : 'ok', name); // Valkey under test:pg only
assert.strictEqual(typeof c.latency_ms, 'number');
assert.strictEqual(typeof c.latency_ms, 'number');
@@ -64,28 +63,28 @@ function raw(base, path, headers = {}) {
@@ -64,28 +63,28 @@ function raw(base, path, headers = {}) {
assert.ok(/release_client_updates_total\{outcome="reloaded",reason="user"\} 2/.test(m.body), m.body.split('\n').filter((l) => l.includes('release_client')).join('\n'));
assert.ok(/release_client_updates_total\{outcome="reloaded",reason="user"\} 2/.test(m.body), m.body.split('\n').filter((l) => l.includes('release_client')).join('\n'));
});
});
- await check('Live down: still ready (comments, forum and Pulse are served), live degraded', async () => {
- await t.live.close();
- await new Promise((r) => setTimeout(r, 20));
- // The Live probe is cached for 15 s; a fresh app instance sees the outage immediately.
- const { createCommunityReadiness } = require('../server/observability');
- const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });
- const b = await rd.run();
- assert.strictEqual(b.ready, true);
- assert.strictEqual(b.status, 'degraded');
- assert.deepStrictEqual(b.degraded, ['live']);
- });
-
await check('Network key not loaded: degraded, not down', async () => {
await check('Network key not loaded: degraded, not down', async () => {
const { createCommunityReadiness } = require('../server/observability');
const { createCommunityReadiness } = require('../server/observability');
const auth = { client: { publicKey: null }, ensureKey: async () => null };
const auth = { client: { publicKey: null }, ensureKey: async () => null };
- const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, pastesAuthority: 'community', mediaInternalUrl: t.media.url } }).run();
+ const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, mediaInternalUrl: t.media.url } }).run();
assert.strictEqual(b.ready, true);
assert.strictEqual(b.ready, true);
assert.deepStrictEqual(b.degraded, ['network_jwks']);
assert.deepStrictEqual(b.degraded, ['network_jwks']);
- assert.strictEqual(b.checks.media.status, 'ok', 'community mode probes Media instead of Live');
+ assert.strictEqual(b.checks.media.status, 'ok', 'Media is the optional dependency');
assert.match(b.checks.network_jwks.error, /not loaded/);
assert.match(b.checks.network_jwks.error, /not loaded/);
});
});
+ await check('Media down: still ready (pastes, comments, forum and Pulse are served), media degraded', async () => {
+ await t.media.close();
+ await new Promise((r) => setTimeout(r, 20));
+ // The Media probe is cached for 15 s; a fresh app instance sees the outage immediately.
+ const { createCommunityReadiness } = require('../server/observability');
+ const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });
+ const b = await rd.run();
+ assert.strictEqual(b.ready, true);
+ assert.strictEqual(b.status, 'degraded');
+ assert.deepStrictEqual(b.degraded, ['media']);
+ });
+
await check('database unusable: 503 not_ready with db failed', async () => {
await check('database unusable: 503 not_ready with db failed', async () => {
t.app.locals.db.close();
t.app.locals.db.close();
const r = await t.get('/api/ready');
const r = await t.get('/api/ready');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * Native /api/pastes (PASTES_AUTHORITY=community): who may call what.
+ * Native /api/pastes (Community is the only paste authority): who may call what.
* Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /
* Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /
* X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via
* X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via
* the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.
* the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.
@@ -248,7 +248,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -248,7 +248,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.owner_subject, alex.subject_id);
assert.strictEqual(p.owner_subject, alex.subject_id);
- assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot (C-24)');
+ assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot');
assert.strictEqual(up.owner, alex.subject_id, 'owned by the person');
assert.strictEqual(up.owner, alex.subject_id, 'owned by the person');
assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.title, 'My desk');
assert.strictEqual(p.title, 'My desk');
@@ -1,83 +1,76 @@
@@ -1,83 +1,76 @@
'use strict';
'use strict';
-/** /api/pastes/* is a transparent proxy to Live that forwards the visitor's identity and address. */
+/**
+ * T10 J6 — Community is the only paste authority. The removed proposal is that nothing reads the
+ * old authority switch and nothing proxies /api/pastes any more:
+ * - no source file under server/ mentions the switch, the Live paste client or the proxy;
+ * - the config exposes no authority/host for a Live paste API;
+ * - /api/pastes is the native store API (a real list, a native 404), and the Live mock is never called;
+ * - the legacy_media_id / legacy_user_id columns are gone from the paste tables.
+ */
const assert = require('assert');
const assert = require('assert');
-const { Readable } = require('stream');
+const fs = require('fs');
+const path = require('path');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
-(async () => {
- const t = await boot();
-
- await check('GET forwards path, query, the ov_token cookie as Bearer and the client address', async () => {
- const r = await t.get('/api/pastes/echo/thing?limit=5&x=y', { cookies: ['ov_token=cookie-jwt'], headers: { 'x-forwarded-for': '203.0.113.9' } });
- assert.strictEqual(r.status, 200);
- const e = r.json();
- assert.strictEqual(e.path, '/api/pastes/echo/thing');
- assert.deepStrictEqual(e.query, { limit: '5', x: 'y' });
- assert.strictEqual(e.headers.authorization, 'Bearer cookie-jwt');
- assert.strictEqual(e.headers['x-forwarded-for'], '203.0.113.9');
- assert.strictEqual(e.headers.cookie, undefined, 'cookies never leave this site');
- });
+const SERVER = path.join(__dirname, '..', 'server');
+const REMOVED = [
+ /PASTES_AUTHORITY/,
+ /OV_LIVE_INTERNAL_URL/,
+ /pastesAuthority/,
+ /liveInternalUrl/,
+ /live-client/,
+ /pastes\/proxy/,
+ /createPastesProxy/,
+ /LiveApiError/,
+];
- await check('Authorization header wins over the cookie; anonymous calls carry no Authorization', async () => {
- const r = await t.get('/api/pastes/echo/x', { cookies: ['ov_token=cookie-jwt'], headers: { authorization: 'Bearer header-jwt' } });
- assert.strictEqual(r.json().headers.authorization, 'Bearer header-jwt');
- const anon = await t.get('/api/pastes/echo/x');
- assert.strictEqual(anon.json().headers.authorization, undefined);
- });
+function sourceFiles(dir, out = []) {
+ for (const name of fs.readdirSync(dir)) {
+ const p = path.join(dir, name);
+ if (fs.statSync(p).isDirectory()) sourceFiles(p, out);
+ else if (p.endsWith('.js')) out.push(p);
+ }
+ return out;
+}
- await check('JSON POST body reaches Live byte-for-byte with its content-type', async () => {
- const body = JSON.stringify({ title: 't', content: 'hello', language: 'text' });
- const r = await t.get('/api/pastes', { method: 'POST', body, headers: { 'content-type': 'application/json' }, cookies: ['ov_token=good-token'] });
- assert.strictEqual(r.status, 201);
- assert.strictEqual(r.json().slug, 'new-paste-99');
- const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();
- assert.strictEqual(call.body.toString(), body);
- assert.strictEqual(call.headers['content-type'], 'application/json');
- assert.strictEqual(call.headers.authorization, 'Bearer good-token');
+(async () => {
+ await check('no server/ source mentions the removed paste-authority switch, Live client or proxy', () => {
+ const hits = [];
+ for (const file of sourceFiles(SERVER)) {
+ const text = fs.readFileSync(file, 'utf8');
+ for (const re of REMOVED) if (re.test(text)) hits.push(`${path.relative(path.join(__dirname, '..'), file)}: ${re}`);
+ }
+ assert.deepStrictEqual(hits, []);
+ assert.strictEqual(fs.existsSync(path.join(SERVER, 'pastes', 'api.js')), true, 'the native paste API is there');
});
});
- await check('multipart screenshot upload streams through untouched', async () => {
- const boundary = 'xxBOUNDARYxx';
- const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3, 255, 254]);
- const body = Buffer.concat([
- Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="title"\r\n\r\nShot\r\n--${boundary}\r\nContent-Disposition: form-data; name="screenshot"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`),
- png, Buffer.from(`\r\n--${boundary}--\r\n`),
- ]);
- const r = await t.get('/api/pastes/screenshot', { method: 'POST', body: Readable.from([body]), headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': String(body.length) } });
- assert.strictEqual(r.status, 201);
- assert.strictEqual(r.json().received, body.length);
- const call = t.live.calls.filter((c) => c.path === '/api/pastes/screenshot').pop();
- assert.ok(call.body.equals(body), 'body identical');
- assert.strictEqual(call.headers['content-type'], `multipart/form-data; boundary=${boundary}`);
+ await check('config exposes no paste-authority switch or Live paste host', () => {
+ const config = require('../server/config');
+ assert.ok(!('pastesAuthority' in config), 'pastesAuthority is gone');
+ assert.ok(!('liveInternalUrl' in config), 'liveInternalUrl is gone');
});
});
- await check('upstream status codes and error bodies pass through (404, 429)', async () => {
- const nf = await t.get('/api/pastes/does-not-exist');
- assert.strictEqual(nf.status, 404);
- assert.deepStrictEqual(nf.json(), { error: 'Paste not found' });
- const rl = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ title: 'slow down', content: 'x' }), headers: { 'content-type': 'application/json' } });
- assert.strictEqual(rl.status, 429);
- assert.strictEqual(rl.json().cooldown, 30);
- });
+ const t = await boot({ pasteLimits: { cooldownSeconds: 0 } });
- await check('DELETE and the copy/like sub-routes are forwarded with the method intact', async () => {
- const del = await t.get('/api/pastes/amber-fox-42', { method: 'DELETE', cookies: ['ov_token=good-token'] });
- assert.strictEqual(del.json().method, 'DELETE');
- const copy = await t.get('/api/pastes/amber-fox-42/copy', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });
- assert.strictEqual(copy.json().path, '/api/pastes/amber-fox-42/copy');
+ await check('/api/pastes is the native API (a list and a native 404), never a proxy to Live', async () => {
+ const list = await t.get('/api/pastes?limit=1');
+ assert.strictEqual(list.status, 200);
+ const body = list.json();
+ assert.ok(Array.isArray(body.pastes), 'the native list shape { pastes, total }');
+ assert.strictEqual(typeof body.total, 'number');
+ const missing = await t.get('/api/pastes/does-not-exist');
+ assert.strictEqual(missing.status, 404);
+ assert.ok(missing.json().error, 'a native error body, never a proxied response');
+ assert.strictEqual(t.live.calls.length, 0, 'Live is never called');
});
});
- await check('an unreachable Live answers 502 JSON instead of hanging', async () => {
- const { createApp } = require('../server/app');
- const http = require('http');
- const db = await require('./helpers/db').testDb();
- const app = await createApp({ liveUrl: 'http://127.0.0.1:1', db });
- const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
- const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);
- assert.strictEqual(res.status, 502);
- assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });
- await new Promise((r) => srv.close(r));
- await db.close();
+ await check('the paste tables no longer carry the legacy_media_id / legacy_user_id columns', async () => {
+ const cols = await t.db.prepare(`SELECT table_name, column_name FROM information_schema.columns
+ WHERE table_schema = current_schema() AND column_name IN ('legacy_media_id', 'legacy_user_id')`).all();
+ assert.deepStrictEqual(cols, []);
+ const kept = await t.db.prepare(`SELECT 1 AS ok FROM information_schema.columns
+ WHERE table_schema = current_schema() AND table_name = 'legacy_id_map' AND column_name = 'source_id'`).get();
+ assert.ok(kept, 'legacy_id_map (identity resolution) is intact');
});
});
await t.close();
await t.close();
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
/**
/**
* SSRF (roadmap WS-R task 5). Community fetches no URL a user chooses: no link previews, unfurls,
* SSRF (roadmap WS-R task 5). Community fetches no URL a user chooses: no link previews, unfurls,
* avatar or image imports, user webhooks or screenshots of URLs. Every outbound call goes to a base URL
* avatar or image imports, user webhooks or screenshots of URLs. Every outbound call goes to a base URL
- * from the owner's environment (Network, Live, Media, Events, VIP, Chat, Search, a Discord webhook
- * variable the owner allowlisted, Discord's gateway), so there is no egress guard to test. Three things
- * keep it that way:
+ * from the owner's environment (Network, Media, Events, VIP, Chat, Search, a Discord webhook
+ * variable the owner allowlisted, Discord's gateway), so there is no egress guard to test. Two
+ * things keep it that way:
*
*
* 1. A ratchet over server/: every outbound call site (fetch, http(s).request/get, WebSocket, net/tls,
* 1. A ratchet over server/: every outbound call site (fetch, http(s).request/get, WebSocket, net/tls,
* dns, child_process, and the SDK clients that make requests) is counted per file and must match
* dns, child_process, and the SDK clients that make requests) is counted per file and must match
@@ -13,8 +13,6 @@
@@ -13,8 +13,6 @@
* 2. Everywhere a person or a service can hand Community a URL (a Pulse item's url, links and image
* 2. Everywhere a person or a service can hand Community a URL (a Pulse item's url, links and image
* syntax in posts and comments, paste content, a screenshot's page_url, a chat room reference, a relay
* syntax in posts and comments, paste content, a screenshot's page_url, a chat room reference, a relay
* mapping), a canary server on loopback must never be called.
* mapping), a canary server on loopback must never be called.
- * 3. PASTES_AUTHORITY=live proxies /api/pastes/* to Live with the visitor's path: dot segments
- * ("/api/pastes/../../internal/x", also %2e%2e) and encoded slashes must not reach any other Live route.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -31,11 +29,9 @@ const INVENTORY = {
@@ -31,11 +29,9 @@ const INVENTORY = {
'server/identity/network.js': [2, 'Network /internal/identity/resolve-batch with a service token'],
'server/identity/network.js': [2, 'Network /internal/identity/resolve-batch with a service token'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
- 'server/live-client.js': [1, 'OV_LIVE_INTERNAL_URL/api + fixed paths; slugs encoded (callers check SLUG_RE)'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
- 'server/observability.js': [1, '/api/ready probes of the configured Live or Media health URL'],
- 'server/pastes/proxy.js': [1, 'OV_LIVE_INTERNAL_URL/api/pastes + the visitor\'s path, which must stay under /api/pastes (tested below)'],
+ 'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
@@ -66,20 +62,6 @@ function inventory() {
@@ -66,20 +62,6 @@ function inventory() {
return out;
return out;
}
}
-/** GET a raw path (no client-side normalisation of dot segments). */
-function rawGet(base, rawPath) {
- const { port } = new URL(base);
- return new Promise((resolve, reject) => {
- const req = http.request({ host: '127.0.0.1', port, path: rawPath, method: 'GET' }, (res) => {
- let body = '';
- res.on('data', (c) => { body += c; });
- res.on('end', () => resolve({ status: res.statusCode, body }));
- });
- req.on('error', reject);
- req.end();
- });
-}
-
(async () => {
(async () => {
await check('every outbound call site in server/ is in the classified inventory (a new one must be classified here)', async () => {
await check('every outbound call site in server/ is in the classified inventory (a new one must be classified here)', async () => {
const found = inventory();
const found = inventory();
@@ -92,33 +74,6 @@ function rawGet(base, rawPath) {
@@ -92,33 +74,6 @@ function rawGet(base, rawPath) {
assert.deepStrictEqual(problems, []);
assert.deepStrictEqual(problems, []);
});
});
- // ── live mode: the /api/pastes proxy ──
- {
- const t = await boot({ appOpts: { startRelay: false } });
- await check('PASTES_AUTHORITY=live: the /api/pastes proxy never leaves Live\'s paste API (dot segments, %2e%2e, encoded slashes)', async () => {
- const bad = ['/api/pastes/../../internal/x', '/api/pastes/../x', '/api/pastes/%2e%2e/%2e%2e/internal/x', '/api/pastes/%2E%2E/%2E%2E/metrics', '/api/pastes/.%2e/.%2e/internal/x',
- '/api/pastes/..%2f..%2finternal/x', '/api/pastes/..%2F..%2Finternal/x', '/api/pastes/..%5c..%5cinternal', '/api/pastes/amber-fox-42/../../../internal/x',
- '/api/pastes/./../../internal/x?next=1'];
- for (const p of bad) {
- const before = t.live.calls.length;
- const r = await rawGet(t.base, p);
- const reached = t.live.calls.slice(before).map((c) => c.path);
- assert.ok(reached.every((x) => x === '/api/pastes' || x.startsWith('/api/pastes/')), `${p} reached Live at ${reached.join(', ')}`);
- assert.ok(!reached.some((x) => /internal|metrics|%2f|%5c/i.test(x)), `${p} reached Live at ${reached.join(', ')}`);
- assert.strictEqual(r.status, 404, `${p}: ${r.status}`);
- }
- // Positive controls: the paste API itself still goes through.
- let before = t.live.calls.length;
- assert.strictEqual((await rawGet(t.base, '/api/pastes/amber-fox-42')).status, 200);
- assert.deepStrictEqual(t.live.calls.slice(before).map((c) => c.path), ['/api/pastes/amber-fox-42']);
- before = t.live.calls.length;
- assert.strictEqual((await rawGet(t.base, '/api/pastes?limit=1')).status, 200);
- const listed = t.live.calls.slice(before).map((c) => c.path);
- assert.ok(listed.length === 1 && /^\/api\/pastes\/?$/.test(listed[0]), listed.join(', '));
- });
- await t.close();
- }
-
// ── community mode: URLs people and services hand in are never fetched ──
// ── community mode: URLs people and services hand in are never fetched ──
const hits = [];
const hits = [];
const canary = await new Promise((resolve) => {
const canary = await new Promise((resolve) => {
@@ -5,6 +5,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -5,6 +5,14 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
+ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = t.network.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const ins = t.db.prepare(`INSERT INTO pastes (slug, owner_subject, type, title, content, language, visibility, screenshot_url, views, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
+ await ins.run('amber-fox-42', alex.subject_id, 'paste', 'Hello world in JavaScript', 'const x = 1;\n', 'javascript', 'public', null, 120, '2026-09-15 10:00:00', '2026-09-15 10:00:00');
+ await ins.run('fair-moon-23', sam.subject_id, 'screenshot', 'Desktop shot', 'my desktop', 'text', 'public', 'https://openvibe.media/o/med_FAIRMOON', 40, '2026-09-11 08:00:00', '2026-09-11 08:00:00');
+ await ins.run('dark-owl-55', alex.subject_id, 'paste', 'secret notes', 'unlisted', 'text', 'unlisted', null, 2, '2026-09-12 08:00:00', '2026-09-12 08:00:00');
+ await ins.run('grim-vale-61', alex.subject_id, 'paste', 'private thing', 'private', 'text', 'private', null, 0, '2026-09-10 08:00:00', '2026-09-10 08:00:00');
await check('robots.txt allows crawling, blocks the private routes, points at the sitemap', async () => {
await check('robots.txt allows crawling, blocks the private routes, points at the sitemap', async () => {
const r = await t.get('/robots.txt');
const r = await t.get('/robots.txt');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Server-rendered pages with PASTES_AUTHORITY=community: everything reads from Community's own
- * store (never from OpenVibe.Live), with the same visibility rules as the API.
+ * Server-rendered pages read from Community's own store (never from OpenVibe.Live), with the
+ * same visibility rules as the API.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -1,11 +1,23 @@
@@ -1,11 +1,23 @@
'use strict';
'use strict';
-/** Server-rendered pages: home, browse, paste (SEO head + body), my, new, errors. */
+/** Server-rendered pages: home, browse, paste (SEO head + body), my, new, errors — read from Community's own store. */
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
- const t = await boot();
+ const t = await boot({ pasteLimits: { cooldownSeconds: 0 } });
const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
+ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: '/data/avatars/alex.png' });
+ const sam = t.network.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ await t.db.prepare('INSERT INTO subject_projection (subject_id, username, display_name, avatar_url) VALUES (?, ?, ?, ?)').run(alex.subject_id, 'alex', 'Alex', '/data/avatars/alex.png');
+ await t.db.prepare('INSERT INTO subject_projection (subject_id, username, display_name, avatar_url) VALUES (?, ?, ?, ?)').run(sam.subject_id, 'sam', 'Sam', null);
+ const ins = t.db.prepare(`INSERT INTO pastes (slug, owner_subject, type, title, content, language, visibility, screenshot_url, views, likes, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
+ await ins.run('amber-fox-42', alex.subject_id, 'paste', 'Hello world in JavaScript', 'const greet = (name) => {\n return `Hello, ${name}!`;\n};\nconsole.log(greet("OpenVibe"));\n', 'javascript', 'public', null, 120, 3, '2026-09-15 10:00:00', '2026-09-15 10:00:00');
+ await ins.run('blue-lake-17', sam.subject_id, 'paste', 'nginx snippet', 'server {\n listen 80;\n}\n', 'nginx', 'public', null, 900, 0, '2026-09-14 09:00:00', '2026-09-14 09:00:00');
+ await ins.run('cold-ridge-88', alex.subject_id, 'paste', 'python helper', 'def add(a, b):\n return a + b\n', 'python', 'public', null, 15, 1, '2026-09-13 08:00:00', '2026-09-13 08:00:00');
+ await ins.run('dark-owl-55', alex.subject_id, 'paste', 'secret notes', 'unlisted <script>alert(1)</script>', 'text', 'unlisted', null, 2, 0, '2026-09-12 08:00:00', '2026-09-12 08:00:00');
+ await ins.run('fair-moon-23', sam.subject_id, 'screenshot', 'Desktop shot', 'my desktop', 'text', 'public', 'https://openvibe.media/o/med_FAIRMOON', 40, 0, '2026-09-11 08:00:00', '2026-09-11 08:00:00');
+ await ins.run('grim-vale-61', alex.subject_id, 'paste', 'private thing', 'private', 'text', 'private', null, 0, 0, '2026-09-10 08:00:00', '2026-09-10 08:00:00');
await check('home renders hero, latest + trending pastes, CTA and roadmap with full SEO head', async () => {
await check('home renders hero, latest + trending pastes, CTA and roadmap with full SEO head', async () => {
const r = await t.get('/');
const r = await t.get('/');
@@ -36,13 +48,26 @@ const { boot, check, done } = require('./helpers/app');
@@ -36,13 +48,26 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
+ assert.strictEqual(cfg.navbar.loginUrl, '/auth/login?next={path}', 'sign-in returns to the current page (boost moves between pages)');
assert.strictEqual(cfg.footer.mount, '#ov-footer');
assert.strictEqual(cfg.footer.mount, '#ov-footer');
assert.strictEqual(cfg.footer.variant, 'full');
assert.strictEqual(cfg.footer.variant, 'full');
// Verbiage: never "free"/"no cost".
// Verbiage: never "free"/"no cost".
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');
});
});
+ await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
+ for (const url of ['/', '/pastes', '/new', '/p/amber-fox-42']) {
+ const r = await t.get(url);
+ assert.strictEqual(r.status, 200, url);
+ assert.match(r.text, /<meta name="ov-boost" content="community@[^"]+">/, `${url}: the boost marker`);
+ assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[^"]+" data-main="#main" defer><\/script>/, `${url}: the boost script, main = #main`);
+ assert.ok(r.text.includes('"loginUrl":"/auth/login?next={path}"'), `${url}: the navbar's login template`);
+ assert.ok(r.text.includes('<main id="main"'), `${url}: the swappable main`);
+ }
+ });
+
await check('paste page: SEO head, Article JSON-LD with author/datePublished, highlighted body, actions, related', async () => {
await check('paste page: SEO head, Article JSON-LD with author/datePublished, highlighted body, actions, related', async () => {
+ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;
const r = await t.get('/p/amber-fox-42');
const r = await t.get('/p/amber-fox-42');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');
has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');
@@ -63,14 +88,12 @@ const { boot, check, done } = require('./helpers/app');
@@ -63,14 +88,12 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, 'data-copy-content="amber-fox-42"');
has(r.text, 'data-copy-content="amber-fox-42"');
has(r.text, 'twitter.com/intent/tweet');
has(r.text, 'twitter.com/intent/tweet');
has(r.text, '<h2>More like this</h2>');
has(r.text, '<h2>More like this</h2>');
- has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative Live avatar made absolute');
+ has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative avatar made absolute against Live');
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.history.type, 'paste');
assert.strictEqual(cfg.navbar.history.type, 'paste');
assert.strictEqual(cfg.navbar.history.title, 'Hello world in JavaScript');
assert.strictEqual(cfg.navbar.history.title, 'Hello world in JavaScript');
assert.strictEqual(cfg.footer.variant, 'compact');
assert.strictEqual(cfg.footer.variant, 'compact');
- // The view was counted by Live (the SSR fetch had no no_view flag).
- const call = t.live.calls.find((c) => c.path === '/api/pastes/amber-fox-42');
- assert.strictEqual(call.query.no_view, undefined);
+ assert.ok((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views >= before, 'views are counted (deduped per visitor)');
});
});
await check('unlisted paste is noindex and its content is escaped', async () => {
await check('unlisted paste is noindex and its content is escaped', async () => {
@@ -81,41 +104,40 @@ const { boot, check, done } = require('./helpers/app');
@@ -81,41 +104,40 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!r.text.includes('<script>alert(1)</script>'));
assert.ok(!r.text.includes('<script>alert(1)</script>'));
});
});
- await check('screenshot paste uses the Media image as og:image and ImageObject JSON-LD', async () => {
+ await check('screenshot paste uses the stored Media image as og:image and ImageObject JSON-LD', async () => {
const r = await t.get('/p/fair-moon-23');
const r = await t.get('/p/fair-moon-23');
- has(r.text, '<meta property="og:image" content="https://openvibe.media/p/fair-moon-23/screenshot">');
+ has(r.text, '<meta property="og:image" content="https://openvibe.media/o/med_FAIRMOON">');
has(r.text, '<meta name="twitter:card" content="summary_large_image">');
has(r.text, '<meta name="twitter:card" content="summary_large_image">');
has(r.text, '"@type":"ImageObject"');
has(r.text, '"@type":"ImageObject"');
- has(r.text, '<img src="https://openvibe.media/p/fair-moon-23/screenshot"');
+ has(r.text, '<img src="https://openvibe.media/o/med_FAIRMOON"');
});
});
await check('private paste 404s for anonymous visitors and renders for its owner', async () => {
await check('private paste 404s for anonymous visitors and renders for its owner', async () => {
const anon = await t.get('/p/grim-vale-61');
const anon = await t.get('/p/grim-vale-61');
assert.strictEqual(anon.status, 404);
assert.strictEqual(anon.status, 404);
has(anon.text, 'Paste not found');
has(anon.text, 'Paste not found');
- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
+ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
const owner = await t.get('/p/grim-vale-61', { cookies: [`ov_token=${token}`] });
const owner = await t.get('/p/grim-vale-61', { cookies: [`ov_token=${token}`] });
assert.strictEqual(owner.status, 200);
assert.strictEqual(owner.status, 200);
has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');
has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');
- const call = t.live.calls.filter((c) => c.path === '/api/pastes/grim-vale-61').pop();
- assert.strictEqual(call.headers.authorization, `Bearer ${token}`, 'visitor token forwarded to Live');
});
});
- await check('raw / screenshot bounce to Media; download serves the text as an attachment without a view', async () => {
+ await check('raw serves the text from the store; screenshot redirects to the stored image; download is an attachment without a view', async () => {
const raw = await t.get('/p/amber-fox-42/raw');
const raw = await t.get('/p/amber-fox-42/raw');
- assert.strictEqual(raw.status, 302);
- assert.strictEqual(raw.headers.get('location'), 'https://openvibe.media/p/amber-fox-42/raw');
+ assert.strictEqual(raw.status, 200);
+ assert.ok(raw.text.startsWith('const greet'));
const shot = await t.get('/p/fair-moon-23/screenshot');
const shot = await t.get('/p/fair-moon-23/screenshot');
- assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/p/fair-moon-23/screenshot');
+ assert.strictEqual(shot.status, 302);
+ assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/o/med_FAIRMOON');
+ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;
const dl = await t.get('/p/amber-fox-42/download');
const dl = await t.get('/p/amber-fox-42/download');
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="amber-fox-42.js"');
assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="amber-fox-42.js"');
assert.ok(dl.text.startsWith('const greet'));
assert.ok(dl.text.startsWith('const greet'));
- const call = t.live.calls.filter((c) => c.path === '/api/pastes/amber-fox-42').pop();
- assert.strictEqual(call.query.no_view, '1');
+ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views, before, 'download does not count a view');
});
});
- await check('browse: search passes through to Live, views sort and language filter work, search pages are noindex', async () => {
+ await check('browse: search, views sort and language filter over the store, search pages are noindex', async () => {
const all = await t.get('/pastes');
const all = await t.get('/pastes');
assert.strictEqual(all.status, 200);
assert.strictEqual(all.status, 200);
has(all.text, '<link rel="canonical" href="https://openvibe.community/pastes">');
has(all.text, '<link rel="canonical" href="https://openvibe.community/pastes">');
@@ -124,8 +146,6 @@ const { boot, check, done } = require('./helpers/app');
@@ -124,8 +146,6 @@ const { boot, check, done } = require('./helpers/app');
has(q.text, '<meta name="robots" content="noindex,follow">');
has(q.text, '<meta name="robots" content="noindex,follow">');
has(q.text, 'href="/p/blue-lake-17"');
has(q.text, 'href="/p/blue-lake-17"');
assert.ok(!q.text.includes('href="/p/amber-fox-42"'));
assert.ok(!q.text.includes('href="/p/amber-fox-42"'));
- const call = t.live.calls.filter((c) => c.path === '/api/pastes' && c.query.search).pop();
- assert.strictEqual(call.query.search, 'nginx');
const views = await t.get('/pastes?sort=views');
const views = await t.get('/pastes?sort=views');
const res = views.text.slice(views.text.indexOf('id="results"'));
const res = views.text.slice(views.text.indexOf('id="results"'));
assert.ok(res.indexOf('blue-lake-17') < res.indexOf('amber-fox-42') && res.indexOf('amber-fox-42') < res.indexOf('cold-ridge-88'));
assert.ok(res.indexOf('blue-lake-17') < res.indexOf('amber-fox-42') && res.indexOf('amber-fox-42') < res.indexOf('cold-ridge-88'));
@@ -141,38 +161,35 @@ const { boot, check, done } = require('./helpers/app');
@@ -141,38 +161,35 @@ const { boot, check, done } = require('./helpers/app');
const anon = await t.get('/my');
const anon = await t.get('/my');
assert.strictEqual(anon.status, 302);
assert.strictEqual(anon.status, 302);
assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fmy');
assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fmy');
- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
+ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });
const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });
assert.strictEqual(mine.status, 200);
assert.strictEqual(mine.status, 200);
has(mine.text, '<meta name="robots" content="noindex,nofollow">');
has(mine.text, '<meta name="robots" content="noindex,nofollow">');
for (const slug of ['amber-fox-42', 'cold-ridge-88', 'dark-owl-55', 'grim-vale-61']) has(mine.text, `href="/p/${slug}"`);
for (const slug of ['amber-fox-42', 'cold-ridge-88', 'dark-owl-55', 'grim-vale-61']) has(mine.text, `href="/p/${slug}"`);
assert.ok(!mine.text.includes('blue-lake-17'));
assert.ok(!mine.text.includes('blue-lake-17'));
- const call = t.live.calls.filter((c) => c.path === '/api/pastes/by-user/alex').pop();
- assert.strictEqual(call.headers.authorization, `Bearer ${token}`);
});
});
- await check('/new renders the form (private option only when signed in) and the no-JS post creates through Live', async () => {
+ await check('/new renders the form (private option only when signed in) and the no-JS post creates in the store', async () => {
const anon = await t.get('/new');
const anon = await t.get('/new');
assert.strictEqual(anon.status, 200);
assert.strictEqual(anon.status, 200);
has(anon.text, 'name="content"');
has(anon.text, 'name="content"');
assert.ok(!anon.text.includes('value="private"'));
assert.ok(!anon.text.includes('value="private"'));
- const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
+ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });
const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });
has(signed.text, 'value="private"');
has(signed.text, 'value="private"');
has(signed.text, 'Fork of Hello world in JavaScript');
has(signed.text, 'Fork of Hello world in JavaScript');
const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();
const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
assert.strictEqual(post.status, 303);
assert.strictEqual(post.status, 303);
- assert.strictEqual(post.headers.get('location'), '/p/new-paste-99');
- const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();
- assert.strictEqual(call.headers.authorization, `Bearer ${token}`);
- assert.deepStrictEqual(JSON.parse(call.body.toString()).content, 'print(1)');
+ const slug = post.headers.get('location').replace('/p/', '');
+ assert.ok(/^[A-Za-z0-9_-]+$/.test(slug), `a generated slug, got ${slug}`);
+ const row = await t.db.prepare('SELECT owner_subject, content FROM pastes WHERE slug = ?').get(slug);
+ assert.deepStrictEqual(row, { owner_subject: alex.subject_id, content: 'print(1)' });
const empty = await t.get('/new', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });
const empty = await t.get('/new', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });
assert.strictEqual(empty.status, 400);
assert.strictEqual(empty.status, 400);
has(empty.text, 'content is empty');
has(empty.text, 'content is empty');
- const limited = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'slow down', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
- assert.strictEqual(limited.status, 429);
- has(limited.text, 'Please wait 30s');
+ const anonPost = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'from anon', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+ assert.strictEqual(anonPost.status, 303, 'anonymous posts create in the store too');
});
});
await check('404 page and API 404 JSON; health', async () => {
await check('404 page and API 404 JSON; health', async () => {