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).

OP OpenVibers committed 29 Sept 2026 · c4b171a75c · 41 files, +581 −796


Changes

ddiff --git a/.env.example b/.env.example
iindex 42f9a8e..b269855 100644
---- a/.env.example
++++ b/.env.example
@@@ -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 bytes
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).
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=
ddiff --git a/README.md b/README.md
iindex b89d34f..576838b 100644
---- a/README.md
++++ b/README.md
@@@ -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 and
comments
- 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 and
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
@@@ -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 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
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
`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 the
one 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 cache
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
-- 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/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
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)
@@@ -712,7 +705,7 @@ server/
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)
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
votes.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 extensions
seo.js robots, sitemap, RSS, JSON-LD builders
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
--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
```
ddiff --git a/STATUS.json b/STATUS.json
iindex 4b6b256..f46e870 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/capabilities-proposal/community.comment.moderate.json b/docs/capabilities-proposal/community.comment.moderate.json
iindex 0ce4ed6..e69de29 100644
---- a/docs/capabilities-proposal/community.comment.moderate.json
++++ b/docs/capabilities-proposal/community.comment.moderate.json
@@@ -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/*"
-- ]
--}
ddiff --git a/docs/capabilities-proposal/community.comment.write.json b/docs/capabilities-proposal/community.comment.write.json
iindex 8e00c53..e69de29 100644
---- a/docs/capabilities-proposal/community.comment.write.json
++++ b/docs/capabilities-proposal/community.comment.write.json
@@@ -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"
-- ]
--}
ddiff --git a/docs/capabilities-proposal/community.post.create.json b/docs/capabilities-proposal/community.post.create.json
iindex 2357e3c..e69de29 100644
---- a/docs/capabilities-proposal/community.post.create.json
++++ b/docs/capabilities-proposal/community.post.create.json
@@@ -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"
-- ]
--}
ddiff --git a/docs/capabilities-proposal/community.pulse.write.json b/docs/capabilities-proposal/community.pulse.write.json
iindex ecebcfa..e69de29 100644
---- a/docs/capabilities-proposal/community.pulse.write.json
++++ b/docs/capabilities-proposal/community.pulse.write.json
@@@ -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"
-- ]
--}
ddiff --git a/migrations/0002_drop_legacy_paste_columns.sql b/migrations/0002_drop_legacy_paste_columns.sql
nnew file mode 100644
iindex 0000000..183a02e
---- /dev/null
++++ b/migrations/0002_drop_legacy_paste_columns.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex dca92dc..1068c22 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 20f79d6..dec9458 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/public/js/community.js b/public/js/community.js
iindex 6535891..2c7c903 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -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);
})();
ddiff --git a/server/app.js b/server/app.js
iindex f877a8d..3e88fc7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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-vars
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 (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;
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex d573fe5..4ce165f 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -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, limits
async function checkCommunityRef(ref) {
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
-- if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here
const p = 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;
}
ddiff --git a/server/config.js b/server/config.js
iindex b5c20c0..7284b26 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/db.js b/server/db.js
iindex 0bb1a54..a15e793 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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.
*
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex f948c28..2937f6e 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, vi
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); } };
++ // 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, vi
const 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, vi
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' });
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, vi
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);
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, vi
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).
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, vi
if (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, vi
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 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, vi
if (!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, vi
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 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, vi
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 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, vi
if (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, vi
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));
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, vi
return 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, vi
const 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, vi
const 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)) };
},
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 1252864..1f736ab 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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);
ddiff --git a/server/identity/capabilities.js b/server/identity/capabilities.js
iindex 12271d4..80ba71b 100644
---- a/server/identity/capabilities.js
++++ b/server/identity/capabilities.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex ca7f033..c459d3f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/live-client.js b/server/live-client.js
ddeleted file mode 100644
iindex 489430f..0000000
---- a/server/live-client.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex fdc2a97..f88d939 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -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):
ddiff --git a/server/observability.js b/server/observability.js
iindex fbdfff8..aa37cbf 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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();
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex b2cf979..df0789a 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -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' }));
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex e7b440a..b4c8863 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -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 query
const 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 = {}) {
ddiff --git a/server/pastes/proxy.js b/server/pastes/proxy.js
ddeleted file mode 100644
iindex d18f178..0000000
---- a/server/pastes/proxy.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex d0d5829..aed9030 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
async 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 = {}, lim
return 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 = {}, lim
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));
++ pingIndexNow(p);
return { success: true };
},
@@@ -626,6 +640,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (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 = {}, lim
await 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 = {}, lim
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 row = await store.setAi(db, p.id, summary, tags);
++ pingIndexNow(row);
return { ok: true, paste: await shapeOne(row, v) };
},
};
ddiff --git a/server/pastes/source.js b/server/pastes/source.js
iindex f1a758b..43fc536 100644
---- a/server/pastes/source.js
++++ b/server/pastes/source.js
@@@ -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),
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 6a9d9f4..5d60210 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -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);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ddd1895..d1cd793 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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 too
notificationsRealtime: 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 };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 7c80382..663fb63 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 } = r
const 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)))));
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex 9721d59..a75a778 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -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();
})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 0cef15d..135b8ad 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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}`;
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..ca3640f
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -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); });
ddiff --git a/test/n-1/boot-child.js b/test/n-1/boot-child.js
iindex 01fe8da..5b4f14d 100644
---- a/test/n-1/boot-child.js
++++ b/test/n-1/boot-child.js
@@@ -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
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex 947ccd1..6625e49 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -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 only
assert.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');
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 775e2da..c609782 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -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');
ddiff --git a/test/proxy.test.js b/test/proxy.test.js
iindex fc6c0a3..93948c3 100644
---- a/test/proxy.test.js
++++ b/test/proxy.test.js
@@@ -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();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 3913bff..c919b1d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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) => {
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 405e682..1ad5226 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -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');
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
iindex 5ef2e18..e6c7c8a 100644
---- a/test/ssr-community.test.js
++++ b/test/ssr-community.test.js
@@@ -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');
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex babb509..b6905c4 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -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 () => {