openvibers/openvibe.space Commits
Space: the forum moves back to Community; Space becomes code hosting and spaces (coming) (#9)
Changes
@@@ -26,72 +26,15 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4940/auth/callback# Cookies — production default is Secure; set false for plain-http local dev#COOKIE_SECURE=false--# OpenVibe.Media — images on posts are med_ objects uploaded through Media's Object API--# (the OAuth client needs media.object.upload for audience openvibe.media).--OV_MEDIA_URL=https://openvibe.media--OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100----# OpenVibe.VIP: members-only spaces and threads. Space asks POST /api/v1/policies/evaluate with a--# service token (the OAuth client needs vip.resource.policy.evaluate for audience openvibe.vip).--# Without it, or with VIP down, only the owner and moderators get in (fails closed).--OV_VIP_URL=https://openvibe.vip--OV_VIP_INTERNAL_URL=http://127.0.0.1:4620--# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.--#VIP_CACHE_TTL_MS=30000--#VIP_CACHE_DENY_TTL_MS=10000--#VIP_CACHE_UNAVAILABLE_TTL_MS=2000----# OpenVibe.Chat: a space's chat room. Space asks Chat with the signed-in person's own token whether they--# manage the room (no service grant needed); the space page links OV_CHAT_URL/r/<room>.--OV_CHAT_URL=https://openvibe.chat--OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400----# OpenVibe.Community — the hub: Space's pages link the network Pulse feed it serves (D12).++# OpenVibe.Community — the hub, and where the forum lives (owner decision, 2026-10-08): every++# forum URL on this site redirects to the same path and query there.OV_COMMUNITY_URL=https://openvibe.community--# IndexNow (openvibe-shared/indexnow): the key file is served at /<key>.txt and a public thread--# appearing, changing or going away pings the engines. Unset: off.--INDEXNOW_KEY=++# OpenVibe.Live — the legal pages the shared footer links++OV_LIVE_URL=https://openvibe.live--# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space: the runtime role through--# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). The forum--# lives there: spaces, threads, posts, votes, attachments, the relay's bookkeeping. Without DATABASE_URL,--# development uses an embedded PGlite database in data/pglite.++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space holds no++# user content yet; the database carries the shared schema (migrations/) and feeds /api/ready.++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.DATABASE_URL=DATABASE_DIRECT_URL=--VALKEY_URL=--VALKEY_PREFIX=ov:space:----# Per-actor limits (server/actor-limits.js): reads one caller (a person, a service or app acting as--# itself, else an address) may make to one API per minute and per hour; past them 429 rate_limited--# with Retry-After. Writes have their own numbers (README). The per-address /api/ limit stays.--#SPACE_LIMITS_MINUTE=120--#SPACE_LIMITS_HOUR=3000----# Browser origins allowed to call the embeddable forum APIs with a Bearer Network JWT--#API_CORS_ORIGINS=https://openvibe.community,https://openvibe.live,https://openvibe.network,https://openvibe.tools----# ── Discord relay (server/relay; docs/discord-relay.md) ──────--# Outbound: new threads and replies in mapped public spaces → the mapping's Discord webhook.--# Mappings (POST /api/v1/relay/mappings, staff) name an ENVIRONMENT VARIABLE that holds the webhook--# URL — the URL itself never goes into the database.--#DISCORD_RELAY_ENABLED=true--#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>--# Variables a mapping may name: DISCORD_WEBHOOK_* unless this lists the exact names.--#DISCORD_RELAY_WEBHOOK_VARS=DISCORD_WEBHOOK_FEEDBACK,DISCORD_WEBHOOK_GENERAL--#DISCORD_RELAY_POLL_MS=30000--#DISCORD_RELAY_BACKOFF_MS=30000--#DISCORD_RELAY_MAX_ATTEMPTS=6--# Inbound: replies on Discord become posts (a bot in the server with the MESSAGE CONTENT intent).--#DISCORD_RELAY_INBOUND=on--#DISCORD_BOT_TOKEN=--#DISCORD_GATEWAY_URL=wss://gateway.discord.gg/?v=10&encoding=json----# OpenVibe.Events — the relay's worker polls creates from the outbox through Events, and Events--# delivers Network's block/account/merge events to /internal/events (server/events-consumer.js).--EVENTS_URL=--# Signature secret Events signs deliveries with (SPACE_EVENTS_SECRET; comma-separated for rotation,--# each 32+ characters). Unset: the internal route answers 503 and nothing is applied.--SPACE_EVENTS_SECRET=--# Set 0 to stop the relay's worker from reading creates off Events (the forum queues them itself).--#DISCORD_RELAY_EVENTS=off@@@ -10,7 +10,7 @@ jobs:test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0with:-- syntax-dirs: server public/js test++ syntax-dirs: server testcontracts-service: spacetest: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).nnew file mode 100644@@@ -0,0 +1,9 @@++# Changelog++++## Unreleased++++- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.++- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.++- Space's home page (`/`) is now a short, honest page: Space will host code and dynamic pages, and "spaces" — apps people build on their OpenVibe account, the network's services and the SDK — with a clear link to the forum at https://openvibe.community/s. It claims no feature Space does not have.++- Kept: sign-in (the Network OAuth2 client `space`), the discovery files (robots, llms.txt, llms-full.txt, a sitemap holding `/` alone), health/ready/release.json. Removed the account export/deletion hooks and the rest of the network-event consumer: they only ever touched the forum's tables, and the forum's account data is Community's. The forum tables stay in the database; this release writes no drop migration.++- The suite is now `home`, `redirects` and `boot` (the real process on a fresh database, ending in a clean SIGTERM).@@@ -1,304 +1,169 @@# OpenVibe.Space--**https://openvibe.space — the forums of OpenVibe.**++**https://openvibe.space — code and dynamic pages for the OpenVibe network.**## Purpose--The forum service of the OpenVibe network: public, members-only and staff **spaces**, their--**threads**, **posts**, votes, ratings, categories, per-space **moderators** and the **Discord--relay**. The forum engine moved here from OpenVibe.Community (plan T10 step 3, decisions D11/D12/D29):--Community stays the hub (pastes, comment threads, Pulse, submissions) and Space owns the forums, in--its own database.----It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that server-renders--every page — crawlers and no-JS readers get the whole thing, every action is a link or a form post —--and adds a little progressive JavaScript for comfort.----## Owns----- spaces (groups, categories, child boards, per-space settings), threads, posts, post versions,-- thread votes, post ratings and image attachments (Media objects it names)--- the board index, the space pages, the thread pages and their reads and writes: `GET /s`,-- `GET /api/v1/spaces…`, `GET|POST|PUT|DELETE /api/v1/posts…`--- per-space moderators (`space_moderators`) — a data row, not a capability: a person the space lists-- moderates that space as discussion staff do everywhere, while the board index and creating spaces-- stay with staff--- a space's chat-room link (OpenVibe.Chat owns the room), the Discord relay's mappings, deliveries,-- message map, cursors and inbound failures, the block projection, the account-data records, the-- `space.*` events and one PostgreSQL database (`ov_space`, ADR-035; schema in [migrations/](migrations/);-- embedded PGlite in development), with Valkey for the per-actor limit counters++Space is being rebuilt to host **code** and **dynamic pages**, and **spaces**: apps people build++that use their OpenVibe account, the network's services and the SDK.++++Nothing is published here yet. The site is honest about that: the home page says what is coming and++nothing else, and no feature is faked.++++## The forum moved to OpenVibe.Community++++The forum lived here from 3 October 2026 (it moved off Community then). On **2026-10-08 the owner++moved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,++per-space moderators, the Discord relay) again, and Space becomes code hosting and spaces.++++So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path and++query on https://openvibe.community**:++++| Path | Methods | Status |++| --- | --- | --- |++| `/s`, `/s/*` (pages, feeds) | GET, HEAD | `301` |++| `/s`, `/s/*` | anything else | `308` (the method and body are preserved) |++| `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*`, `/api/v1/relay*` | all | `308` |++++A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.++Nothing that is not a forum path redirects.++++## What is here today++++- **`GET /`** — the home page (server-rendered through the shared OpenVibe Frame), with a clear link to++ the forum at https://openvibe.community/s.++- **Sign-in** — the Network OAuth2 client `space`: `/auth/login|callback|fedcm|logout|me|refresh`.++ No signed-in surface needs it yet; it is kept because the shared navbar signs people in everywhere.++- **Discovery** — `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (the home page alone).++- **Health and release** — `/api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback only).## Does not own--- identity and SSO (OpenVibe.Network), image bytes (OpenVibe.Media), memberships (OpenVibe.VIP),-- chat rooms (OpenVibe.Chat) and the Discord API itself--- **pastes, typed comment threads, Pulse and submissions**: they are OpenVibe.Community's, and they-- stay there (plan T10 D3). A thread's replies are its own posts — the forum does not use the comment-- service — and Space records no Pulse item and keeps no paste table.++- the forum, pastes, typed comment threads, Pulse and submissions — OpenVibe.Community's++- identity, accounts and tokens — OpenVibe.Network's++- images, memberships and chat rooms — OpenVibe.Media's, OpenVibe.VIP's and OpenVibe.Chat's## Depends on--- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve` for author names)--- OpenVibe.Media (attachment uploads through the Object API), OpenVibe.VIP (members-only gates),-- OpenVibe.Chat (a space's room), OpenVibe.Events (the outbox relay and the block/account/VIP-- subscriptions), OpenVibe.Community (the hub: the Pulse feed Space's pages link)--- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.35.0, `openvibe-shared` v2.15.0, pinned by release tarball++- OpenVibe.Network (SSO, JWKS, the `space` OAuth client)++- OpenVibe.Community (where every forum URL redirects)++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.35.0, `openvibe-shared` v2.15.0, pinned by release tarball--## How it fits the network++## What Space applies from the network: nothing--| Concern | Where it lives | How Space reaches it |--| --- | --- | --- |--| Spaces, threads, posts, votes, ratings | **Space's PostgreSQL** (`ov_space`, `server/forum/*`) | own database |--| Image bytes | **OpenVibe.Media** (Object API v2, tenant `space`) | service token (`media.object.upload`) |--| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `space` |--| 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) |--| Pulse (the network's public activity) | **OpenVibe.Community** | a link (`OV_COMMUNITY_URL/pulse`); Space stores none of it |--| Blocks, token cutoffs, account export/deletion, subject merge | **OpenVibe.Network** through **OpenVibe.Events** | `POST /internal/events`, signed with `SPACE_EVENTS_SECRET` |----Space resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`),--which is exactly what Space stores, so there is no second identity to map and no proxy.----## Endpoints----Pages (server-rendered) and API:----| Page | API |--| --- | --- |--| `GET /s` — the board index (every space, grouped, with topics, posts and the last post) | `GET /api/v1/spaces`, `GET /api/v1/space-groups` |--| `GET /s/:space` — a space's threads (`?sort=hot\|new\|top\|active&page=&category=&status=`) | `GET /api/v1/spaces/:space/threads` |--| `GET /s/:space/t/:slug` — a thread with its posts (`?page=`, `?quote=<post id>`) | `GET /api/v1/spaces/:space/threads/:slug` |--| `GET /s/:space/new`, `POST /s/:space/new` — start a thread | `POST /api/v1/spaces/:space/threads` |--| `POST /s/:space/t/:slug/reply` — reply (multipart images allowed) | `POST /api/v1/spaces/:space/threads/:slug/posts` |--| `POST /s/:space/t/:slug/vote` — 1, −1 or 0 | `POST /api/v1/spaces/:space/threads/:slug/votes` |--| `POST …/state`, `…/delete`, `…/members-only`, `…/status`, `…/category`, `…/crosspost` — moderation, no JS | `PUT /api/v1/spaces/:space/threads/:slug/…`, `PUT\|DELETE /api/v1/posts/:id` |--| `POST /s/:space/chat-room`, `…/chat-room/detach` | `PUT\|DELETE /api/v1/spaces/:space/chat-room` |--| `POST /s/:space/moderators`, `…/moderators/remove` | `GET\|PUT\|DELETE /api/v1/spaces/:space/moderators[/:subject]` |--| `GET /s/feed.xml`, `/s/:space/feed.xml` — RSS | `GET /robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` |--| `GET /auth/login\|callback\|logout\|me\|refresh` | `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback) |--| `GET /`, `/updates` — 301/302 to `/s` and the community's log | `POST /internal/events` (signed Events deliveries) |----## The forum----A space is one of three visibilities — `public` (anyone reads, people post), `members` (signed-in--people read and post) and `staff` (moderators only; it looks missing to everyone else) — and one of--two styles: `feed` (ranked by votes, hot/new/top, like a subreddit) or `forum` (the newest reply on--top, author panels, quotes; like a classic board). A thread is a `discussion`, a `request` (Feedback:--open → planned → in progress → done, or declined) or a `roadmap` item (the Roadmap space, synced from--`docs/roadmap/public.json` at every boot), each with its own statuses and its own thread.----Writing needs a person: the browser's Network JWT, or a service naming one in `X-OV-Subject` (or--`X-OV-Origin: ai` for AI output, stored with origin `ai` and no author). Per-person limits--(server/limits.js) hold: threads 3 a minute, replies 6 a minute, votes and ratings 60, uploads 12,--plus a daily thread cap; per-actor limits (server/actor-limits.js) cap requests per route family.----Images on posts go to OpenVibe.Media as `med_` objects (`kind: attachment`, `visibility: unlisted`),--owned by the person who uploaded them; the post names at most four `media_id`s. Post bodies are--Markdown through `server/render/markdown.js` — a small, safe renderer: every piece of text is escaped--first and only a fixed tag set leaves it.----### Per-space moderators----A space lists its own moderators (`space_moderators`, people only, added by `@username` or `usr_…`--resolved through the Network). In that space they act as discussion staff do everywhere: thread--state and status, deletes and edits, settings, categories, members-only gating and the chat room.--They can add and remove each other, and step down. Creating spaces, the board index (groups, a--space's place on it), roadmap items and staff spaces stay with discussion staff--(`identity/capabilities.js`).----### Members-only spaces and threads (OpenVibe.VIP)----A space (its creator, or a moderator for any creator) or a single thread (its author, for their own--members, or a moderator) can be opened to one creator's VIP members: `members_only_owner` is the--creator's `usr_…` subject. Reading, posting, voting and editing then need an active entitlement,--asked of VIP with a service token and cached (a "yes" at most `VIP_CACHE_TTL_MS`); the owner and--moderators always pass, and every doubt (signed out, VIP down, no grant) fails closed with--`403 vip.members_only` and a join link. A gated thread's title is listed with its `members_only`--flag, never a body; gated things never reach the sitemap, feeds or the Discord relay.----## Discord relay----`server/relay/discord.js` mirrors new threads and replies in mapped public spaces to a Discord--webhook, with attribution, a link and no mentions, and follows edits and deletes through the--message map. Webhook URLs live in environment variables named by a mapping (`webhook_url_ref`) —--never in the database. `server/relay/events-worker.js` can queue the creates from--`space.thread.created` / `space.post.created` off OpenVibe.Events instead (the default when--`EVENTS_URL` and the client secret are set); `server/relay/inbound.js` + `discord-gateway.js` bring--replies written on Discord back as posts, with the Discord name and `origin 'discord'`, honouring--locks, members-only gates and per-author limits. The whole relay is off unless--`DISCORD_RELAY_ENABLED` (inbound also needs `DISCORD_RELAY_INBOUND=on` and `DISCORD_BOT_TOKEN`);--see [docs/discord-relay.md](docs/discord-relay.md) (the moved doc from Community).----## What Space applies from the network----`POST /internal/events` (server/events-consumer.js) is Space's Events subscription endpoint--(consumer `space`, signature v2 under `SPACE_EVENTS_SECRET`, loopback only). Exactly once, through--the SDK inbox:----- `network.user.token_valid_after` — the person's token cutoff moves (sign out everywhere, password-- change, ban) and their older tokens are refused here at once--- `network.block.changed` — the block projection (`network_blocks`): nobody replies in a thread whose-- author blocked them, or reacts to a post whose author blocked them--- `network.subject.merged` — the folded-in subject's rows become the survivor's--- `network.account.export_requested` / `network.account.deleted` — Space's part of an account export,-- and what the person wrote goes (or becomes an authorless tombstone when others replied under it,-- so their replies keep their place); `space_moderators` and `network_blocks` rows go, `created_by`-- and `added_by` are cleared, and Space confirms the counts--- `vip.membership.changed` — the cached members-only answers for that creator are dropped at once----## Capabilities----Space's capabilities are `space.*` (openvibe-contracts `manifests/services/space.json`); v0.110.0--registers them active and lists them on the Space manifest:----| Capability | What it covers |--| --- | --- |--| `space.forum.read` | list the spaces the caller can open, one space's metadata, `GET /s` |--| `space.forum.manage` | moderation and staff administration of spaces (create, rename, settings, categories, moderators, visibility, members-only) |--| `space.thread.read` | a space's threads and one thread with its posts, the feeds |--| `space.post.write` | create threads and replies, vote, edit or delete one's own posts |----`space.thread.write` (a thread lifecycle grant on its own) and `space.pulse.read` (the Pulse activity--beside the forums) stay planned in Contracts and Space serves neither: `space.post.write` carries--thread writes and Community serves the Pulse feed.----Service tokens are held to these (`server/http/v1.js` `serviceCap`/`serviceAnyCap`); browsers and--anonymous callers pass the middlewares and are judged by Space's own rules instead. Staff powers come--from the contracts staff map (`staff.moderation.discussions`), so a person is staff on every OpenVibe--site at once.----## Per-actor limits----`server/actor-limits.js` counts requests per caller (a person, a first-party service relaying a--visitor, a service acting as itself, else the address) per route family, once `req.viewer` is--resolved: reads get `SPACE_LIMITS_MINUTE`/`SPACE_LIMITS_HOUR` (120/3000), writes their own numbers--where they are mounted. Past a limit the route answers `429 rate_limited` with `Retry-After` before--it does any work, counted in `space_rate_limited_total{limit,window}`. The per-address `/api/` limit--stays in front, and `/internal/events`, `/api/health`, `/api/ready`, `/release.json` and `/metrics`--are never limited.----## SEO----Every page carries the full head through `openvibe-shared/shell`: description, canonical, robots,--Open Graph, Twitter card, an ai-summary and JSON-LD (`WebSite`, `BreadcrumbList`, and--`DiscussionForumPosting` for a thread, with its replies; never for a gated page). `/robots.txt`,--`/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (rebuilt at most hourly, members-only spaces and--threads never in it), `/s/feed.xml` and `/s/:space/feed.xml` come from `server/discovery.js` and--`openvibe-shared/seo`; with `INDEXNOW_KEY` set, a public, indexable page appearing, changing or going--away pings the engines.++Space holds **no user content**, so it consumes no network events and has nothing to export or erase:++++- **Account export and deletion are gone.** Space's former export/deletion hooks (`network.account.export_requested`,++ `network.account.deleted`) only ever touched the **forum tables** — threads, posts, attachments, votes,++ reactions, the moderator list. Those tables' rows are the forum's, and the forum is++ OpenVibe.Community's, so **the forum's account data is Community's** and Space's hooks were removed.++- **Platform blocks, subject merges and VIP membership changes are gone** for the same reason: they only++ updated forum projections.++- The **token-cutoff projection** (`network.user.token_valid_after`) went with them: without signed-in++ content there is nothing here for a cutoff to protect.++++The forum tables stay in Space's database (migrations `0001_initial.sql` onward still create them).++This release writes **no drop migration**; the rows are Community's, and dropping them is a later++decision.## Configuration--Copy [.env.example](.env.example) to `.env` (or `/etc/openvibe/space.env` in production, 0600).--Everything has a working default except the OAuth client secret and the database. The keys that--change behaviour:----- `PORT` (4940), `HOST` (127.0.0.1), `BASE_URL`, `TRUST_PROXY` (2: Cloudflare → nginx → Node)--- `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID` (`space`),-- `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`--- `DATABASE_URL` (PgBouncer) and `DATABASE_DIRECT_URL` (the owner, migrations); without them,-- development uses an embedded PGlite database in `data/pglite` (`SPACE_PGLITE_DIR` overrides the path)--- `VALKEY_URL` / `VALKEY_PREFIX` (`ov:space:`) — shared per-actor counters--- `OV_MEDIA_URL` / `OV_MEDIA_INTERNAL_URL`, `OV_VIP_URL` / `OV_VIP_INTERNAL_URL`,-- `OV_CHAT_URL` / `OV_CHAT_INTERNAL_URL`, `OV_COMMUNITY_URL`--- `EVENTS_URL`, `SPACE_EVENTS_SECRET`, `INDEXNOW_KEY`--- `DISCORD_RELAY_*`, `DISCORD_BOT_TOKEN`, `DISCORD_GATEWAY_URL`++Copy `.env.example` to `.env` (production: `/etc/openvibe/space.env`, mode 0600).++++| Variable | Default | Meaning |++| --- | --- | --- |++| `PORT` / `HOST` | `4940` / `127.0.0.1` | Listen address (nginx in front) |++| `BASE_URL` | `https://openvibe.space` in production | Canonical origin |++| `TRUST_PROXY` | `2` | X-Forwarded-For hops (Cloudflare → nginx → Node) |++| `OV_NETWORK_URL` | `https://openvibe.network` | Issuer / authorize URL / JWKS |++| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Token grants, JWKS (tried first) |++| `OV_OAUTH_CLIENT_ID` | `space` | Registered on the Network |++| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |++| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.space/auth/callback` | Must match the registration |++| `OV_COMMUNITY_URL` | `https://openvibe.community` | Where every forum URL redirects |++| `OV_LIVE_URL` | `https://openvibe.live` | Legal pages the shared footer links |++| `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 space`) |## Run--```sh--npm ci--cp .env.example .env # fill in OV_OAUTH_CLIENT_SECRET; without DATABASE_URL it uses PGlite--npm run dev # http://localhost:4940--npm test # every test in test/ (PGlite)--npm run test:pg # the same, on PostgreSQL + PgBouncer + Valkey (openvibe-sdk scripts/test-services.sh)++```++npm install++npm start # http://127.0.0.1:4940++npm test # boots the Network mock in-process; no network needed```## Acceptance--- `npm test` green: the forum rules and API, SSR, the two styles (feed/forum), per-space moderators,-- members-only gating, chat rooms, attachments, the safe Markdown renderer, votes under race,-- categories and the roadmap, the Discord relay (outbound, inbound through a fake gateway, and the-- Events worker), and `test/boot.test.js` — the real process on a fresh database: `/api/ready` 2xx,-- `GET /s` renders, SIGTERM stops it cleanly.--- `openvibe-contracts-check` for `space` in CI, and no `community.` id anywhere under `server/`.++`npm test` runs every `test/*.test.js`:++++- `home` — the home page says what Space will host and links the forum on Community; discovery, health,++ readiness and sign-in answer; an unknown path is an honest 404;++- `redirects` — `/s`, `/s/*` (301 for GET/HEAD, 308 otherwise) and the forum APIs (308) redirect to the++ same path and query on openvibe.community, and nothing else redirects;++- `boot` — the real process on a fresh PGlite database: migrations run, `/api/ready` is 2xx, the home++ page renders, the forum URLs redirect, and SIGTERM stops it cleanly.## Security--- [SECURITY.md](SECURITY.md) — how to report a vulnerability.--- Identity never comes from a request body or query: a service token is judged on the token, a bad-- one is refused rather than downgraded to anonymous, and a first-party service acting for someone-- must name a subject the token allows.--- Post bodies are rendered by the safe Markdown renderer; every other user value is escaped.--- The relay never stores a webhook URL (only the name of the environment variable holding it), and-- `/internal/events` refuses any request that came through a proxy.--- Per-address and per-actor limits on every API surface, plus the per-person write limits.++Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:++++- **Auth.** Browsers use Network JWTs (the `ov_token` cookie); the session layer verifies them offline++ against the Network's JWKS. The OAuth `state` cookie is required, and post-auth targets are restricted++ to this site or `https://openvibe.network/…`.++- **Egress.** Space calls only its configured Network host.++- **Secrets.** `OV_OAUTH_CLIENT_SECRET` lives in `/etc/openvibe/space.env` (0600), by name only.## Deploy--`deploy/systemd/openvibe-space.service` (unit `openvibe-space`, `127.0.0.1:4940`,--`WorkingDirectory=/opt/openvibe.space`, `EnvironmentFile=/etc/openvibe/space.env`) and--`deploy/nginx/openvibe.space.conf` (TLS, www → apex, the auth and API rate limits, `/metrics`--refused from the edge). The database is OpenVibe.Host's `add-service.sh space`--(`ov_space` on the data role). Deployed with the host's `ovhost deploy space`.++```++/opt/openvibe.space # git checkout, `npm ci --omit=dev`++/etc/openvibe/space.env # secrets (0600)++deploy/systemd/openvibe-space.service # → /etc/systemd/system/, User=ubuntu, port 4940++deploy/nginx/openvibe.space.conf # → /etc/nginx/sites-available/, TLS from++ # /etc/letsencrypt/live/openvibe.space/++```++++The database is `ov_space` on the host's data role. Rollback: ovhost puts the previous sha back when++`/api/ready` does not answer 2xx after the restart.## Layout```server/-- index.js, app.js process entry and the Express app-- config.js, db.js configuration and the PostgreSQL handle (migrations at boot)-- events.js Space → OpenVibe.Events (the outbox)-- events-consumer.js OpenVibe.Events → Space (blocks, accounts, cutoffs, VIP)-- observability.js readiness for GET /api/ready-- auth/routes.js the OAuth2 client session (Network)-- identity/ subjects, capabilities, authors, blocks, projection, account data, merge-- forum/ store (SQL), service (rules), api (JSON), routes (pages/forms), reactions, roadmap-- relay/ discord (outbound), events-worker, discord-gateway + inbound, api-- render/ layout, pages, forum + board (the two styles), markdown, highlight, jsonld-- http/v1.js problems, capability guards, cursors, CORS-- limits.js, actor-limits.js, votes.js, chat-rooms.js, media/, vip/, discovery.js--migrations/0001_initial.sql the whole schema (forum, relay, blocks, accounts, outbox, inbox)--public/ space.css, space.js, the app icons and og-default.png--test/ the suites (test/run.js runs them) and their mocks--deploy/ systemd unit and nginx vhost--docs/roadmap/public.json the Roadmap space's source (synced at boot)++ index.js process entry (listen, graceful stop)++ app.js Express app factory: redirects, home page, sign-in, discovery, health++ config.js env → config++ db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot++ discovery.js robots disallow set, llms sections, the sitemap (the home page alone)++ observability.js truthful /api/ready (the database is required; the Network key degrades)++ auth/routes.js OAuth2 client (login/callback/fedcm/logout/me/refresh)++ render/layout.js page shell (openvibe-shared/shell) and the redirect/site constants++ render/pages.js the home page and the error pages++public/ css/space.css, assets/ (logo, og-default), manifest.webmanifest++migrations/ the shared schema, including the forum tables Space no longer serves++deploy/ systemd unit, nginx vhost++test/ run.js + home/redirects/boot tests```## What is next--- **The data cutover** (plan T10 step 4, not this repository's job): move Community's forum tables-- into `ov_space`, 301 `/s/*` on Community to Space, drop Community's forum tables and the-- deprecated `community.*` forum capabilities, and remove the Sites placeholder for openvibe.space.--- **Contracts**: v0.110.0 registers Space live and its capabilities active, and publishes the-- `space.thread.created` / `space.post.created` / `space.moderation.action` event payloads, so the-- outbox only waits on `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET`; `space.thread.write` and-- `space.pulse.read` stay planned (`space.post.write` carries thread writes, Community serves Pulse).--- **Search**: Space pushes no Search documents yet (there is no `space.index_document.*` contract),-- so `/search` on every site does not see forum threads; there is no `/search` page here.--- **Pulse**: forum activity no longer publishes to the network Pulse feed. Restoring it belongs to-- Community's side (its consumer subscribing to `space.thread.*`) or to a `community.pulse.write`-- grant Space could use.--- **Paste cards on posts**: Community's forum let a post attach pastes; pastes are Community's, so-- Space posts do not carry them. If the feature is wanted, it needs a cross-service read of-- Community's paste metadata (a decision, not code that exists).++Code hosting (repositories, releases) and dynamic pages, then "spaces": apps people build on their++OpenVibe account, the network's services and the SDK.## Related services--- **OpenVibe.Community** (https://openvibe.community) — the hub: pastes, comment threads, Pulse, submissions--- **OpenVibe.Network** (https://openvibe.network) — identity, SSO and the shared chrome--- **OpenVibe.Media** (https://openvibe.media) — objects and image bytes--- **OpenVibe.VIP** (https://openvibe.vip) — memberships and the members-only gate--- **OpenVibe.Chat** (https://openvibe.chat) — a space's chat room--- **OpenVibe.Events** (https://openvibe.events) — the events fabric++- Identity/SSO: https://openvibe.network (OpenVibers/OpenVibe.Network)++- The forum and the hub: https://openvibe.community (OpenVibers/OpenVibe.Community)++- Streaming: https://openvibe.live (OpenVibers/OpenVibe.Live)++- Tools: https://openvibe.tools (OpenVibers/OpenVibe.Tools)++- Media: https://openvibe.media (OpenVibers/OpenVibe.Media)## LicenseAGPL-3.0-only. See [LICENSE](LICENSE).<!-- versions:start -->--- openvibe-contracts: v0.112.0++- openvibe-contracts: v0.118.0- openvibe-sdk: v0.35.0- openvibe-shared: v2.15.0<!-- versions:end -->@@@ -2,42 +2,30 @@"repository": "OpenVibers/OpenVibe.Space","stage": "alpha","code": true,-- "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19), T10 step 3 — the forum moved off OpenVibe.Community",-- "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19) — the forum moved back to OpenVibe.Community (owner decision, 2026-10-08); Space becomes code hosting and spaces",++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)","port": 4940,"domain": "openvibe.space",-- "contracts": "openvibe-contracts v0.112.0",++ "contracts": "openvibe-contracts v0.118.0","sdk": "openvibe-sdk v0.35.0","shared": "openvibe-shared v2.15.0",-- "capabilities": [-- "space.forum.read",-- "space.forum.manage",-- "space.thread.read",-- "space.post.write"-- ],-- "capabilitiesRegistered": true,++ "capabilities": [],++ "capabilitiesRegistered": false,"deployed": false,-- "deployment": "Not deployed yet (T10 step 3 built the service; step 4 is the cutover). The Host inventory entry, the deploy recipe, the Contracts flip (space.json status/exposure live, space.* activated) and the Sites placeholder removal for openvibe.space are the deploy job's. Unit deploy/systemd/openvibe-space.service on 127.0.0.1:4940, vhost deploy/nginx/openvibe.space.conf, database ov_space via OpenVibe.Host roles/data add-service.sh space. Production's forum tables in ov_community are empty except the migration seeds, so the data cutover moves only the schema's rows.",++ "deployment": "Not deployed: Space publishes nothing yet. Unit deploy/systemd/openvibe-space.service on 127.0.0.1:4940, vhost deploy/nginx/openvibe.space.conf, database ov_space via OpenVibe.Host roles/data add-service.sh space. Every forum URL (/s and /s/*, /api/v1/spaces|posts|space-groups|relay) answers a permanent redirect to the same path and query on https://openvibe.community. The forum tables stay in ov_space; this release writes no drop migration.","features": [-- "spaces: public/members/staff visibility, feed and forum styles, groups, child boards, per-space settings",-- "threads and posts: Markdown (safe renderer), edits with versions, soft deletes, pins and locks",-- "thread votes, Facepunch-style post ratings, categories, statuses, crossposts and quotes",-- "per-space moderators (space_moderators), addable by @username through the Network",-- "image attachments as OpenVibe.Media objects (med_), at most four per post",-- "members-only spaces and threads gated by OpenVibe.VIP (fail closed)",-- "a space's chat room on OpenVibe.Chat, attached with the person's own token",-- "the Discord relay: outbound webhooks, the Events worker, and inbound through the gateway",-- "blocks, token cutoffs, account export/deletion and subject merge from Network events",-- "service capabilities space.forum.read/manage, space.thread.read, space.post.write",-- "server-rendered pages with full SEO (robots, llms.txt, sitemap, RSS, IndexNow)",-- "per-actor limits (openvibe-sdk/limits) and per-person write limits",++ "the home page: what Space will host (code, dynamic pages, \"spaces\" apps) and where the forum went",++ "permanent forum redirects: /s and /s/* (301 GET/HEAD, 308 otherwise) and the forum APIs (308) to openvibe.community",++ "sign-in: the Network OAuth2 client `space` (login, callback, FedCM, logout, me, refresh)",++ "discovery: robots.txt, llms.txt, llms-full.txt, sitemap.xml (the home page alone)",++ "health and release: /api/health, /api/ready, /release.json, /metrics (loopback)","the process boots on a fresh database and stops cleanly (test/boot.test.js)"],"knownGaps": [-- "space.thread.write and space.pulse.read stay 'planned' in openvibe-contracts and Space serves neither; space.post.write carries thread and reply writes and community serves the network Pulse feed",-- "no Search documents (no space.index_document.* contract) and no /search page",-- "the network Pulse feed is Community's: Space publishes no Pulse item and renders no pulse page",-- "paste cards on forum posts were dropped with the move (pastes are Community's)"++ "code hosting and dynamic pages are not built yet, and no \"spaces\" apps run here; the home page says so",++ "openvibe-contracts still registers space.forum.read/manage, space.thread.read and space.post.write for Space; the Contracts update that retires or reassigns them is a separate change",++ "the forum tables remain in ov_space (migrations 0001+ still create them) with their rows; no drop migration in this release",++ "Space consumes no network events and has no account export/deletion: it holds no user content, and the forum's account data is Community's"],"updated": "2026-10-08"}@@@ -4,11 +4,12 @@## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf## — keep them in sync, and reload nginx after changing it.##--## The Node app (port 4940, systemd unit openvibe-space) renders every page:--## the board index and the spaces at /s/*, the JSON API at /api/v1/spaces|posts|space-groups|relay,--## and the internal Events delivery route at /internal/events (loopback only — it refuses forwarded--## requests itself). nginx terminates TLS, redirects www → apex, rate-limits the auth and API paths,--## and compresses text.++## The Node app (port 4940, systemd unit openvibe-space) renders the site: the home page, sign-in++## (/auth/*), the discovery files (/robots.txt, /llms.txt, /sitemap.xml) and health/readiness.++## The forum is OpenVibe.Community's again (owner decision, 2026-10-08): Node answers every forum URL++## — /s and /s/*, /api/v1/spaces|posts|space-groups|relay — with a permanent redirect to the same path++## there, so nginx just proxies. nginx terminates TLS, redirects www → apex, rate-limits the auth and++## API paths, and compresses text.## ═══════════════════════════════════════════════════════════════limit_req_zone $binary_remote_addr zone=ovspace_api:10m rate=10r/s;@@@ -52,7 +53,6 @@ server {access_log /var/log/nginx/openvibe.space.access.log;error_log /var/log/nginx/openvibe.space.error.log;-- # Post bodies are Markdown and nothing else; images go to OpenVibe.Media, never through here.client_max_body_size 1m;add_header X-Content-Type-Options nosniff always;@@@ -96,23 +96,9 @@ server {proxy_set_header X-Forwarded-Proto $scheme;}-- # ── Image uploads (multipart, streamed on to OpenVibe.Media) ──-- location ~ ^/api/v1/spaces/[^/]+/attachments$ {-- limit_req zone=ovspace_api burst=5 nodelay;-- client_max_body_size 9m;-- proxy_request_buffering off;-- proxy_read_timeout 120s;-- proxy_send_timeout 120s;-- proxy_pass http://127.0.0.1:4940;-- proxy_http_version 1.1;-- proxy_set_header Host $host;-- proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $remote_addr;-- proxy_set_header CF-Connecting-IP $remote_addr;-- proxy_set_header X-Forwarded-Proto $scheme;-- }--# ── API (moderate rate limit) ───────────────────────────++ # Space serves only /api/health and /api/ready here; the forum APIs are permanent redirects to++ # OpenVibe.Community, and a redirected request should reach it with its method and body intact.location /api/ {limit_req zone=ovspace_api burst=30 nodelay;proxy_pass http://127.0.0.1:4940;@@@ -1,9 +1,9 @@[Unit]--Description=OpenVibe.Space — the forums of the OpenVibe network--# Network (identity) should be up first, but Space degrades gracefully if it is not: public pages and--# reads still work, sign-in and signed-in writes answer 503 (server/observability.js).--After=network.target postgresql.service pgbouncer.service valkey-server.service--Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++Description=OpenVibe.Space — code and dynamic pages for the OpenVibe network++# Network (identity) should be up first, but Space degrades gracefully if it is not: the home page and++# the discovery files still work, sign-in is unavailable (server/observability.js).++After=network.target postgresql.service pgbouncer.service++Wants=openvibe-network.service postgresql.service pgbouncer.service[Service]Type=simpleddeleted file mode 100644@@@ -1,164 +0,0 @@--# Discord relay----Space threads on Discord, and replies from Discord back as posts (roadmap WS-J tasks 5 and 6).--Everything is **off by default** and stays inert until the owner provides webhooks, a mapping and,--for the inbound half, a bot token.----## What it does----**Out** (`DISCORD_RELAY_ENABLED=true` plus a mapping):----- A new thread in a mapped **public** space is announced through the mapping's Discord webhook:-- "New thread in s/<space> by <name>", the title, an excerpt of the opening post and the link.--- Each reply to a thread that went to Discord follows it into the same channel (or into the-- Discord thread the mapping names), with who replied, an excerpt and the link to the post.--- Editing the opening post edits the thread's Discord message; editing a reply edits its message.--- Deleting a reply or a thread (by its author or a moderator), or making a thread or space-- members-only (VIP), deletes the Discord messages the relay had sent for it. Anything still-- waiting to be sent is skipped.--- Mentions are disabled on every message (`allowed_mentions.parse = []`); names and titles are-- escaped, so nothing relayed can ping anyone.----**In** (`DISCORD_RELAY_INBOUND=on`, `DISCORD_BOT_TOKEN`, and `inbound: true` on the mapping):----- A reply on Discord to a relayed message (Discord's Reply), or any message in a Discord thread-- started from a relayed message, becomes a post in that Space thread. Other messages in-- the channel are ignored.--- The post has origin `discord`, belongs to nobody on the site (no author subject) and shows the-- Discord display name with a "Discord" badge. It is never relayed back out.--- Edits on Discord edit the post (a new revision, `edited_by` `discord`); deletes on Discord-- delete it. Deleting the relay's own announcement on Discord only marks it gone; the thread stays.--- The thread must still be public, open (not locked) and not members-only; otherwise the-- message is refused and the reason is recorded for staff.--- Limits: `DISCORD_RELAY_INBOUND_PER_MINUTE` (6) messages a minute per Discord author per-- mapping, ten times that per mapping; `DISCORD_RELAY_INBOUND_MAX_CHARS` (4000) per message,-- longer text is cut. User, role and channel mentions, custom emoji and timestamps become plain-- text, `@everyone`/`@here` are defused, attachments are noted ("2 attachments on Discord") but-- never fetched. The rest is Markdown, which the forum's safe renderer shows (no raw HTML).----## How it works----| Piece | File | Notes |--| --- | --- | --- |--| Queue and sender | `server/relay/discord.js` | `relay_deliveries`: one row per dedupe key, actions `create`, `edit`, `delete`. Creates are sent with `?wait=true`, so Discord answers with the message and its id goes into the map. |--| External message map (task 5) | `relay_message_map` | Every Discord message the relay knows, both ways (`out`: a thread or reply it sent; `in`: a reply that came from Discord). Unique both ways: a Discord message is one local object, and a local object is one message per mapping. Edits and deletes on either side find their counterpart here. |--| Events worker (task 6) | `server/relay/events-worker.js` | Reads Space's own `space.thread.*` and `space.post.*` from OpenVibe.Events (`GET /api/v1/events`, capability `events.event.read`) and queues the creates. Its cursor (`relay_cursors`) moves in the same transaction as the deliveries a page queued; a replay queues nothing twice (dedupe keys), and nothing already in the map is sent twice. The first start begins at Events' head: older threads are never announced. |--| Gateway | `server/relay/discord-gateway.js` | Discord gateway v10 over Node 22's built-in WebSocket: IDENTIFY/RESUME, heartbeats (a missing ACK means a zombied connection: resume), RECONNECT, INVALID_SESSION, backoff. Intents `GUILD_MESSAGES` and `MESSAGE_CONTENT`. |--| Inbound | `server/relay/inbound.js` | `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `MESSAGE_DELETE_BULK` for mapped channels only. |--| Staff API | `server/relay/api.js` | `/api/v1/relay/*`, below. |----When the Events worker is on, it is the one path for creates (the forum's own calls queue none).--Without `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET`, or with `DISCORD_RELAY_EVENTS=off`, the forum--queues creates itself, as the first relay did. Edits and deletes are always queued by the forum:--Contracts has no `space.thread`/`space.post` update or delete events yet.----**Loop prevention**, both halves: a thread or post with origin `discord` is never queued or sent--out (checked when queueing and again when sending); inbound ignores webhook messages (the--relay's own among them), bots, the bot itself and system messages, and never takes a message--that is already in the map.----**Retries and the dead letter**: network errors, timeouts, 5xx, 429 (its `retry_after`--honoured; the rest of that webhook's queue waits for the next pass) and an unset webhook variable--are retried with exponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour)--up to `DISCORD_RELAY_MAX_ATTEMPTS`. Then the delivery is `failed`: the dead letter, kept with its--error, that staff retry or drop. Other 4xx fail at once. A delete that finds the message already--gone on Discord counts as delivered; an edit of a message that is gone is skipped.----**Secrets never live in the database**: a mapping names the environment variable holding the--webhook URL (`webhook_url_ref`), and only allow-listed names (`DISCORD_WEBHOOK_*`, or exactly--`DISCORD_RELAY_WEBHOOK_VARS`) can be mapped. No API response carries a webhook URL or the bot token.----## Staff API----Staff only: an admin/global_mod browser session, or a service holding `space.forum.manage`.----| Route | What |--| --- | --- |--| `GET /api/v1/relay/status` | The queue by status (`failed` = dead letters), the Events worker (cursor, latest seq, lag, last error, last gap), the gateway (state, reconnects, last error) and open inbound failures |--| `GET /api/v1/relay/deliveries?status=&action=&limit=` | Deliveries; `status` pending, delivered, failed, dropped or skipped; `action` create, edit or delete |--| `POST /api/v1/relay/deliveries/:id/retry` | Queue it again now, with a fresh attempt budget |--| `POST /api/v1/relay/deliveries/:id/drop` | Give up on a pending or failed one (kept as `dropped`) |--| `GET /api/v1/relay/inbound?all=1` | Messages from Discord that did not become (or change) a post, and why: ids only, never the text |--| `POST /api/v1/relay/inbound/:id/dismiss` | Mark one seen |--| `GET /api/v1/relay/mappings` | Mappings: space, webhook variable (and whether it is set), Discord channel and thread ids, inbound |--| `POST /api/v1/relay/mappings` | `{ space, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? }` |--| `PUT /api/v1/relay/mappings/:id` | `{ enabled?, inbound?, discord_channel_id?, discord_thread_id? }` (`null` clears an id) |----`GET /api/ready` shows the same summary under `discord_relay` while the relay is on.----## Owner steps----1. **Webhook per channel** (outbound). In Discord: the channel's settings → Integrations →-- Webhooks → New Webhook, name it (for example "OpenVibe.Space"), Copy Webhook URL. You need-- Manage Webhooks on that channel to do this; the bot does not.--2. **The bot** (inbound only). At <https://discord.com/developers/applications>: New Application →-- Bot → Reset Token (this is `DISCORD_BOT_TOKEN`). Under Privileged Gateway Intents turn on-- **Message Content Intent** (Presence and Server Members stay off). Invite it: OAuth2 → URL-- Generator → scope `bot`, permissions **View Channels** and **Read Message History** (permission-- integer `66560`). It sends nothing itself (the webhook does), so leave Send Messages and Manage-- Webhooks off. It must be able to see each mapped channel, and threads in it (public threads only).--3. **Environment** in `/etc/openvibe/space.env` (0600):---- ```sh-- DISCORD_RELAY_ENABLED=true-- DISCORD_WEBHOOK_GENERAL=https://discord.com/api/webhooks/<id>/<token> # one per channel, any DISCORD_WEBHOOK_* name-- # inbound-- DISCORD_RELAY_INBOUND=on-- DISCORD_BOT_TOKEN=<the bot token>-- ```---- `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are already set for the outbox, so the Events worker-- starts by itself. It needs the Network grant `space` → `events.event.read` on audience-- `openvibe.events` (in the Network's principal seed; check it is provisioned: a missing grant-- shows as a 403 in the worker's `last_error`).-- Then `sudo systemctl restart openvibe-space`. The boot log says-- `[Relay] Discord relay on: creates queued by the Events worker; events worker on; inbound on`.--4. **Map a space**, signed in as an admin on openvibe.space, from the browser console:---- ```js-- await (await fetch('/api/v1/relay/mappings', { method: 'POST', headers: { 'content-type': 'application/json' },-- body: JSON.stringify({ space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true }) })).json();-- ```---- `discord_channel_id` is learned from Discord's answer to the first message; set it yourself-- (Discord: Settings → Advanced → Developer Mode, then right-click the channel → Copy Channel ID)-- if you want replies accepted before anything was sent. To post into one Discord thread instead-- of the channel, add `discord_thread_id`. Only public spaces are ever relayed.--5. **One production round trip**: start a thread in the mapped space → it appears on Discord;-- reply to it on Discord (Reply, or in a thread on the message) → the reply appears in the-- thread with a Discord badge; edit and delete it on Discord → the post follows; reply and edit-- on the site → Discord follows; delete the thread → its messages leave Discord. Watch-- `GET /api/v1/relay/status` (no dead letters, lag 0, gateway `ready`).----**Off again**: unset `DISCORD_RELAY_ENABLED` and restart (nothing is sent or read; the tables stay),--or per mapping `PUT /api/v1/relay/mappings/:id { "enabled": false }` (deletes still go out) or--`{ "inbound": false }`. Unset `DISCORD_RELAY_INBOUND` to close the gateway only.----## Configuration----| Variable | Default | What |--| --- | --- | --- |--| `DISCORD_RELAY_ENABLED` | off | The relay at all |--| `DISCORD_WEBHOOK_*` | — | Webhook URLs, named by mappings |--| `DISCORD_RELAY_WEBHOOK_VARS` | — | Exact variable names mappings may use (instead of `DISCORD_WEBHOOK_*`) |--| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Sender cadence, first retry delay, attempts before the dead letter |--| `DISCORD_RELAY_EVENTS` | on | `off` leaves creates to the forum instead of the Events worker |--| `DISCORD_RELAY_EVENTS_POLL_MS` | `5000` | How often the worker reads Events |--| `EVENTS_URL`, `OV_OAUTH_CLIENT_SECRET` | — | Events' address and Space's client secret (shared with the outbox) |--| `DISCORD_RELAY_INBOUND` | off | The gateway |--| `DISCORD_BOT_TOKEN` | — | The bot's token (inbound only) |--| `DISCORD_GATEWAY_URL` | `wss://gateway.discord.gg/?v=10&encoding=json` | The gateway |--| `DISCORD_RELAY_INBOUND_PER_MINUTE` | `6` | Messages a minute per Discord author per mapping (10× per mapping) |--| `DISCORD_RELAY_INBOUND_MAX_CHARS` | `4000` | Longest message taken in; longer is cut |----## Known limits----- Messages sent on Discord while Space is restarting (or the gateway is down longer than a-- resume allows) are not replayed: a new session starts from now.--- Private Discord threads, attachments (either way), reactions and Discord-side thread titles are-- not relayed.--- Threads created before the Events worker's first start are never announced, and replies go-- out only for threads that went out through that mapping.--- Edits made by the Roadmap sync (`docs/roadmap/public.json`) are not propagated to Discord.--- A retention gap in Events (the worker fell further behind than Events keeps) skips the creates-- in it; the gap is logged and shown in the status.ddeleted file mode 100644@@@ -1,41 +0,0 @@--{-- "about": "OpenVibe's public roadmap: the plain-language summary of what is being built next, synced into the Roadmap space on openvibe.space at every boot (server/forum/roadmap.js). Edit this file to change the roadmap; each item keeps its thread (by key), so replies and votes stay with it.",-- "items": [-- { "key": "openvibe-chat", "category": "launches", "status": "done", "title": "OpenVibe.Chat, on its own site",-- "summary": "Global chat, direct messages, rooms and your chat settings at [openvibe.chat](https://openvibe.chat), working with or without JavaScript and even while Live is down. Opened on 24 September 2026; rooms followed on 25 September." },-- { "key": "openre-stream", "category": "launches", "status": "in_progress", "title": "OpenRe.Stream: stream once, restream everywhere",-- "summary": "Send your stream to one ingest and let OpenRe.Stream forward it to every platform, with keys, destinations and output health in one place. The service is running; moving Live's stream slots onto it needs the new ingest address and a short switch-over window for each broadcaster." },-- { "key": "openvibe-tips-vip", "category": "launches", "status": "planned", "title": "OpenVibe.Tips and OpenVibe.VIP",-- "summary": "Tips, goals and paid messages for creators, and creator memberships with perks recognised on every site. Both are built; they open once all money moves through the network's billing ledger, so every tip and membership is recorded exactly once." },-- { "key": "openvibe-news-reviews", "category": "launches", "status": "planned", "title": "OpenVibe.News and OpenVibe.Reviews",-- "summary": "Source-backed stories with citations and several perspectives, and reviews gathered with their sources. They open when their source lists have been reviewed: nothing is published without a source." },-- { "key": "openvibe-deals-coupons", "category": "launches", "status": "planned", "title": "OpenVibe.Deals and OpenVibe.Coupons",-- "summary": "Community deals with the source, price and freshness always shown, and coupon codes checked by the people who use them." },-- { "key": "openvibe-trade", "category": "launches", "status": "planned", "title": "OpenVibe.Trade: watchlists and alerts",-- "summary": "Watchlists, sourced market context and alerts. Information only: no custody and no orders." },-- { "key": "openvibe-host", "category": "launches", "status": "planned", "title": "OpenVibe.Host",-- "summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },-- { "key": "chat-calls", "category": "features", "status": "planned", "title": "Voice and video calls in OpenVibe.Chat",-- "summary": "Calls move from Live into OpenVibe.Chat, so rooms and direct messages can turn into a call on any site." },-- { "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts",-- "summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },-- { "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",-- "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },-- { "key": "forum-boards", "category": "features", "status": "done", "title": "Forum boards, ratings and pastes in posts",-- "summary": "Spaces can be classic forum boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },-- { "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",-- "summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },-- { "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",-- "summary": "Set a style, a length and a perspective once on your account, and OpenVibe AI follows them whenever it writes something for you. Turn off \"Keep what I ask AI\" and it keeps neither your request nor a reusable copy of the answer." },-- { "key": "favourite-tools", "category": "features", "status": "done", "title": "Favourite tools on every device",-- "summary": "Star a tool on openvibe.tools or on your account page, and it leads your tools everywhere you sign in." },-- { "key": "sign-out-everywhere", "category": "platform", "status": "done", "title": "Sign out everywhere means everywhere",-- "summary": "Signing out everywhere, changing your password or a ban now reaches every OpenVibe site within seconds: older sign-ins stop working and open chat and game connections close." },-- { "key": "channel-renames", "category": "platform", "status": "done", "title": "Rename your channel without breaking links",-- "summary": "When you change your username, links to your old channel address forward to the new one, and nobody else can take your old name for six months." },-- { "key": "media-reliability", "category": "platform", "status": "in_progress", "title": "Every VOD and clip checked and recoverable",-- "summary": "Recordings and clips are stored in more than one place and checked regularly. A video now plays only once a stored copy has been checked, holds keep evidence from being deleted or moved (clips follow their recording), and a monthly report lists stray files without touching them. Still to come: splitting very long recordings into parts, and repairing the few copies known to be damaged." },-- { "key": "discord-relay", "category": "platform", "status": "planned", "title": "Community threads on Discord, and back",-- "summary": "New threads are already announced on Discord. Next: replies both ways, edits and deletions kept in step, with no loops." }-- ]--}@@@ -14,10 +14,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "highlight.js": "^11.11.1",-- "iovalkey": "^0.4.0",-- "multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"@@@ -40,7 +37,9 @@"version": "0.1.0","resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz","integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/accepts": {"version": "1.3.8",@@@ -88,12 +87,6 @@}}},-- "node_modules/append-field": {-- "version": "1.0.0",-- "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",-- "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",-- "license": "MIT"-- },"node_modules/array-flatten": {"version": "1.1.1","resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",@@@ -130,17 +123,6 @@"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==","license": "BSD-3-Clause"},-- "node_modules/busboy": {-- "version": "1.6.0",-- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",-- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",-- "dependencies": {-- "streamsearch": "^1.1.0"-- },-- "engines": {-- "node": ">=10.16.0"-- }-- },"node_modules/bytes": {"version": "3.1.2","resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",@@@ -184,6 +166,8 @@"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz","integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"engines": {"node": ">=0.10.0"}@@@ -251,6 +235,8 @@"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz","integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"engines": {"node": ">=0.10"}@@@ -582,15 +568,6 @@"url": "https://github.com/sponsors/EvanHahn"}},-- "node_modules/highlight.js": {-- "version": "11.12.0",-- "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",-- "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",-- "license": "BSD-3-Clause",-- "engines": {-- "node": ">=12.0.0"-- }-- },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -634,6 +611,8 @@"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz","integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"@iovalkey/commands": "^0.1.0","cluster-key-slot": "^1.1.0",@@@ -654,6 +633,8 @@"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz","integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"ms": "^2.1.3"},@@@ -670,7 +651,9 @@"version": "2.1.3","resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz","integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/ipaddr.js": {"version": "1.9.1",@@@ -740,7 +723,9 @@"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz","integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/lodash.includes": {"version": "4.3.0",@@@ -752,7 +737,9 @@"version": "3.1.0","resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz","integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/lodash.isboolean": {"version": "3.0.3",@@@ -865,24 +852,6 @@"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==","license": "MIT"},-- "node_modules/multer": {-- "version": "2.4.0",-- "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",-- "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",-- "license": "MIT",-- "dependencies": {-- "append-field": "^1.0.0",-- "busboy": "^1.6.0",-- "type-is": "^1.6.18"-- },-- "engines": {-- "node": ">= 10.16.0"-- },-- "funding": {-- "type": "opencollective",-- "url": "https://opencollective.com/express"-- }-- },"node_modules/negotiator": {"version": "0.6.3","resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",@@@ -917,9 +886,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.112.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",-- "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",++ "version": "0.118.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",++ "integrity": "sha512-vd5OzSIGhGB9ezy/SDHolji+jCJIxp1SmaKkE01KbiManwOnMdM6FOX3LIEVldXCBTjWH77iMkqB5sCxeY+qiQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1178,6 +1147,8 @@"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz","integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=4"}@@@ -1187,6 +1158,8 @@"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz","integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"redis-errors": "^1.0.0"},@@@ -1377,7 +1350,9 @@"version": "2.1.0","resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz","integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/statuses": {"version": "2.0.2",@@@ -1388,14 +1363,6 @@"node": ">= 0.8"}},-- "node_modules/streamsearch": {-- "version": "1.1.0",-- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",-- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",-- "engines": {-- "node": ">=10.0.0"-- }-- },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -2,7 +2,7 @@"name": "openvibe-space","version": "0.1.0","private": true,-- "description": "OpenVibe.Space — the forums of the OpenVibe network: public and members-only spaces, threads, posts, votes, categories, per-space moderators, a space's chat room on OpenVibe.Chat and the Discord relay. The forum engine moved here from OpenVibe.Community (plan T10 step 3); identity stays on OpenVibe.Network, images on OpenVibe.Media, memberships on OpenVibe.VIP.",++ "description": "OpenVibe.Space — code and dynamic pages for the OpenVibe network, and \"spaces\": apps people build that use their OpenVibe account, the network's services and the SDK. The forum moved back to OpenVibe.Community (owner decision, 2026-10-08); every forum URL here redirects there.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -13,10 +13,9 @@"keywords": ["openvibe","space",-- "forum",-- "forums",-- "boards",-- "community"++ "code",++ "pages",++ "apps"],"author": "OpenVibers","license": "AGPL-3.0-only",@@@ -27,10 +26,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "highlight.js": "^11.11.1",-- "iovalkey": "^0.4.0",-- "multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"@@@ -1,262 +1,51 @@--/* OpenVibe.Space — small, theme-following stylesheet.-- Colors come from the shared theme tokens set by theme-loader.js; the fallbacks are the-- network's default (blue "vibe") palette so the page looks right before it loads. */--:root {-- --bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;-- --text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;-- --accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;-- --border: #1f2d47; --border-light: #2c3d5c;-- --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;-- --shadow: 0 2px 12px rgba(0,0,0,0.45); --shadow-lg: 0 8px 32px rgba(0,0,0,0.6);-- --radius: 12px; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;--}--* { box-sizing: border-box; }--html { color-scheme: var(--color-scheme, dark); scroll-behavior: smooth; }--body { margin: 0; background: var(--bg-primary); color: var(--text-primary); font: 15px/1.55 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }--a { color: var(--accent-light); text-decoration: none; }--a:hover { text-decoration: underline; }--h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 .4em; }--h1 { font-size: clamp(1.7rem, 3.5vw, 2.4rem); }--h2 { font-size: 1.35rem; }--h3 { font-size: 1.05rem; }--img { max-width: 100%; height: auto; }--.page { max-width: 1120px; margin: 0 auto; padding: 24px 16px 48px; }--.muted { color: var(--text-secondary); }--.small { font-size: .85rem; }--.empty { color: var(--text-muted); padding: 32px 0; text-align: center; }--.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .75rem; font-weight: 700; color: var(--accent-light); margin: 0 0 8px; }--.lede { font-size: 1.1rem; color: var(--text-secondary); max-width: 46rem; }----/* Buttons */--.btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-card); color: var(--text-primary); font: inherit; font-weight: 600; cursor: pointer; transition: border-color .15s, background .15s, transform .1s; }--.btn:hover { border-color: var(--accent); text-decoration: none; background: var(--bg-hover); }--.btn:active { transform: translateY(1px); }--/* The derived pair reads at 4.5:1 in every theme (Shared 1.13.0); white on the accent did not. */--.btn-primary { background: var(--accent-strong, #1d4ed8); border-color: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); }--.btn-primary:hover { background: var(--accent-dark, var(--accent)); border-color: var(--accent-dark, var(--accent)); }--.btn-ghost { background: transparent; }--.btn-danger { border-color: var(--danger); color: var(--danger); }--.btn-danger:hover { background: var(--danger); color: #fff; }--.btn-sm { padding: 6px 11px; font-size: .85rem; border-radius: 7px; }--.btn[disabled] { opacity: .6; cursor: wait; }----/* Sections */--.section { padding: 28px 0 8px; }--.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }--.section-head h2 { margin: 0; }--.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }----.author { display: inline-flex; align-items: center; gap: 6px; color: inherit; min-width: 0; }--.author span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }--.avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: none; }--.avatar-letter { display: inline-grid; place-items: center; background: var(--accent); color: var(--on-accent); font-size: .7rem; font-weight: 700; }--.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; border: 1px solid var(--border-light); color: var(--text-secondary); }--a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-light); }--.stat { display: inline-flex; align-items: center; gap: 4px; }----.page-head { padding: 16px 0 8px; }--.page-head h1 { margin-bottom: 4px; }--.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 22px 0 0; }--.pager a { font-weight: 600; }--.pager-info { color: var(--text-muted); font-size: .9rem; }--[data-results].loading { opacity: .5; pointer-events: none; }----.crumbs { font-size: .85rem; color: var(--text-muted); margin: 4px 0 14px; }--/* Underlined: a crumb link sits in muted text and must not be told apart by colour alone (axe link-in-text-block). */--.crumbs a { text-decoration: underline; text-underline-offset: 2px; }--.sep { color: var(--text-muted); }--.actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }----/* Syntax colors follow the theme instead of shipping a fixed palette. */--.hljs { color: var(--text-primary); }--.hljs-comment, .hljs-quote, .hljs-meta { color: var(--text-muted); font-style: italic; }--.hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-section, .hljs-name { color: var(--accent-light); font-weight: 600; }--.hljs-string, .hljs-regexp, .hljs-addition, .hljs-template-tag { color: var(--success); }--.hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--warning); }--.hljs-title, .hljs-title.function_, .hljs-title.class_, .hljs-type, .hljs-built_in, .hljs-selector-id, .hljs-selector-class { color: var(--info); }--.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-property, .hljs-params { color: var(--text-secondary); }--.hljs-deletion { color: var(--danger); }--.hljs-emphasis { font-style: italic; }--.hljs-strong { font-weight: 700; }++/* OpenVibe.Space — the home page and error pages. The shared Frame (openvibe-shared/shell) brings the++ theme variables, navbar and footer; this is only what this site renders itself. */--/* Forms */--.post-form { display: flex; flex-direction: column; gap: 14px; max-width: 900px; }--.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }--.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }--.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }--.field textarea { font-family: var(--mono); font-size: 13px; line-height: 1.5; min-height: 160px; resize: vertical; }--.field :focus { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 0; border-color: var(--accent); }--.field-row { display: flex; flex-wrap: wrap; gap: 14px; }--.check { display: inline-flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: .9rem; }--.tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--border); }--.tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-secondary); padding: 8px 12px; font: inherit; font-weight: 600; cursor: pointer; }--.tab.active { color: var(--accent-light); border-bottom-color: var(--accent); }--.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }--.form-status { min-height: 1.2em; margin: 0; color: var(--text-secondary); }--.form-status.error { color: var(--danger); }--.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }--.alert-error { border-color: var(--danger); color: var(--danger); }----/* Spaces, threads, posts */--.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }--.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }--.space-item h2 { margin: 0 0 4px; font-size: 1.15rem; }--.space-item p { margin: 4px 0 0; }--.space-link:hover { text-decoration: none; }--.sort-tabs { margin: 8px 0 14px; }--.sort-tabs .tab { text-decoration: none; }--.thread-row { display: flex; gap: 14px; align-items: flex-start; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; }--.thread-score { min-width: 2.6em; text-align: center; font-weight: 700; color: var(--text-secondary); padding-top: 2px; }--.thread-main { min-width: 0; flex: 1; }--.thread-title { font-weight: 600; color: var(--text-primary); word-break: break-word; }--.thread-title i { color: var(--accent-light); font-size: .85em; }--.thread-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin: 4px 0 0; font-size: .82rem; color: var(--text-secondary); }--.badge-ai { border-color: var(--info); color: var(--info); }--.badge-relay { border-color: var(--accent-light); color: var(--accent-light); }--.badge-vis { font-size: .7rem; vertical-align: middle; }--/* Categories, requests and the roadmap (forum pages) */--.badge-status, .badge-category { font-size: .7rem; vertical-align: middle; }--.badge-category { color: var(--text-secondary); }--.badge-category:hover { border-color: var(--accent); text-decoration: none; }--.status-planned, .status-open { border-color: var(--info); color: var(--info); }--.status-in_progress { border-color: var(--warning); color: var(--warning); }--.status-done { border-color: var(--success); color: var(--success); }--.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }--.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }--.post-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-top: 10px; }--.post-images img { display: block; width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; border: 1px solid var(--border-light); background: var(--bg-secondary, transparent); }--.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }--.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: .85rem; font-weight: 600; }--.chip:hover { border-color: var(--accent); text-decoration: none; }--.chip.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }--.chip.active .muted { color: inherit; opacity: .8; }--/* The members-only (OpenVibe.VIP) teaser page and the badge that marks a gated space or thread. */--.members-only { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 22px; margin: 18px 0; }--.badge-vip { border-color: var(--warning); color: var(--warning); }--.inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }--.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }--.thread-head { display: flex; gap: 16px; align-items: flex-start; }--.thread-head h1 { word-break: break-word; }--.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }--.vote-score { font-weight: 700; }--.vote-btn { background: none; border: 1px solid var(--border-light); border-radius: 7px; color: var(--text-secondary); width: 2.2em; height: 2em; cursor: pointer; font: inherit; }--.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }--.vote-btn.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }--.mod-actions form { margin: 0; }--.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }--.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }--.post-opening { border-color: var(--border-light); }--.post-deleted { background: transparent; border-style: dashed; padding: 8px 18px; }--.post-deleted p { margin: 0; }--.post-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: .88rem; color: var(--text-secondary); }--.post-num { margin-left: auto; }--.md { overflow-wrap: anywhere; }--.md > :first-child { margin-top: .6em; }--.md > :last-child { margin-bottom: 0; }--.md blockquote { margin: .6em 0; padding: 4px 14px; border-left: 3px solid var(--border-light); color: var(--text-secondary); }--.md code { font: .9em var(--mono); background: var(--bg-input); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; }--.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }--.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }--.md hr { border: 0; border-top: 1px solid var(--border); }--.reply-form { margin-top: 18px; }++.page {++ max-width: 60rem;++ margin: 0 auto;++ padding: 4rem 1.25rem 6rem;++}--/* Errors + footer host */--.error { text-align: center; padding: 60px 0; }--.error .btn { margin: 4px; }--.ov-footer { margin-top: 24px; }++.hero h1 {++ font-size: clamp(1.75rem, 4vw, 2.75rem);++ line-height: 1.15;++ margin: 0.25rem 0 1rem;++}--@media (max-width: 640px) {-- .page { padding: 16px 16px 40px; }-- .thread-row { flex-wrap: wrap; }-- .thread-head { gap: 10px; }++.eyebrow {++ text-transform: uppercase;++ letter-spacing: 0.08em;++ font-size: 0.75rem;++ font-weight: 600;++ opacity: 0.7;++ margin: 0;}--/* ── Forum style: board index, topic tables, author panels (render/board.js) ── */--.board-group { margin: 22px 0; }--.board-group-h { font-size: 1rem; text-transform: uppercase; letter-spacing: .06em; color: var(--accent-light); margin: 0 0 6px; }--.board-group-p { margin: 0 0 8px; }--.board-table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }--.board-table th.num { width: 6.5em; }--.board-table th:last-child { width: 16em; }--.topic-table th.topic-icon { width: 2.6em; }--.board-table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary); padding: 8px 12px; border-bottom: 1px solid var(--border-light); background: var(--bg-hover); }--.board-table td { padding: 10px 12px; border-top: 1px solid var(--border-light); vertical-align: top; }--.board-table tbody tr:first-child td { border-top: 0; }--.board-table .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; width: 5.5em; }--.board-name { display: flex; gap: 12px; align-items: flex-start; }--.board-name > div { min-width: 0; }--.board-name p { margin: 2px 0 0; }--.board-icon { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: rgba(var(--accent-rgb), .12); color: var(--accent-light); }--.board-title, .topic-title { font-weight: 700; }--.board-last { width: 15em; font-size: .85rem; }--.board-last > a, .board-last > .small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 15em; }--.board-last .small a { display: inline; }--.badge-style { font-size: .65rem; vertical-align: middle; }--.board-children { margin-top: 4px; }--.topic-table .topic-icon { width: 2.2em; text-align: center; color: var(--text-secondary); }--.topic-main { min-width: 0; }--.topic-table tr.sticky td { background: rgba(var(--accent-rgb), .06); }--.topic-by { display: block; margin-top: 3px; }--.topic-pages a { margin-left: 3px; }--.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }--.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }--.space-settings, .crosspost-form { margin: 18px 0; }--.chat-room { margin: 12px 0 16px; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--bg-card); }--.chat-room h2 { margin: 0 0 6px; }--.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }--.mod-list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 6px; }--.mod-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 10px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--bg-card); }--.mod-row .inline-form { margin-left: auto; }--.postbits { display: grid; gap: 12px; margin: 12px 0; }--.postbit { display: grid; grid-template-columns: 170px 1fr; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; scroll-margin-top: 80px; }--.postbit-author { padding: 14px; background: var(--bg-hover); border-right: 1px solid var(--border-light); border-radius: 12px 0 0 12px; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; }--.postbit-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }--.postbit-letter { display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: 1.5rem; }--.postbit-name { font-weight: 700; word-break: break-word; }--.postbit-stats { display: grid; gap: 2px; margin: 0; font-size: .8rem; color: var(--text-secondary); }--.postbit-stats div { display: flex; gap: 6px; justify-content: center; }--.postbit-stats dt { font-weight: 600; }--.postbit-stats dd { margin: 0; }--.postbit-ratings { margin: 0; font-size: .8rem; display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }--.postbit-main { padding: 12px 16px; min-width: 0; }--.postbit-head { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }--.postbit.post-deleted { grid-template-columns: 1fr; }--@media (max-width: 720px) {-- .postbit { grid-template-columns: 1fr; }-- .postbit-author { flex-direction: row; flex-wrap: wrap; text-align: left; border-right: 0; border-bottom: 1px solid var(--border-light); border-radius: 12px 12px 0 0; padding: 10px 12px; }-- .postbit-avatar { width: 40px; height: 40px; }-- .postbit-stats { grid-auto-flow: column; gap: 10px; }-- .board-table thead { display: none; }-- .board-table, .board-table tbody, .board-table tr, .board-table td { display: block; width: 100%; }-- .board-table tr { border-top: 1px solid var(--border-light); padding: 8px 0; }-- .board-table tbody tr:first-child { border-top: 0; }-- .board-table td { border-top: 0; padding: 2px 12px; }-- .board-table td.num { display: inline-block; width: auto; text-align: left; }-- .board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }-- .board-table td.topic-icon { display: none; }-- .board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }++.lede {++ font-size: 1.125rem;++ line-height: 1.6;++ max-width: 44rem;}--/* ── Ratings (Facepunch style) ── */--.reactions { position: relative; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 10px 0 0; }--.rx { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light); background: transparent; color: var(--text-primary, inherit); font: inherit; font-size: .82rem; cursor: default; }--button.rx { cursor: pointer; }--button.rx:hover { border-color: var(--accent); }--.rx.mine { border-color: var(--accent); background: rgba(var(--accent-rgb), .15); }--.rx-negative { border-style: dashed; }--.rx-pick { position: static; }--.rx-pick summary { list-style: none; cursor: pointer; font-size: .82rem; color: var(--text-secondary); padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; }--.rx-pick summary::-webkit-details-marker { display: none; }--.rx-pick summary:hover, .rx-pick[open] summary { border-color: var(--border-light); }--.rx-menu { position: absolute; left: 0; top: 100%; z-index: 20; margin-top: 6px; padding: 8px; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.35); display: grid; gap: 6px; width: max-content; max-width: min(440px, 100%); }--.rx-row { display: flex; flex-wrap: wrap; gap: 4px; }--.rx-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 8px; border: 1px solid var(--border-light); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; }--.rx-btn:hover, .rx-btn:focus-visible { border-color: var(--accent); background: var(--bg-hover); }--.rx-btn.rx-negative { color: var(--text-secondary); }++.forum-note {++ margin-top: 1.5rem;++ padding: 0.875rem 1rem;++ border: 1px solid currentColor;++ border-radius: 0.5rem;++ font-weight: 600;++}--/* Crossposts */--.crosspost { margin: 8px 0; }--.crosspost-note { margin: 4px 0; }++.muted {++ opacity: 0.7;++ font-size: 0.9375rem;++}--/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */--.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }++.btn {++ display: inline-block;++ margin-right: 0.5rem;++ padding: 0.5rem 0.875rem;++ border: 1px solid currentColor;++ border-radius: 0.375rem;++ text-decoration: none;++}ddeleted file mode 100644@@@ -1,33 +0,0 @@--/* OpenVibe.Space — progressive enhancement. Every page works without this file: the board index,-- spaces, threads and posts are server-rendered, and every action (reply, vote, moderate, settings,-- attach a chat room) is a plain link or form post. What this file adds is only comfort. */--(function () {-- 'use strict';-- var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };---- // A silent sign-in that found no session leaves ?sso=none behind — tidy the address bar.-- if (/[?&]sso=none\b/.test(location.search) && history.replaceState) {-- var u = new URL(location.href); u.searchParams.delete('sso');-- history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);-- }---- // A dead avatar URL (an old Live avatar, Media offline) falls back to the letter badge.-- document.addEventListener('error', function (e) {-- var img = e.target;-- if (!img || img.tagName !== 'IMG' || !img.classList.contains('avatar')) return;-- var name = (img.parentNode && img.parentNode.textContent || '?').trim();-- var span = document.createElement('span'); span.className = 'avatar avatar-letter'; span.setAttribute('aria-hidden', 'true'); span.textContent = (name[0] || '?').toUpperCase();-- img.replaceWith(span);-- }, true);---- // A post's permalink (#post-12) is a copy target: a click on the number copies the full URL-- // (the anchor still navigates if the clipboard is unavailable).-- document.addEventListener('click', function (e) {-- var a = e.target && e.target.closest ? e.target.closest('a[href^="#post-"]') : null;-- if (!a || !navigator.clipboard) return;-- navigator.clipboard.writeText(location.href.split('#')[0] + a.getAttribute('href')).then(function () {-- var old = a.textContent; a.textContent = 'Link copied';-- setTimeout(function () { a.textContent = old; }, 1200);-- }).catch(function () { /* the anchor still jumps to the post */ });-- });--})();@@@ -1,8 +1,8 @@{"name": "OpenVibe.Space","short_name": "Space",-- "description": "The forums of the OpenVibe network: spaces, threads, replies and votes",-- "start_url": "/s",++ "description": "Code and dynamic pages for the OpenVibe network. The forum is on OpenVibe.Community.",++ "start_url": "/","scope": "/","display": "standalone","background_color": "#0a0f1c",ddeleted file mode 100644@@@ -1,80 +0,0 @@--'use strict';----/**-- * Per-actor rate limits at the API routes (roadmap WS-R task 4; openvibe-sdk/limits).-- *-- * The per-address /api/ limit (app.js) and the per-person content limits (limits.js: cooldowns, writes-- * a minute) stay. These count requests by who makes them, once req.viewer is resolved:-- *-- * a person user:usr_… (their own token, or named by a service or app in X-OV-Subject)-- * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For),-- * a signed-out visitor as the per-address limit counts them-- * a service or app acting as its principal (svc:live, app:app_…): moderation and AI output-- * itself-- * a signed-out browser ip:<address>-- *-- * A first-party service reading for itself (no person, no visitor) is not counted on reads: its pages-- * speak for all its visitors, and the per-address /api/ limit already bounds it. Past a limit the route-- * answers 429 problem+json `rate_limited` with Retry-After before it does any work; the refusal is-- * logged once and counted in space_rate_limited_total{limit,window}. Reads get SPACE_LIMITS_MINUTE-- * / SPACE_LIMITS_HOUR (120 and 3000); writes set their own numbers where they are mounted. Counters-- * live in this process: a restart forgets them.-- *-- * Never limited: /api/health, /api/ready, /release.json, /metrics, and the signed Events deliveries-- * (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;-- * they also carry token cutoffs and account deletions). Pages keep their per-address form limits.-- */--const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');--const config = require('./config');----const FIRST_PARTY = /^svc:/;--const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;----/** A first-party service that forwards the address of the visitor it acts for. */--function relaysVisitor(req) {-- const v = req.viewer;-- return !!(v && v.kind === 'service' && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));--}----function actor(req) {-- const v = req.viewer;-- if (!v || v.kind === 'anonymous') return defaultActor(req);-- if (v.subject) return `user:${v.subject}`;-- if (v.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;-- return defaultActor(req);--}----/** A first-party service reading for itself: no person, no visitor. */--function serviceItself(req) {-- const v = req.viewer;-- return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));--}----/**-- * limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of-- * a router (its writes set their own limits per route). opts.limits and opts.now are for tests.-- */--function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console, valkey = null } = {}) {-- const refused = registry-- ? registry.counter({ name: 'space_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })-- : null;-- const limiter = createActorLimiter({-- limits: limits || { minute: config.limits.minute, hour: config.limits.hour },-- actor,-- now,-- // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.-- ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),-- onLimited(e) {-- // The actor is a subject id, a principal or an address, never a token.-- log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);-- if (refused) refused.inc({ limit: e.name, window: e.window });-- },-- });-- limiter.reads = (name) => {-- const limit = limiter(name);-- return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(req) ? limit(req, res, next) : next());-- };-- return limiter;--}----module.exports = { createActorLimits, actor, serviceItself };@@@ -1,23 +1,23 @@'use strict';/**-- * OpenVibe.Space — the forums of OpenVibe.++ * OpenVibe.Space — code and dynamic pages for the OpenVibe network.** Express app factory (server/index.js listens; tests build their own instance).*-- * Pages (server-rendered) API / machine-- * GET /s the board index /api/v1/spaces/* spaces, threads, posts, votes,-- * GET /s/:space one space's topics /api/v1/posts/* categories, moderators (forum/api.js)-- * GET /s/:space/t/:slug one thread /api/v1/space-groups/* the board index's groups-- * GET /s/:space/new start one /api/v1/relay/* Discord relay admin (relay/api.js)-- * GET /s/feed.xml, /s/:space/feed.xml GET /api/health, /api/ready, /release.json, /metrics (loopback)-- * POST /s/…/reply | /vote | /state | … GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml-- * GET /auth/login|callback|logout|me|refresh POST /internal/events (signed Events deliveries)++ * Space is being rebuilt and publishes one page of its own:*-- * The forum lives in Space's own database (spaces, threads, posts, votes, attachments). People are-- * OpenVibe.Network subjects; images are OpenVibe.Media objects; members-only spaces and threads are-- * OpenVibe.VIP; a space's chat room is OpenVibe.Chat. Pastes, typed comment threads, submissions and-- * the network Pulse feed stay on OpenVibe.Community, the hub.++ * GET / the home page: what Space will host, and where the forum went++ * GET /auth/login|callback|fedcm|logout|me|refresh sign-in (OpenVibe.Network OAuth2 client)++ * GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)++ * GET /api/health, /api/ready, /release.json, /metrics (loopback)++ *++ * The forum is OpenVibe.Community's again (owner decision, 2026-10-08): every forum URL here answers++ * a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308++ * otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).++ *++ * Space holds no user content, so it consumes no network events and has no account export or++ * deletion: the forum's account data is Community's. See README.md.*/const path = require('path');const express = require('express');@@@ -30,34 +30,38 @@ const discovery = require('./discovery');const { assetVersion, SITE_NAME } = require('./render/layout');const { createAuthClient, createAuthRoutes } = require('./auth/routes');const { getDb } = require('./db');--const { createNetworkIdentity } = require('./identity/network');--const { createViewerResolver } = require('./identity/viewer');--const { createForumService } = require('./forum/service');--const { createVipGate } = require('./vip');--const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');--const { createForumRoutes } = require('./forum/routes');--const { createDiscordRelay } = require('./relay/discord');--const { createRelayEventsWorker } = require('./relay/events-worker');--const { createDiscordGateway } = require('./relay/discord-gateway');--const { createDiscordInbound } = require('./relay/inbound');--const { createRelayApi } = require('./relay/api');--const { createActorLimits } = require('./actor-limits');--const { createIndexNow } = require('openvibe-shared/indexnow');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;--// Pages are rendered for the person reading them (and a form post redirects back to them), so no++// Pages are rendered for the person reading them (and the shared navbar reads their session), so no// shared or browser cache keeps one.const PAGE_CACHE = cache.htmlHeaders({ private: true });++/** The forum paths that now live on OpenVibe.Community: /s and /s/*, and the forum APIs. */++const FORUM_PAGE = /^\/s(\/|$)/;++const FORUM_API = /^\/api\/v1\/(spaces|posts|space-groups|relay)(\/|$)/;++++/**++ * Permanent redirect to the same path and query on Community. A GET or HEAD of a page keeps its++ * method with a 301; anything else (a form post, a vote) must not be replayed as a GET, and the++ * APIs are called with bodies, so they always answer 308.++ */++function forumRedirect(req, res, next) {++ const isPage = FORUM_PAGE.test(req.path);++ if (!isPage && !FORUM_API.test(req.path)) return next();++ const method = String(req.method || 'GET').toUpperCase();++ const status = isPage && (method === 'GET' || method === 'HEAD') ? 301 : 308;++ res.redirect(status, `${config.communityUrl}${req.originalUrl}`);++}++async function createApp(opts = {}) {const app = express();app.disable('x-powered-by');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: 'space', root: require('path').join(__dirname, '..') });++ const release = require('openvibe-shared/release').createRelease({ service: 'space', root: 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.@@@ -74,9 +78,9 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://cdnjs.cloudflare.com', 'https://static.cloudflareinsights.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://openvibe.network'],fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],-- // Attachments serve from openvibe.media (which may 302 to object storage); avatars from Network/Live.imgSrc: ["'self'", 'data:', 'blob:', 'https:'],-- connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.events', 'https://openvibe.media', 'https://openvibe.chat', 'https://openvibe.vip', 'https://cloudflareinsights.com'],++ // The shared navbar's notification bell hears OpenVibe.Events (notificationsRealtime).++ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com'],// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'self'"],@@@ -92,6 +96,9 @@ async function createApp(opts = {}) {}));app.use(cookieParser());++ // ── The forum is Community's: every forum URL redirects there ───++ app.use(forumRedirect);++// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────const auth = opts.auth || createAuthClient(config);app.locals.auth = auth;@@@ -100,83 +107,20 @@ async function createApp(opts = {}) {app.use('/auth', createAuthRoutes(config, auth));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }-- // ── Space's database, identity, and what lives in it ─────++ // ── Space's database (for readiness and the shared schema) ───const db = opts.db || getDb();-- const network = opts.network || createNetworkIdentity({ config, db });-- // Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password-- // changes and bans refuse older tokens here at once (WS-B task 4).-- // Cutoffs are read into memory before the app serves: isRevoked() is synchronous on every signed-in request.-- const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });-- await revocations.load();-- const viewers = createViewerResolver({ auth, config, network, revocations });-- const relay = opts.relay || createDiscordRelay({-- db, config, enabled: config.discordRelay.enabled,-- pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,-- webhookVars: config.discordRelay.webhookVars,-- ...(opts.relayOptions || {}),-- });-- // Its Events worker (creates from space.thread.* / space.post.*) and the inbound gateway;-- // both off unless configured (relay/events-worker.js, relay/discord-gateway.js, relay/inbound.js).-- const relayInbound = relay.enabled ? createDiscordInbound({ db, perMinute: config.discordRelay.inboundPerMinute, maxChars: config.discordRelay.inboundMaxChars, ...(opts.inboundOptions || {}) }) : null;-- if (!opts.relay) {-- relay.attach({-- worker: createRelayEventsWorker({-- db, relay, enabled: config.discordRelay.events, eventsUrl: config.discordRelay.eventsUrl, clientSecret: config.oauth.clientSecret,-- tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, pollMs: config.discordRelay.eventsPollMs, fetchImpl: opts.fetchImpl,-- ...(opts.relayWorkerOptions || {}),-- }),-- gateway: relayInbound && config.discordRelay.inbound ? createDiscordGateway({-- token: config.discordRelay.botToken, url: config.discordRelay.gatewayUrl,-- onDispatch: async (type, data, ctx) => await relayInbound.handle(type, data, ctx),-- ...(opts.gatewayOptions || {}),-- }) : null,-- inbound: relayInbound,-- });-- }-- // OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).-- const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });-- // Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).-- 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 });-- // 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 forum service-- // pings 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, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });-- discovery.useForum(forum);-- Object.assign(app.locals, { db, network, relay, relayInbound, vip, forum, indexnow });-- if (opts.startRelay !== false) relay.start();---- // Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its-- // viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).-- // Valkey (ADR-035): the per-actor limit counters, shared across processes; opts.valkey for tests (null: none).-- const valkey = opts.valkey !== undefined ? opts.valkey-- : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null);-- app.locals.valkey = valkey;-- const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });---- // ── /api/v1: the forum, its groups and the relay admin ───-- app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));-- app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));-- app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));-- app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));-- app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));-- // OpenVibe.Events → Space (server/events-consumer.js): token cutoffs, platform blocks, account-- // export/deletion/merge and VIP cache convergence.-- const eventsConsumer = require('./events-consumer').createEventsConsumer({ db, vipCache: vip && vip.cache, revocations, accountSend: config.oauth && config.oauth.clientSecret ? require('./identity/account-data').createSender({ config }) : null, secrets: String(process.env.SPACE_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });-- app.locals.eventsConsumer = eventsConsumer;-- // Never per-actor limited: Events pushes at its own pace (a 429 only makes it retry and fall behind),-- // and these deliveries carry token cutoffs and account deletions.-- app.use('/internal/events', eventsConsumer.router);++ app.locals.db = db;++++ // /updates: Space ships with the community's log until it has one of its own.++ app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-space', version: VERSION }));// 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; the Network key-- // and Media failures degrade (server/observability.js).-- const readiness = require('./observability').createSpaceReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });++ // Readiness reports what is actually served: 503 only without the database; a Network key that has++ // not loaded degrades (server/observability.js).++ const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release });app.get('/api/ready', readiness.handler);// ── Static assets (content-hashed ?v= → immutable) ───────@@@ -192,11 +136,11 @@ async function createApp(opts = {}) {}));// ── Machine endpoints ────────────────────────────────────-- // Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.++ // Written by openvibe-shared/seo from server/discovery.js; the sitemap is / alone.app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })));app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))-- .send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, sections: discovery.llmsSections() })));++ .send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.SUMMARY, sections: discovery.llmsSections() })));app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));app.get('/sitemap.xml', async (_req, res) => {@@@ -204,20 +148,10 @@ async function createApp(opts = {}) {try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));});-- // 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: the viewer (subject, staff) is resolved here too.-- // Every page response below (redirects and error pages too) that sets no policy of its own.-- app.use(cache.applyHtml({ private: true }));---- // The board index lives at /s (GET /s renders it); / sends visitors there.-- app.get('/', (_req, res) => res.redirect(301, '/s'));-- // /updates: Space ships with the community's log until it has one of its own.-- app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));---- app.use(createForumRoutes({ forum, viewers, config }));++ // The home page: what Space will host, and where the forum went (no fake features).++ app.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(require('./render/pages').homePage()));// ── 404 / errors ─────────────────────────────────────────app.use((req, res) => {ddeleted file mode 100644@@@ -1,89 +0,0 @@--'use strict';--/**-- * OpenVibe.Chat rooms for spaces (roadmap WS-I task 4): a space's owner or staff attach a chat room to-- * the space; the space page links it.-- *-- * Each side checks its own end. Space decides who may change the space (forum/service.js); Chat-- * decides who may attach the room: the person must manage it (its owner, or chat staff). Space asks-- * Chat on the person's behalf with the person's OWN Network token (the ov_token they are signed in with),-- * so no service grant or new capability is involved:-- *-- * POST <chat>/api/chat/rooms/:room/attachments { service: 'space', resource: <space slug>, title }-- * 201 attached, 200 already attached (idempotent) → { room: { id, slug, name, kind, visibility } }-- * DELETE <chat>/api/chat/rooms/:room/attachments/space/:space (the room's managers or who attached it)-- *-- * Chat's answers become Space's: 404 (no such room, or a private room the person is not in: Chat-- * says nothing more), 403 (not the room's owner), 401 (the token expired). Chat unreachable: 503.-- */--const { fail } = require('./http/v1');----const ROOM_SLUG = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;----/** A room slug from "night-owls", "/r/night-owls" or "https://openvibe.chat/r/night-owls"; null otherwise. */--function parseRoomRef(input) {-- const s = String(input == null ? '' : input).trim();-- if (!s || s.length > 300) return null;-- const m = s.match(/^(?:https?:\/\/[^/\s]+)?\/r\/([^/?#\s]+)\/?(?:[?#].*)?$/i);-- const slug = (m ? m[1] : s).toLowerCase();-- return ROOM_SLUG.test(slug) ? slug : null;--}----function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {-- const chat = (config && config.chat) || {};-- const base = chat.internalUrl || '';-- const timeoutMs = chat.timeoutMs || 4000;-- const publicUrl = chat.url || 'https://openvibe.chat';---- async function call(method, path, token, body) {-- if (!base) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');-- let res;-- try {-- res = await fetchImpl(`${base}${path}`, {-- method,-- headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },-- body: body ? JSON.stringify(body) : undefined,-- signal: AbortSignal.timeout(timeoutMs),-- });-- } catch (err) {-- fail(503, 'chat_room.unavailable', 'OpenVibe.Chat did not answer. Try again in a minute');-- }-- let data = null;-- try { data = await res.json(); } catch { data = null; }-- return { status: res.status, data };-- }---- /** Ask Chat to attach `room` to `space` as the person holding `token`. → { room, created } */-- async function attach({ token, room, space, title }) {-- if (!token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');-- const r = await call('POST', `/api/chat/rooms/${encodeURIComponent(room)}/attachments`, token, { service: 'space', resource: space, title: title || undefined });-- if ((r.status === 200 || r.status === 201) && r.data && r.data.room && r.data.room.slug) {-- const x = r.data.room;-- return {-- created: r.status === 201,-- room: { id: Number(x.id) || null, slug: String(x.slug), name: String(x.name || x.slug).slice(0, 80), kind: String(x.kind || 'community'), visibility: x.visibility === 'private' ? 'private' : 'public' },-- };-- }-- if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');-- if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');-- if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');-- if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');-- fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');-- }---- /** Tell Chat the space let go of the room; best effort. → 'detached' | 'not_allowed' | 'unavailable' */-- async function detach({ token, room, space }) {-- if (!token || !base) return 'unavailable';-- try {-- const r = await call('DELETE', `/api/chat/rooms/${encodeURIComponent(room)}/attachments/space/${encodeURIComponent(space)}`, token);-- if (r.status === 200) return 'detached';-- if (r.status === 403 || r.status === 404) return 'not_allowed';-- return 'unavailable';-- } catch { return 'unavailable'; }-- }---- const roomUrl = (slug) => `${publicUrl}/r/${encodeURIComponent(slug)}`;---- return { attach, detach, roomUrl, parseRoomRef };--}----module.exports = { createChatRooms, parseRoomRef, ROOM_SLUG };@@@ -18,13 +18,6 @@ module.exports = {// Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,-- // Per-actor limits at the API routes (server/actor-limits.js): the reads one caller may make to-- // one API per minute and per hour. Writes set their own numbers per route.-- limits: {-- minute: parseInt(process.env.SPACE_LIMITS_MINUTE, 10) || 120,-- hour: parseInt(process.env.SPACE_LIMITS_HOUR, 10) || 3000,-- },--// Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),@@@ -43,80 +36,14 @@ module.exports = {secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,},-- // OpenVibe.Community — the hub (D12). Space's pages link the network Pulse feed Community serves;-- // Space stores none of it and serves no Pulse page of its own.++ // OpenVibe.Community — the hub. The forum is Community's again (owner decision, 2026-10-08) and++ // every forum URL here redirects to the same path and query on Community.communityUrl: (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, ''),-- // OpenVibe.Live — author profile links (the Network hosts the profile page; Live's layout is the-- // familiar one) and avatars.++ // OpenVibe.Live — the legal pages the shared footer links (the Network hosts the profile page).liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),-- // OpenVibe.Media — public host that serves the images posts attach (med_ objects).-- mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),-- // Media's internal address: attachment uploads go to its Object API with a service token-- // (media.object.upload for audience openvibe.media).-- mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),-- // PostgreSQL (ADR-035) for Space's own data: the forum (spaces, threads, posts, votes,-- // categories, attachments), the Discord relay's bookkeeping and the account-data records.-- // DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,-- // development uses an embedded PGlite database in data/pglite.++ // PostgreSQL (ADR-035). Space holds no user content yet; the database is kept for the release++ // ledger and the shared schema migrations. Without DATABASE_URL, 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.-- valkey: { url: process.env.VALKEY_URL || '', prefix: process.env.VALKEY_PREFIX || 'ov:space:' },---- // Browser origins allowed to call the embeddable forum APIs with a Bearer Network JWT.-- // No cookies cross origins.-- apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.community,https://openvibe.live,https://openvibe.network,https://openvibe.tools')-- .split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),---- // OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from-- // VIP's POST /api/v1/policies/evaluate (capability vip.resource.policy.evaluate, client-- // credentials of client `space`), cached per viewer: a "yes" at most ttlMs (the convergence-- // bound after VIP stops granting), a "no" denyTtlMs, a failure unavailableTtlMs. Without a-- // client secret or with VIP down, nobody but the owner and moderators gets in.-- vip: {-- internalUrl: (process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/$/, ''),-- publicUrl: (process.env.OV_VIP_URL || 'https://openvibe.vip').replace(/\/$/, ''),-- timeoutMs: parseInt(process.env.VIP_TIMEOUT_MS, 10) || 2000,-- ttlMs: parseInt(process.env.VIP_CACHE_TTL_MS, 10) || 30_000,-- denyTtlMs: parseInt(process.env.VIP_CACHE_DENY_TTL_MS, 10) || 10_000,-- unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,-- },---- // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set, the key file is served at-- // /<key>.txt and a public, indexable page appearing, changing or going away pings the engines.-- // Unset: off — nothing is mounted and nothing is sent.-- indexnow: { key: process.env.INDEXNOW_KEY || '' },---- // OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Space asks Chat, with the-- // signed-in person's own Network token, whether they manage the room; the space page links it.-- chat: {-- url: (process.env.OV_CHAT_URL || 'https://openvibe.chat').replace(/\/$/, ''),-- internalUrl: (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/$/, ''),-- timeoutMs: parseInt(process.env.CHAT_TIMEOUT_MS, 10) || 4000,-- },---- // Discord relay (server/relay; docs/discord-relay.md). Off by default, and inert without an owner's-- // webhook variables, mappings and (for inbound) bot token.-- // out threads and replies in mapped public spaces → the mapping's Discord webhook; edits and deletes follow-- // in replies on Discord → posts (the gateway; DISCORD_RELAY_INBOUND=on and DISCORD_BOT_TOKEN)-- // Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.-- discordRelay: {-- enabled: /^(1|true|yes|on)$/i.test(process.env.DISCORD_RELAY_ENABLED || ''),-- pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,-- backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,-- maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,-- // The only variables a mapping may name (comma-separated exact names); unset = DISCORD_WEBHOOK_*.-- webhookVars: (process.env.DISCORD_RELAY_WEBHOOK_VARS || '').split(',').map((s) => s.trim()).filter(Boolean),-- // The Events worker queues creates from space.thread.* / space.post.* (needs EVENTS_URL and-- // OV_OAUTH_CLIENT_SECRET, capability events.event.read); 'off' leaves them to the forum.-- events: !/^(0|false|no|off)$/i.test(process.env.DISCORD_RELAY_EVENTS || ''),-- eventsUrl: (process.env.EVENTS_URL || '').replace(/\/+$/, '') || null,-- eventsPollMs: parseInt(process.env.DISCORD_RELAY_EVENTS_POLL_MS, 10) || 5000,-- // Inbound through the Discord gateway (a bot in the server with the MESSAGE CONTENT intent).-- inbound: /^(1|true|yes|on)$/i.test(process.env.DISCORD_RELAY_INBOUND || ''),-- botToken: process.env.DISCORD_BOT_TOKEN || '',-- gatewayUrl: process.env.DISCORD_GATEWAY_URL || 'wss://gateway.discord.gg/?v=10&encoding=json',-- inboundPerMinute: parseInt(process.env.DISCORD_RELAY_INBOUND_PER_MINUTE, 10) || 6,-- inboundMaxChars: parseInt(process.env.DISCORD_RELAY_INBOUND_MAX_CHARS, 10) || 4000,-- },};@@@ -3,13 +3,11 @@/*** Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.*-- * Space owns the forum: space groups, spaces, categories, threads, posts and their versions, thread-- * votes, attachments (Media objects), post reactions, a space's chat room, per-space moderators, the-- * Discord relay's bookkeeping and the account-data and block projections. 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.-- *-- * Pastes, typed comment threads, Pulse and submissions are OpenVibe.Community's; they are not here.++ * Space holds no user content while it is rebuilt. The migrations still create the forum tables++ * (spaces, threads, posts, votes, attachments, the relay's bookkeeping, the event inbox and the++ * account-data records) that Space served when the forum lived here; they stay in the database —++ * this release writes no drop migration — and their rows are OpenVibe.Community's now, where the++ * forum is. The database itself stays for the shared schema and the release ledger.** Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()* with those semantics, and ov_hot() for the forum's hot rank.@@@ -1,86 +1,47 @@'use strict';/**-- * What Space tells crawlers, feed readers and language models: the robots.txt disallow set,-- * the /llms.txt and /llms-full.txt sections, the sitemap rows (cached ~1h) and the feed items.-- * Only data lives here; openvibe-shared/seo writes every format (server/app.js, server/forum/routes.js).++ * What Space tells crawlers and language models: the robots.txt disallow set, the /llms.txt and++ * /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes++ * every format (server/app.js).++ *++ * Space publishes one page of its own — the home page — while it is rebuilt to host code and++ * dynamic pages and "spaces", apps people build on their OpenVibe account, the network's services++ * and the SDK. The forum is OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum++ * shaped appears here: no /s entries, no feeds.*/const config = require('./config');const { abs } = require('./render/layout');--const { isoDate } = require('./render/jsonld');--const SITEMAP_TTL_MS = 60 * 60 * 1000;--const ROBOTS_DISALLOW = ['/api/', '/auth/', '/s/*/new', '/*?sso='];--const SUMMARY = 'The forums of the OpenVibe network: public and members-only spaces, their threads and replies, votes and categories. The network Pulse feed lives on OpenVibe.Community.';--const CONTENT_LABELS = 'Threads and replies written by services for the OpenVibe AI carry origin "ai" and are labelled as AI-generated; everything else was written by people.';----// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,--// the feeds and /llms-full.txt.--let _forum = null;--function useForum(forum) { _forum = forum || null; resetCaches(); }++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/*?sso='];++const SUMMARY = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────function llmsSections() {const u = config.baseUrl;return [-- { title: 'Browse', links: [-- { title: 'Spaces', url: `${u}/s`, note: 'the board index: every public space, grouped' },-- { title: 'Pulse', url: `${config.communityUrl}/pulse`, note: 'network activity, on OpenVibe.Community' },++ { title: 'This site', links: [++ { title: 'Home', url: `${u}/`, note: 'what Space will host (nothing is published here yet)' },++ ] },++ { title: 'The network', links: [++ { title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },++ { title: 'OpenVibe.Network', url: config.networkUrl, note: 'identity, accounts and sign-in' },] },{ title: 'Machine-readable', links: [{ title: 'Sitemap', url: `${u}/sitemap.xml` },-- { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },{ title: 'Full text for language models', url: `${u}/llms-full.txt` },] },];}--/** The latest public threads in full (nothing gated). */async function llmsFullSections() {-- const { markdownToText } = require('./render/markdown');-- const sections = [{ title: 'Content labels', pages: [{ title: 'Who wrote what', url: '/s', text: CONTENT_LABELS }] }];-- if (_forum) {-- const threads = await _forum.recentPublic({ limit: 50 });-- sections.push({ title: 'Latest threads', pages: threads.map((t) => ({-- title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),-- })) });-- }-- return sections;++ return [{ title: 'OpenVibe.Space', pages: [{ title: 'What Space will host', url: '/', text: SUMMARY }] }];}--// ── sitemap.xml rows (cached ~1h) ────────────────────────────--let _sitemap = null, _sitemapAt = 0;--async function buildSitemapRows() {-- const rows = [];-- const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });-- add('/s', null, 'hourly', '1.0');-- if (_forum) {-- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');-- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');-- }-- return rows;--}--/** The sitemap rows, rebuilt at most hourly; a failed rebuild keeps serving the last good rows. */++// ── sitemap.xml rows ─────────────────────────────────────────++/** Space's only page is the home page. */async function sitemapRows() {-- if (!_sitemap || Date.now() - _sitemapAt > SITEMAP_TTL_MS) {-- try { _sitemap = await buildSitemapRows(); _sitemapAt = Date.now(); }-- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) throw e; }-- }-- return _sitemap;++ return [{ loc: abs('/'), lastmod: null, changefreq: 'weekly', priority: '1.0' }];}--// ── Feed items (openvibe-shared/seo feedXml, 2.5.0 item shape) ──--/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */--function threadFeedItems(threads) {-- const { markdownToText } = require('./render/markdown');-- return threads.map((t) => {-- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);-- const description = markdownToText(t.opening, 300);-- return { title: t.title, link: url, guid: url, description, content: description,-- author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };-- });--}----function resetCaches() { _sitemap = null; _sitemapAt = 0; }----module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, threadFeedItems, useForum, resetCaches };++module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };ddeleted file mode 100644@@@ -1,152 +0,0 @@--'use strict';--/**-- * OpenVibe.Events → Space: POST /internal/events, the endpoint of Space's Events subscriptions-- * (consumer `space`, created at boot by startSubscriptions()). The forum keeps its tables honest with-- * what the rest of the network says about people:-- *-- * network.user.token_valid_after revocation (WS-B task 4): the person's token cutoff moves-- * (openvibe-sdk createPgRevocationStore); the viewer resolver refuses-- * their older tokens.-- * network.block.changed platform blocks (WS-E task 5): the network_blocks projection-- * (../identity/blocks.js; the newest revision per pair wins) that-- * replies honour.-- * network.subject.merged two accounts became one (ADR-029): the folded-in subject's rows-- * become the survivor's (../identity/subject-merge.js).-- * network.account.export_requested Space's part of an account export (../identity/account-data.js).-- * network.account.deleted what the subject wrote goes (or becomes a tombstone) and Space-- * confirms with counts.-- * vip.membership.changed VIP convergence: the member's cached members-only answers for that-- * creator are dropped at once (the VIP gate's cache handleEvent)-- * instead of waiting out its TTL.-- *-- * Pulse item creation is NOT here: the network Pulse feed is OpenVibe.Community's (plan T10 D3, D12),-- * and Space stores none of it.-- *-- * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the write.-- * Signature v2 only (parseDelivery requireV2) under SPACE_EVENTS_SECRET (comma-separated for rotation,-- * 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header came-- * through nginx and is refused.-- */--const express = require('express');--const { http, serviceAuth } = require('openvibe-contracts');--const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');--const blocks = require('./identity/blocks');----const CONSUMER = 'space';--const TOPICS = Object.freeze(['vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);--const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;----function createEventsConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {-- const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);-- // Receipts (space_event_inbox) are in migrations/0001_initial.sql.-- const inbox = createPgInbox(db, { table: 'space_event_inbox', now });-- const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };-- const router = express.Router();-- router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {-- const ctx = http.requestContext(req.headers);-- const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });-- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'space.internal_only', 'internal route');-- if (!keys.length) return problem(503, 'space.events_disabled', 'SPACE_EVENTS_SECRET is not set');-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);-- let delivery = null;-- for (const s of keys) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true, now: now() }); if (delivery) break; }-- if (!delivery) { stats.refused++; return problem(401, 'space.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window'); }-- const event = delivery.event;-- if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'space.bad_delivery', 'body must be { event: <envelope>, seq }'); }-- stats.received++; stats.last_at = new Date(now()).toISOString();-- if (event.event_type === 'network.user.token_valid_after') {-- if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await revocations.apply(event) }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- }-- if (event.event_type === 'network.block.changed') {-- const p = blocks.payloadOf(event);-- if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }-- try {-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await blocks.apply(db, p, now()) }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- } catch (err) {-- stats.failed++;-- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(500, 'space.event_failed', 'processing failed; it will be retried');-- }-- }-- if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {-- // Account export and deletion (ADR-033, ./identity/account-data.js): once per export or deletion by its own-- // record, answered after Network took the part or the confirmation, so a failure is redelivered without-- // erasing twice.-- if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }-- return require('./identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {-- stats.applied++;-- res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });-- }, (err) => {-- stats.failed++;-- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- problem(500, 'space.event_failed', 'processing failed; it will be retried');-- });-- }-- if (event.event_type === 'network.subject.merged') {-- // Two accounts became one (ADR-029): the folded-in subject's rows become the survivor's (./identity/subject-merge.js).-- const merge = require('./identity/subject-merge');-- const p = merge.payloadOf(event);-- if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }-- try {-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await merge.apply(db, p, { log }) }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- } catch (err) {-- stats.failed++;-- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(500, 'space.event_failed', 'processing failed; it will be retried');-- }-- }-- if (event.event_type === 'vip.membership.changed') {-- if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }-- const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- }-- stats.ignored++;-- return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:type' });-- });-- return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };--}----/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */--function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVENTS_URL, fetchImpl = globalThis.fetch, log = console }) {-- if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.SPACE_EVENTS_SUBSCRIBE === '0') return null;-- const base = String(eventsUrl).replace(/\/+$/, '');-- const endpoint = process.env.SPACE_EVENTS_ENDPOINT || `http://127.0.0.1:${port}/internal/events`;-- const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });-- const call = async (method, path, body) => {-- const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });-- const json = await res.json().catch(() => ({}));-- return { status: res.status, ok: res.ok, body: json };-- };-- const attempt = async () => {-- const listed = await call('GET', '/api/v1/subscriptions');-- if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);-- const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);-- for (const topic of TOPICS) {-- if (mine.some((s) => s.topic_pattern === topic)) continue;-- const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });-- if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);-- if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);-- }-- };-- const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];-- let i = 0;-- let timer = null;-- let stopped = false;-- const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {-- if (stopped) return;-- if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);-- }); };-- timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();-- // Graceful stop: no further attempts (they run again at the next start).-- return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };--}----module.exports = { createEventsConsumer, startSubscriptions, TOPICS };ddeleted file mode 100644@@@ -1,125 +0,0 @@--'use strict';--/**-- * Space → OpenVibe.Events (contracts space.*@1).-- *-- * space.thread.created, space.post.created server/forum/store.js-- * space.moderation.action server/forum/service.js (staff actions on others' content)-- *-- * Each event is written to Space's own outbox inside the SAME transaction as the change (the SDK-- * outbox refuses anything else), and a relay publishes it with Space's service token (audience-- * openvibe.events, capability events.event.publish). Events down: rows wait and are retried; a write-- * never waits on Events. Payloads carry ids, the author subject, visibility and the public URL, never-- * a body or a title. Envelope visibility is public only for public items.-- *-- * Off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set (EVENTS_PUBLISH=off disables it).-- *-- * The event types are the forum's own namespace (plan T10 D3: the forum's contracts are Space's).-- * openvibe-contracts v0.110.0 publishes the space.thread.created / space.post.created /-- * space.moderation.action payloads (space.json `eventsProduced`), so Events accepts them; the producer-- * and the Discord relay's Events worker stay off without EVENTS_URL and OV_OAUTH_CLIENT_SECRET.-- */--const { createClient } = require('openvibe-sdk/core');--const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');--const config = require('./config');----let outbox = null;--let outboxDb = null; // the handle enqueue() writes through (it joins the caller's ambient transaction)--let pruneTimer = null;--const stats = { queued: 0, lastError: null };--const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;----function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oauth && config.oauth.clientSecret, fetchImpl, intervalMs } = {}) {-- if (outbox) return outbox;-- if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;-- const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'space', clientSecret, fetch: fetchImpl });-- const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });-- outboxDb = db;-- // The PostgreSQL outbox (its table is in migrations/0001_initial.sql); several processes relay it safely (leases).-- outbox = createPgOutbox(db, {-- events: createEventsClient(client, { source: 'space' }),-- intervalMs: intervalMs || 2000,-- onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },-- });-- outbox.start();-- pruneTimer = setInterval(() => { outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);-- if (pruneTimer.unref) pruneTimer.unref();-- outbox.pending().then((n) => console.log(`[Events] space → ${eventsUrl} (${n} pending)`), () => {});-- return outbox;--}----const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? { type: 'user', id: sub } : (sub && /^gst_/.test(sub) ? { type: 'guest', id: sub } : null));----/**-- * Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,-- * so the change rolls back with it. A no-op (null) while publishing is off.-- */--async function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {-- if (!outbox) return null;-- const env = await outbox.enqueue(outboxDb, {-- event_type: eventType,-- actor: actor || { type: 'service', id: 'space' },-- subject,-- visibility: isPublic ? 'public' : 'internal',-- priority: 'low',-- payload,-- });-- stats.queued++;-- outboxDb.afterCommit(() => outbox && outbox.kick());-- return env;--}----// ── Builders (read the row as it is after the change) ─────────────────────────────--const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };----// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).--const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');--async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {-- const visibility = forumVisibility(thread, spaceVisibility);-- return await record('space.thread.created', { type: 'thread', id: String(thread.id) }, {-- thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,-- }, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });--}--async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {-- const visibility = forumVisibility(thread, spaceVisibility);-- return await record('space.post.created', { type: 'post', id: String(post.id) }, {-- post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,-- }, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });--}----/**-- * A staff action on someone else's content: space.moderation.action, for the network's moderation-- * audit log (ADR-022). Call inside the same transaction as the change. Never the content.-- */--async function moderationAction(v, action, target, { reason = null, details = {} } = {}) {-- const actorSubject = v && v.subject ? v.subject : null;-- const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };-- return await record('space.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },-- { action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },-- { actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });--}----/** Synchronous: whether the relay is on, and what this process queued. */--function status() {-- if (!outbox) return { enabled: false };-- return { enabled: true, queued_since_boot: stats.queued, last_error: stats.lastError };--}--/** status() with the outbox's backlog (rows not sent yet, rows Events refused). */--async function backlog() {-- if (!outbox) return { enabled: false };-- return { ...status(), pending: await outbox.pending(), rejected: await outbox.rejected() };--}--/**-- * Graceful stop (server/index.js): no further sends; resolves when the send in progress has finished.-- * Rows written after this (a request that was still finishing) stay in the outbox for the next start.-- */--function stop() {-- if (pruneTimer) clearInterval(pruneTimer);-- pruneTimer = null;-- return outbox ? outbox.stop() : Promise.resolve();--}--function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }----module.exports = { init, record, moderationAction, threadCreated, postCreated, status, backlog, stop, _reset };ddeleted file mode 100644@@@ -1,145 +0,0 @@--'use strict';----/**-- * /api/v1/spaces and /api/v1/posts — the forum's JSON API (the pages in render/forum.js are the-- * no-JS equivalent; service.js holds the rules for both).-- *-- * GET /spaces spaces the caller can open-- * GET /spaces/:space one space-- * GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)-- * POST /spaces/:space/threads { title, body } new thread (body is Markdown)-- * GET /spaces/:space/threads/:slug?page= thread + posts-- * DELETE /spaces/:space/threads/:slug author or moderator-- * POST /spaces/:space/threads/:slug/posts { body } reply-- * POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0-- * PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators-- * PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)-- * PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators-- * POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread-- * POST /spaces/:space/attachments (multipart `file`) an image for a new thread or reply → { attachment: { media_id, url, … } };-- * then POST …/threads or …/posts with { attachments: [media_id] } (at most 4)-- * GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)-- * PUT /spaces/:space/categories/:category { name, description?, position? } moderators-- * DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)-- * PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators-- * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,-- * roadmap items planned|in_progress|done|paused (?status= filters)-- * POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators-- * PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators-- * POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)-- * PUT /spaces/:space/chat-room { room: slug | https://openvibe.chat/r/<slug> } the space's owner or staff, signed in-- * themselves (Chat checks they manage the room) → { chat_room, created }-- * DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }-- * GET /spaces/:space/moderators the space's own moderators → { space, moderators: [{ subject, username, display_name, added_by, added_at }] }-- * PUT /spaces/:space/moderators/:subject add a person (usr_… or @username, resolved through the Network; 404 moderator.unknown_user)-- * — the space's moderators or staff (idempotent)-- * DELETE /spaces/:space/moderators/:subject remove one — the space's moderators or staff (idempotent)-- * POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }-- * PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions-- *-- * Services write with space.post.write (as X-OV-Subject, or as AI with X-OV-Origin: ai) and-- * moderate with space.forum.manage. "moderators" on a /spaces/:space route means discussion staff or-- * one of that space's own moderators (people, signed in themselves). Errors are problem+json. A members-only space or thread-- * refuses readers and writers without the creator's VIP membership with 403 vip.members_only-- * ({ reason, gate, members_only: { owner, owner_username, join_url } }).-- */--const express = require('express');--const multer = require('multer');--const contracts = require('openvibe-contracts');--const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');----const POST = 'space.post.write';--const MOD = 'space.forum.manage';----function createSpacesApi({ forum, viewers, limits }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());-- const write = serviceCap(POST);-- const writeOrMod = serviceAnyCap([POST, MOD]);-- const p = (req) => req.params;-- // A space's moderators: a service needs space.forum.manage; a browser must be discussion-- // staff or one of the space's own moderators (the service methods decide again, with 404 for a missing space).-- const spaceMod = (req, res, next) => {-- const v = req.viewer;-- if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);-- return forum.canModerate(v, p(req).space)-- .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))-- .catch(next);-- };-- // Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js-- // (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;-- // these cap requests, refused ones included, and every write that has no content limit.-- router.use(limits.reads('space.forum.read'));-- const configure = limits('space.space.configure', { minute: 30, hour: 300 }); // moderators' settings-- const editThread = limits('space.thread.edit', { minute: 30, hour: 300 });---- router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));-- router.post('/', serviceCap(MOD), limits('space.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));-- router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));-- router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));-- router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));-- router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));-- // An image to attach: multipart `file`; then name its media_id in `attachments` when posting.-- const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');-- const withFile = (req, res, next) => one(req, res, (err) => (err-- ? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })-- : next()));-- // An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.-- router.post('/:space/attachments', write, limits('space.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));-- router.put('/:space/categories/:category', configure, spaceMod, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));-- router.delete('/:space/categories/:category', configure, spaceMod, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));-- router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/status', configure, spaceMod, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.post('/:space/threads/:slug/crosspost', write, limits('space.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));-- router.post('/:space/threads', write, limits('space.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));-- router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));-- router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));-- router.post('/:space/threads/:slug/posts', write, limits('space.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));-- router.post('/:space/threads/:slug/votes', write, limits('space.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/members-only', configure, spaceMod, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));-- // A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.-- router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));-- router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));-- // The space's own moderators (people): listed to its readers, added and removed by its moderators or staff.-- router.get('/:space/moderators', run(async (req) => await forum.moderators(req.viewer, p(req).space)));-- router.put('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.addModerator(req.viewer, p(req).space, p(req).subject)));-- router.delete('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.removeModerator(req.viewer, p(req).space, p(req).subject)));---- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----function createPostsApi({ forum, viewers, limits }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());-- const writeOrMod = serviceAnyCap([POST, MOD]);-- // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.-- router.use(limits.reads('space.forum.read'));---- router.put('/:id', writeOrMod, limits('space.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));-- router.post('/:id/reactions', serviceCap(POST), limits('space.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));-- router.delete('/:id', writeOrMod, limits('space.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));-- router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));---- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----/** /api/v1/space-groups — the board index's groups: GET (everyone), PUT /:group { name, description?, position? } (moderators). */--function createGroupsApi({ forum, viewers, limits }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());-- // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.-- router.use(limits.reads('space.forum.read'));-- router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));-- router.put('/:group', serviceCap(MOD), limits('space.space.configure', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.putGroup(req.viewer, req.params.group, req.body || {})));-- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----module.exports = { createSpacesApi, createPostsApi, createGroupsApi };ddeleted file mode 100644@@@ -1,76 +0,0 @@--'use strict';----/**-- * Ratings on forum posts, after Facepunch: instead of a plain like, a row of specific community judgments.-- * One rating per person per post (rating again with the same one takes it back; another replaces it);-- * nobody rates their own post. A space can switch ratings off (spaces.reactions).-- */--const REACTIONS = Object.freeze([-- { key: 'agree', emoji: '✅', label: 'Agree', group: 'positive' },-- { key: 'winner', emoji: '🏆', label: 'Winner', group: 'positive' },-- { key: 'funny', emoji: '😂', label: 'Funny', group: 'positive' },-- { key: 'informative', emoji: '💡', label: 'Informative', group: 'positive' },-- { key: 'friendly', emoji: '😊', label: 'Friendly', group: 'positive' },-- { key: 'sympathy', emoji: '❤️', label: 'Sympathy', group: 'positive' },-- { key: 'dumb', emoji: '📦', label: 'Dumb', group: 'negative' },-- { key: 'disgusting', emoji: '🤢', label: 'Disgusting', group: 'negative' },-- { key: 'bad_reading', emoji: '📖', label: 'Bad reading', group: 'utility' },-- { key: 'late', emoji: '🕒', label: 'Late', group: 'utility' },--]);--const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));--const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));----/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */--async function setReaction(db, postId, subject, reaction) {-- const cur = await db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);-- if (!reaction || (cur && cur.reaction === reaction)) {-- await db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);-- return null;-- }-- await db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)-- ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`).run(postId, subject, reaction);-- return reaction;--}----const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');----/**-- * Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]-- * in the fixed order, only ratings someone gave.-- */--async function reactionsFor(db, postIds, viewerSubject = null) {-- const out = new Map();-- if (!postIds.length) return out;-- const rows = await db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);-- for (const r of rows) {-- const def = BY_KEY.get(r.reaction);-- if (!def) continue;-- if (!out.has(r.post_id)) out.set(r.post_id, new Map());-- const m = out.get(r.post_id);-- if (!m.has(r.reaction)) m.set(r.reaction, { ...def, count: 0, raters: [], mine: false });-- const e = m.get(r.reaction);-- e.count++;-- if (e.raters.length < 8) e.raters.push(r.subject_id);-- if (viewerSubject && r.subject_id === viewerSubject) e.mine = true;-- }-- return new Map([...out].map(([id, m]) => [id, [...m.values()].sort((a, b) => ORDER.get(a.key) - ORDER.get(b.key))]));--}----/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */--async function receivedBy(db, subjects) {-- const out = new Map();-- const list = [...new Set(subjects.filter(Boolean))];-- if (!list.length) return out;-- const rows = await db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id-- WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);-- for (const r of rows) {-- const def = BY_KEY.get(r.reaction);-- if (!def) continue;-- if (!out.has(r.s)) out.set(r.s, []);-- out.get(r.s).push({ key: def.key, emoji: def.emoji, label: def.label, count: r.n });-- }-- for (const [s, l] of out) out.set(s, l.sort((a, b) => b.count - a.count || ORDER.get(a.key) - ORDER.get(b.key)).slice(0, 3));-- return out;--}----module.exports = { REACTIONS, BY_KEY, setReaction, reactionsFor, receivedBy };ddeleted file mode 100644@@@ -1,58 +0,0 @@--'use strict';----/**-- * The Roadmap space (WS-J task 8): OpenVibe's public roadmap summary (docs/roadmap/public.json) as one-- * thread per item, written by the site itself (origin 'system', shown as OpenVibe). Every boot syncs the-- * file: a new item becomes a thread; a changed title, status, category or summary updates the item's-- * thread (the summary as an edit of its opening post, so the history stays); an item that left the file-- * keeps its thread. Items are matched by key (threads.external_key), so replies and votes stay with them.-- */--const fs = require('fs');--const path = require('path');--const store = require('./store');--const { STATUSES } = require('./service');----const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');--const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;----/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */--async function syncRoadmap(db, items, { log = console } = {}) {-- const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };-- const space = await store.getSpace(db, 'roadmap');-- if (!space) return out;-- for (const item of Array.isArray(items) ? items : []) {-- const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();-- const summary = String((item && item.summary) || '').trim();-- if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !summary || !STATUSES.roadmap.includes(item.status)) {-- out.skipped++;-- log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);-- continue;-- }-- const category = item.category ? await store.getCategory(db, space.id, item.category) : null;-- const categoryId = category ? category.id : null;-- const existing = await store.getThreadByKey(db, space.id, item.key);-- if (!existing) {-- await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });-- out.created++;-- continue;-- }-- let changed = false;-- if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {-- await db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);-- changed = true;-- }-- const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);-- if (opening && opening.body_markdown !== summary) { await store.editPost(db, opening.id, summary, 'system'); changed = true; }-- if (changed) out.updated++; else out.unchanged++;-- }-- return out;--}----/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */--async function syncFromFile(db, { file = FILE, log = console } = {}) {-- let doc;-- try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }-- return await syncRoadmap(db, doc.items, { log });--}----module.exports = { syncRoadmap, syncFromFile, FILE };ddeleted file mode 100644@@@ -1,319 +0,0 @@--'use strict';----/**-- * Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):-- *-- * GET /s spaces-- * GET /s/feed.xml RSS: latest threads in public spaces-- * GET /s/:space threads (?sort=hot|new|top&page=)-- * GET /s/:space/feed.xml RSS: latest threads of one public space-- * GET /s/:space/new POST /s/:space/new start a thread-- * GET /s/:space/t/:slug a thread (?page=)-- * POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms-- * POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)-- *-- * A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the-- * creator's join link (403), never its posts.-- *-- * Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the-- * visitor); an Origin header from somewhere else is refused as well.-- */--const express = require('express');--const multer = require('multer');--const seo = require('openvibe-shared/seo');--const cache = require('openvibe-shared/cache-policy');--const discovery = require('../discovery');--const { SITE_NAME } = require('../render/layout');--const pages = require('../render/pages');--const forumPages = require('../render/forum');--const boardPages = require('../render/board');--const { ApiError } = require('../http/v1');----function createForumRoutes({ forum, viewers, config }) {-- const router = express.Router();-- const withViewer = viewers.middleware({ services: false });-- const form = express.urlencoded({ extended: false, limit: '256kb' });-- // New threads and replies may carry up to 4 images (multipart `attachments`); a urlencoded form passes through.-- const imageParser = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 20, fieldSize: 64 * 1024 } }).array('attachments', 4);-- const withImages = (req, res, next) => imageParser(req, res, (err) => {-- if (err) req.uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') ? 'At most 4 images per post' : 'The images could not be read';-- next();-- });-- /** Store the form's images in Media first. → their media ids (the post then names them) */-- async function uploadImages(req) {-- if (req.uploadError) throw new ApiError(400, 'attachments.invalid', req.uploadError);-- const ids = [];-- for (const f of req.files || []) {-- if (!f || !f.size) continue;-- ids.push((await forum.uploadAttachment(req.viewer, req.params.space, f)).attachment.media_id);-- }-- return ids;-- }-- const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;-- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', cache.htmlHeaders({ private: true })).send(body);-- const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);-- const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);---- /** Render an ApiError as a page: sign-in for 401, the error page for the rest. */-- function failPage(req, res, err, next) {-- if (!(err instanceof ApiError)) return next(err);-- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|react|crosspost|settings|status|category)$/, ''));-- if (err.code === 'vip.members_only' && err.extra) {-- return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);-- }-- const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };-- return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);-- }---- function sameOrigin(req, res, next) {-- const origin = req.get('origin');-- if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);-- next();-- }---- // The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.-- router.get('/s', withViewer, wrap(async (req, res, next) => {-- try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }-- }));---- // Moderators: a new space, either style.-- router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {-- try {-- if (!req.user) return login(res, '/s/new-space');-- if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');-- html(res, boardPages.newSpacePage({ groups: await forum.groups() }));-- } catch (err) { failPage(req, res, err, next); }-- }));-- router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- const b = req.body || {};-- const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };-- try {-- const out = await forum.createSpace(req.viewer, values);-- discovery.resetCaches();-- res.redirect(303, `/s/${out.space.slug}`);-- } catch (err) {-- if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);-- html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);-- }-- }));---- // RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).-- const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({-- title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,-- link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,-- description: space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Space.',-- language: 'en',-- selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,-- items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),-- }, { format: 'rss' }));---- router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));---- router.get('/s/:space/feed.xml', wrap(async (req, res) => {-- const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);-- if (!space) return res.status(404).type('text/plain').send('Not found');-- await threadFeed(res, space);-- }));---- router.get('/s/:space', withViewer, wrap(async (req, res, next) => {-- try {-- const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });-- if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- const chatRoom = forumPages.chatRoomBox(out.space, { canManage: out.viewer.can_manage_chat_room, error: typeof req.query.chat_error === 'string' ? req.query.chat_error.slice(0, 200) : null });-- const me = req.viewer && req.viewer.subject;-- const modError = typeof req.query.mod_error === 'string' ? req.query.mod_error.slice(0, 200) : null;-- if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom, me, modError }));-- html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) + boardPages.moderatorsBox(out.space, out.moderators || [], { me, error: modError }) : '' }));-- } catch (err) { failPage(req, res, err, next); }-- }));---- router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {-- try {-- // A members-only space: the teaser, not the form, for someone who may not post there.-- const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });-- if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');-- const { space } = await forum.space(req.viewer, req.params.space);-- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled() }));-- } catch (err) { failPage(req, res, err, next); }-- }));---- router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {-- const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };-- if ((req.body || {}).members_only === '1') values.members_only = true;-- if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);-- try {-- // Images go to Media only when the rest can be saved (no strays from an empty form).-- if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }-- const out = await forum.createThread(req.viewer, req.params.space, values);-- discovery.resetCaches();-- res.redirect(303, out.thread.url);-- } catch (err) {-- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);-- try {-- const { space } = await forum.space(req.viewer, req.params.space);-- const { categories } = await forum.categories(req.viewer, req.params.space);-- html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);-- } catch (e) { failPage(req, res, e, next); }-- }-- }));---- async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {-- const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });-- if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- if (status === 200 && await forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;-- // Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.-- if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {-- try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }-- }-- const view = { ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() };-- html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);-- }---- router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {-- try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }-- }));---- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {-- const draft = String((req.body || {}).body || '').slice(0, 40_000);-- try {-- const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];-- const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined });-- res.redirect(303, out.url);-- } catch (err) {-- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);-- try {-- // The reply form lives on the thread's last page; show it there with the error and the draft.-- const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});-- req.query.page = String(probe.pages);-- await renderThread(req, res, { status: err.status, error: err.message, draft });-- } catch (e) { failPage(req, res, e, next); }-- }-- }));---- const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;---- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });-- res.redirect(303, back(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- // Members-only on/off (no JS): on gates the thread to its author's VIP members.-- router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });-- discovery.resetCaches();-- res.redirect(303, back(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- const b = req.body || {};-- const flags = {};-- if (b.pinned !== undefined) flags.pinned = b.pinned === '1';-- if (b.locked !== undefined) flags.locked = b.locked === '1';-- try {-- await forum.moderateThread(req.viewer, req.params.space, req.params.slug, flags);-- res.redirect(303, back(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- // Rate a post (no JS): the rating's button in the post's form; back to the post.-- router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- const b = req.body || {};-- try {-- await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });-- const page = Math.max(parseInt(b.page, 10) || 1, 1);-- res.redirect(303, `${back(req)}${page > 1 ? `?page=${page}` : ''}#post-${encodeURIComponent(String(b.post || ''))}`);-- } catch (err) { failPage(req, res, err, next); }-- }));---- // Crosspost to another space (no JS).-- router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });-- discovery.resetCaches();-- res.redirect(303, out.thread.url);-- } catch (err) { failPage(req, res, err, next); }-- }));---- // Moderators: a space's settings (no JS).-- router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- const b = req.body || {};-- try {-- // The group (the board index) is staff's; a space's own moderators change the rest.-- await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });-- discovery.resetCaches();-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);-- } catch (err) { failPage(req, res, err, next); }-- }));---- // The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's-- // moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.-- const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;-- const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'space.blocked' || err.code === 'identity.unavailable');-- router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));-- res.redirect(303, modsBack(req));-- } catch (err) {-- if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));-- failPage(req, res, err, next);-- }-- }));-- router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));-- res.redirect(303, modsBack(req));-- } catch (err) {-- if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));-- failPage(req, res, err, next);-- }-- }));---- // The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.-- const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;-- router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });-- res.redirect(303, chatBack(req));-- } catch (err) {-- if (err instanceof ApiError && String(err.code).startsWith('chat_room.')) return res.redirect(303, chatBack(req, err.message));-- failPage(req, res, err, next);-- }-- }));-- router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.detachChatRoom(req.viewer, req.params.space);-- res.redirect(303, chatBack(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- // Status (moderators) and category (the author or moderators), no JS.-- router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });-- res.redirect(303, back(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {-- try {-- await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });-- res.redirect(303, back(req));-- } catch (err) { failPage(req, res, err, next); }-- }));---- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {-- try {-- await forum.deleteThread(req.viewer, req.params.space, req.params.slug);-- discovery.resetCaches();-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);-- } catch (err) { failPage(req, res, err, next); }-- });---- return router;--}----module.exports = { createForumRoutes };ddeleted file mode 100644@@@ -1,994 +0,0 @@--'use strict';----/**-- * Forum rules — spaces, threads and posts on top of store.js. Used by the JSON API (api.js) and-- * the server-rendered pages (render/forum.js) alike, so both enforce exactly the same things.-- *-- * Spaces: public — anyone reads; people post-- * members — signed-in people read and post (every Network account is a member for now)-- * staff — discussion moderators only; looks missing to everyone else-- * Writers: a person (browser JWT subject, or a service with space.post.write naming them in-- * X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'-- * and no author, labelled as AI). Anonymous visitors read; they do not post.-- * Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose-- * author blocked them on the network (identity/blocks.js). Authors edit and delete their-- * own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.-- * A space also has its own moderators (space_moderators, people only): in that space they act as-- * discussion staff do — thread state and status, deletes and edits, settings, categories, members-only,-- * the chat room — and add or remove its moderators. Creating spaces, the board index (groups, a space's-- * place on it), roadmap items and staff spaces stay with discussion staff.-- * Edits keep every revision in post_versions.-- *-- * Side effects of a new thread/post: a space.thread.created / space.post.created event in the outbox-- * (../events.js) and a Discord relay delivery per mapping (relay/discord.js; off unless-- * DISCORD_RELAY_ENABLED; while its Events worker runs, it queues creates off those events instead).-- * Edits, deletes and gating queue the matching Discord edit or delete for whatever the relay had sent.-- * The network Pulse feed is OpenVibe.Community's (plan T10 D3): Space records no Pulse item.-- *-- * Members-only (OpenVibe.VIP): a space or a single thread can be for one creator's VIP members-- * (members_only_owner = the creator's usr_ subject). Reading its threads and posts, starting a-- * thread, replying, voting and editing need an active entitlement, asked of VIP-- * (POST /policies/evaluate with that owner, through vip/index.js's cache); a gated thread in a gated-- * space needs both. The owner and discussion moderators always pass; every doubt (signed out, VIP-- * down, no grant) is a 403 vip.members_only with the reason and a join link. Listings keep a gated-- * thread's title with its members_only flag, never a body; gated things never reach the Discord-- * relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author-- * gates it to their own members.-- *-- * A chat room (OpenVibe.Chat, WS-I task 4): a space's owner (its creator, or the creator whose members'-- * space it is) or discussion staff attach one room to the space; the space page links it. Chat checks-- * that the same person manages the room, with their own token (chat-rooms.js), so services cannot attach-- * (moderator services can detach). Space keeps the link: space_chat_rooms.-- */--const store = require('./store');--const events = require('../events');--const { applyVote, myVotes, parseVote } = require('../votes');--const { fail, isoTime } = require('../http/v1');--const { createAuthors } = require('../identity/authors');--const { createPersonLimiter } = require('../limits');--const { discussionModerator } = require('../identity/capabilities');--const { renderMarkdown, markdownToText } = require('../render/markdown');--const { isUserSubject } = require('../vip');--const { stripImageMetadata } = require('../media/strip-metadata');--const reactions = require('./reactions');--const blocks = require('../identity/blocks');--const STYLES = ['feed', 'forum'];--const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;----const THREADS_PER_PAGE = 25;--/**-- * What a thread is (spaces.thread_kind decides it for new threads) and the statuses it can have:-- * discussion no status-- * request a feature request, bug or question (Feedback): open → planned → in_progress → done, or declined; staff set it-- * roadmap a roadmap item (the Roadmap space; staff and the roadmap sync start them): planned, in_progress, done, paused-- */--const STATUSES = Object.freeze({-- request: ['open', 'planned', 'in_progress', 'done', 'declined'],-- roadmap: ['planned', 'in_progress', 'done', 'paused'],--});--const FIRST_STATUS = { request: 'open', roadmap: 'planned' };--const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;--// Attachments (WS-J task 2): images only, stored in OpenVibe.Media as med_ objects (media/objects.js).--const ATTACH_MAX = 4;--const ATTACH_BYTES = 8 * 1024 * 1024;--const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;--/** The image type from its first bytes (never the name or the declared type), or null. */--function sniffImage(b) {-- if (!Buffer.isBuffer(b) || b.length < 12) return null;-- if (b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image/png';-- if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';-- if (b.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';-- if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';-- return null;--}--const POSTS_PER_PAGE = 50;--const TITLE_MIN = 3, TITLE_MAX = 200;--const BODY_MAX = 40_000;--const THREADS_PER_DAY = 20;----function createForumService({ db, network = null, 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 || {}) });-- const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });-- const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });-- const reactLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'ratings', ...(limits.reactions || {}) });-- // Views: one per viewer (person, or address) per thread per 30 minutes; memory only.-- const viewSeen = new Map();-- 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);-- /** Global discussion staff, or a person (signed in themselves) this space lists as its moderator. */-- const moderates = async (v, space) => moderator(v)-- || (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));---- // ── a space's chat room (OpenVibe.Chat) ──────────────────-- const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);-- const shapeChatRoom = (r) => (r ? {-- slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,-- url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,-- attached_at: isoTime(r.attached_at),-- } : null);-- /** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */-- const canManageChatRoom = async (v, space) => await moderates(v, space) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));---- // ── access ───────────────────────────────────────────────-- function canRead(v, space) {-- if (space.visibility === 'public') return true;-- if (space.visibility === 'members') return person(v) || moderator(v);-- return moderator(v);-- }---- // ── members-only (OpenVibe.VIP) ──────────────────────────-- /** { owner, join_url } for a gated row, null for an open one. */-- async function membersOnly(owner, projections = null) {-- if (!owner) return null;-- const p = (projections || await authors.projectionsFor([owner])).get(owner);-- const username = p && p.username ? p.username : null;-- return { owner, owner_username: username, join_url: vip ? vip.joinUrl(owner, username) : null };-- }---- /**-- * Throw 403 vip.members_only unless the viewer may use every gated level named: the space and/or-- * the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).-- */-- async function requireMembership(v, space, thread = null) {-- if (await moderates(v, space)) return;-- const gates = [];-- if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });-- if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });-- for (const g of gates) {-- const subject = person(v) ? v.subject : null;-- const d = vip ? await vip.check({ subject, type: g.type, id: g.id, owner: g.owner })-- : (subject && subject === g.owner ? { allow: true } : { allow: false, reason: 'vip_unavailable' });-- if (d.allow) continue;-- const mo = await membersOnly(g.owner);-- fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {-- reason: d.reason || 'denied', gate: g.type, members_only: mo,-- space: { slug: space.slug, name: space.name, description: space.description || null },-- // A thread's title is shown only for its own gate: inside a members-only space it is not listed, and-- // post ids are sequential (GET /posts/:id/versions would name every thread in the space).-- thread: thread && g.type === 'thread' ? { slug: thread.slug, title: thread.title } : null,-- });-- }-- }---- /** A creator subject a viewer may gate something to: themselves, or anyone for moderators (mod: of the space). */-- function gateOwner(v, requested, fallbackOwner, mod) {-- if (requested === null || requested === false || requested === undefined) return null;-- const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);-- if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');-- if (!mod && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');-- return owner;-- }---- /** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */-- async function spaceFor(v, slug) {-- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;-- if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');-- if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');-- return space;-- }---- async function threadFor(v, spaceSlug, threadSlug) {-- const space = await spaceFor(v, spaceSlug);-- const thread = await store.getThreadBySlug(db, space.id, threadSlug);-- if (!thread) fail(404, 'thread.not_found', 'No such thread');-- return { space, thread };-- }---- async function postFor(v, postId) {-- const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;-- const thread = post ? await store.getThread(db, post.thread_id) : null;-- const space = thread ? await store.getSpaceById(db, thread.space_id) : null;-- if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');-- // A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).-- try { await spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }-- return { post, thread, space };-- }---- /** Who a write is attributed to. → { author, origin, key } (key: the rate-limit bucket) */-- function writer(v) {-- if (v && v.kind === 'service' && v.origin === 'ai') return { author: null, origin: 'ai', key: null };-- if (person(v)) return { author: v.subject, origin: 'user', key: `s:${v.subject}` };-- return fail(401, 'auth.required', 'Sign in with your OpenVibe account to post');-- }---- function mayPostIn(v, space) {-- if (space.visibility === 'staff' && !moderator(v)) fail(403, 'space.staff_only', 'Only staff post here');-- }---- const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();-- function cleanBody(b) {-- const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');-- if (!body.trim()) fail(400, 'post.empty', 'Write something first');-- if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);-- return body;-- }---- // ── shapes ───────────────────────────────────────────────-- const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;---- const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);---- async function shapeThread(t, space, v, projections, votes) {-- const mo = t.members_only_owner || null;-- const p = mo && projections ? projections.get(mo) : null;-- return {-- id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),-- kind: t.kind || 'discussion', status: t.status || null,-- category: t.category_id ? shapeCategory(await store.getCategoryById(db, t.category_id)) : null,-- members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,-- author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,-- pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,-- pages: Math.max(Math.ceil(((t.reply_count || 0) + 1) / POSTS_PER_PAGE), 1),-- last_post: t.last_post_id ? { id: t.last_post_id, author: authors.author(t.last_author_subject, t.last_origin, projections || new Map()) } : null,-- crosspost_of: t.crosspost_of || null,-- last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),-- my_vote: (votes && votes.get(t.id)) || 0,-- };-- }---- const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });-- /** post id → [attachment], in order. */-- async function attachmentsOf(postIds) {-- const out = new Map();-- if (!postIds.length) return out;-- const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);-- for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }-- return out;-- }---- /** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */-- async function claimable(w, ids) {-- if (ids === undefined || ids === null) return [];-- const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];-- if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);-- if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');-- const rows = (await Promise.all(list.map(async (id) => (MED_ID.test(id) ? await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null))));-- if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');-- return rows;-- }-- async function attach(rows, postId) {-- const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');-- for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);-- }---- function shapePost(p, v, projections, attachments = null, mod = moderator(v)) {-- const deleted = !!p.deleted_at;-- const mine = person(v) && p.author_subject === v.subject;-- return {-- attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),-- id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,-- author: deleted ? null : authors.author(p.author_subject, p.origin, projections, p.relay_author),-- body_markdown: deleted ? null : p.body_markdown,-- body_html: deleted ? null : renderMarkdown(p.body_markdown),-- revision: p.revision, deleted,-- created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),-- can_edit: !deleted && (mine || mod),-- };-- }---- async function shapeThreads(rows, spaceOf, v) {-- const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);-- const votes = await myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);-- return (await Promise.all(rows.map(async (r) => await shapeThread(r, spaceOf(r), v, projections, votes))));-- }---- async function shapeSpace(s, mo = null) {-- return {-- slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,-- thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],-- style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,-- group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,-- parent: s.parent_slug ? { slug: s.parent_slug, name: s.parent_name } : null,-- post_count: s.post_count != null ? s.post_count : undefined,-- members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,-- thread_count: s.thread_count != null ? s.thread_count : undefined,-- last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,-- chat_room: shapeChatRoom(await chatRoomRow(s.id)),-- };-- }---- /**-- * Newly gated threads leave Discord at once: what the relay had not sent yet is skipped and-- * what it had sent is deleted there (reads and sends re-check as well).-- */-- async function hideGated(threadIds) {-- if (!threadIds.length) return;-- if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));-- }---- async function removeThread(v, thread, space) {-- const mine = person(v) && thread.author_subject === v.subject;-- if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');-- await db.tx(async () => {-- await store.softDeleteThread(db, thread.id);-- if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });-- });-- if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));-- pingThread(space, thread, { withSpace: true });-- return { ok: true, id: thread.id, deleted: 'thread' };-- }---- /**-- * Where a thread came from and where else it was crossposted, as far as the viewer may see.-- * → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }-- */-- async function crosspostInfo(v, thread) {-- const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));-- let from = null;-- if (thread.crosspost_of) {-- const t = await store.getThread(db, thread.crosspost_of);-- const sp = t ? await store.getSpaceById(db, t.space_id) : null;-- if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };-- }-- const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))-- .map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))-- .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));-- const targets = person(v) && !thread.members_only_owner-- ? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))-- .map((sp) => ({ slug: sp.slug, name: sp.name }))-- : [];-- return { from, to, targets };-- }---- /** Where a space sits on the board index (breadcrumbs). → { group, parent } */-- async function placeOf(space) {-- const g = space.group_id ? await db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;-- const p = space.parent_id ? await db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;-- return { group: g || null, parent: p || null };-- }---- /** A space's moderators, with who they are. → [{ subject, username, display_name, added_by, added_at }] */-- async function moderatorList(space) {-- const rows = await store.listModerators(db, space.id);-- const projections = await authors.projectionsFor(rows.map((r) => r.subject_id));-- return rows.map((r) => {-- const a = authors.author(r.subject_id, 'user', projections);-- return { subject: r.subject_id, username: a ? a.username : null, display_name: a ? a.display_name : null, added_by: r.added_by || null, added_at: isoTime(r.created_at) };-- });-- }---- /**-- * The person a moderator form or API call names: a Network user subject (usr_…) as it is, or an @username-- * resolved through the Network (identity.subject.resolve). 400 when it is neither, 404 when nobody holds the name.-- */-- async function moderatorSubject(who) {-- const s = String(who || '').trim();-- if (isUserSubject(s)) return s;-- if (!/^@?[A-Za-z0-9_.-]{1,40}$/.test(s)) fail(400, 'moderator.invalid_subject', 'Name the person by their @username');-- let subject = null;-- if (network && network.subjectForUsername) {-- try { subject = await network.subjectForUsername(s); }-- catch { fail(503, 'identity.unavailable', 'Accounts cannot be looked up right now; try again in a minute'); }-- }-- if (!subject) fail(404, 'moderator.unknown_user', `No OpenVibe account is called @${s.replace(/^@/, '')}`);-- return subject;-- }---- /** The space, when the viewer moderates it: 404 (no such space), else 403. */-- async function moderatedSpace(v, slug, what) {-- const space = await spaceFor(v, slug);-- if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);-- return space;-- }---- /** Validated space fields from a settings body (only what was sent). */-- async function spaceFields(body, space) {-- const f = {};-- if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }-- if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;-- if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum'); f.style = body.style; }-- for (const k of ['votes', 'reactions']) if (body[k] !== undefined) f[k] = (body[k] === true || body[k] === 1 || body[k] === '1' || body[k] === 'true') ? 1 : 0;-- if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;-- if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }-- if (body.group !== undefined) {-- const g = body.group ? await db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;-- if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');-- f.group_id = g ? g.id : null;-- }-- if (body.parent !== undefined) {-- const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;-- if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');-- if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');-- if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');-- f.parent_id = p ? p.id : null;-- }-- return f;-- }---- // ═════════════════════════════════════════════════════════-- return {-- SORTS: store.SORTS,-- THREADS_PER_PAGE,-- POSTS_PER_PAGE,---- isModerator: moderator,-- /** Whether the viewer moderates this space (global discussion staff, or one of its own moderators). */-- async canModerate(v, slug) {-- if (moderator(v)) return true;-- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;-- return !!space && await moderates(v, space);-- },---- /** A space's own moderators — whoever can read the space. → { space, moderators } */-- async moderators(v, spaceSlug) {-- const space = await spaceFor(v, spaceSlug);-- await requireMembership(v, space);-- return { space: space.slug, moderators: await moderatorList(space) };-- },---- /**-- * Add a person as one of the space's moderators — its moderators or discussion staff. A Network user-- * subject (usr_…); not someone who blocked the one adding them, or whom they blocked. Idempotent.-- */-- async addModerator(v, spaceSlug, who) {-- const space = await moderatedSpace(v, spaceSlug, 'add moderators');-- const subject = await moderatorSubject(who);-- if (person(v) && v.subject !== subject && (await blocks.hasBlocked(db, subject, v.subject) || await blocks.hasBlocked(db, v.subject, subject))) {-- fail(403, 'space.blocked', 'You cannot add this person as a moderator: one of you blocked the other');-- }-- if (!await store.moderatesSpace(db, space.id, subject)) {-- await db.tx(async () => {-- await store.addModerator(db, space.id, subject, person(v) ? v.subject : (v && v.service) || null);-- await events.moderationAction(v, 'space.moderator_added', { type: 'space', id: space.slug }, { details: { subject } });-- });-- }-- return { space: space.slug, moderators: await moderatorList(space) };-- },---- /** Remove one of the space's moderators (themselves included) — its moderators or discussion staff. Idempotent. */-- async removeModerator(v, spaceSlug, who) {-- const space = await moderatedSpace(v, spaceSlug, 'remove moderators');-- const subject = await moderatorSubject(who);-- await db.tx(async () => {-- if (await store.removeModerator(db, space.id, subject)) await events.moderationAction(v, 'space.moderator_removed', { type: 'space', id: space.slug }, { details: { subject } });-- });-- return { space: space.slug, moderators: await moderatorList(space) };-- },-- /** The board index's groups (for the settings and new-space forms). */-- groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),---- /** Whether images can be attached (Media and the service principal are configured). */-- attachmentsEnabled: () => !!(media && media.configured),---- /** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */-- async listSpaces(v) {-- const vis = ['public'];-- if (person(v) || moderator(v)) vis.push('members');-- if (moderator(v)) vis.push('staff');-- const rows = await store.listSpaces(db, vis);-- const last = await store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));-- const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);-- const shaped = await Promise.all(rows.map(async (r) => {-- const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));-- const l = last.get(r.id);-- sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;-- return sp;-- }));-- // The board index: groups in order, each with its top-level spaces; child boards under their parent.-- const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));-- for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);-- const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));-- const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))-- .filter((g) => g.spaces.length);-- const other = top.filter((sp) => !sp.group);-- if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });-- return { spaces: shaped, groups };-- },---- /** The board index's groups — moderators create or change one { name, description?, position? }. */-- async putGroup(v, slug, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');-- if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');-- const name = cleanTitle(body.name);-- if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');-- await db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)-- ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)-- .run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);-- return { group: await db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };-- },---- /**-- * A space's settings — moderators. { name?, description?, style?: feed|forum, votes?, reactions?, group?: slug|null,-- * parent?: slug|null, position?, kind?: discussion|request|roadmap }-- */-- async updateSpaceSettings(v, spaceSlug, body = {}) {-- const space = await moderatedSpace(v, spaceSlug, 'change it');-- // Where a space sits on the board index (its group, parent board and position) is the board index's: staff.-- if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');-- 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)) };-- },---- /** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */-- async createSpace(v, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');-- const slug = String(body.slug || '').trim().toLowerCase();-- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');-- if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');-- const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';-- const fields = await spaceFields({ ...body, name: body.name }, null);-- if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');-- const style = fields.style || 'feed';-- await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),-- 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) };-- },---- async space(v, slug) {-- const s = await spaceFor(v, slug);-- return { space: await shapeSpace(s, await membersOnly(s.members_only_owner)) };-- },---- /**-- * Attach a chat room to the space { room: slug or openvibe.chat link } — the space's owner or staff,-- * signed in themselves (Chat checks they manage the room). Attaching the same room again is a no-op;-- * another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }-- */-- async attachChatRoom(v, spaceSlug, body = {}) {-- const space = await spaceFor(v, spaceSlug);-- if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');-- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');-- if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');-- if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');-- const slug = chatRooms.parseRoomRef(body.room);-- if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');-- const before = await chatRoomRow(space.id);-- const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });-- const same = !!before && before.room_slug === out.room.slug;-- await db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,-- room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,-- attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,-- attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`)-- .run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);-- 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 };-- },---- /** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */-- async detachChatRoom(v, spaceSlug) {-- const space = await spaceFor(v, spaceSlug);-- if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');-- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');-- const before = await chatRoomRow(space.id);-- if (!before) return { detached: false, chat: null };-- 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 };-- },---- /** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */-- async listThreads(v, spaceSlug, q = {}) {-- const space = await spaceFor(v, spaceSlug);-- await requireMembership(v, space);-- const forumStyle = space.style === 'forum';-- let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');-- if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';-- const page = Math.max(parseInt(q.page, 10) || 1, 1);-- const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);-- const category = q.category ? await store.getCategory(db, space.id, q.category) : null;-- if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');-- const statuses = STATUSES[space.thread_kind] || [];-- if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');-- const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });-- const index = await this.listSpaces(v);-- const self = index.spaces.find((x) => x.slug === space.slug) || {};-- const mod = await moderates(v, space);-- return {-- space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },-- sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),-- categories: (await store.listCategories(db, space.id)).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,-- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: mod, signed_in: person(v), can_manage_chat_room: await canManageChatRoom(v, space) },-- moderators: mod ? await moderatorList(space) : undefined,-- children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),-- groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,-- threads: await shapeThreads(rows, () => space, v),-- };-- },---- /**-- * Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.-- * People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in-- * OpenVibe.Media as a public med_ object the person owns. Name it in `attachments` when posting.-- */-- async uploadAttachment(v, spaceSlug, file) {-- if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');-- const space = await spaceFor(v, spaceSlug);-- if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');-- mayPostIn(v, space);-- await requireMembership(v, space);-- if (!file || !Buffer.isBuffer(file.buffer) || !file.buffer.length) fail(400, 'attachments.missing', 'Choose an image');-- if (file.buffer.length > ATTACH_BYTES) fail(413, 'attachments.too_large', 'Images are limited to 8 MB');-- const mime = sniffImage(file.buffer);-- if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');-- uploadLimiter.check(`s:${v.subject}`);-- const buffer = stripImageMetadata(file.buffer, mime);-- const filename = String(file.originalname || 'image').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'image';-- let stored;-- try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {-- console.warn('[Forum] attachment upload failed:', err.message);-- fail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');-- }-- uploadLimiter.record(`s:${v.subject}`);-- await db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')-- .run(stored.id, v.subject, filename, mime, buffer.length, stored.url);-- return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };-- },---- /**-- * Rate a post { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late | null }.-- * One rating per person per post: the same one again takes it back, another replaces it. Not your own post,-- * not in a space with ratings off, not on a locked thread. → { post_id, mine, reactions }-- */-- async react(v, postId, body = {}) {-- if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');-- const { post, thread, space } = await postFor(v, postId);-- if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');-- await requireMembership(v, space, thread);-- if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');-- if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');-- const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);-- if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);-- reactLimiter.check(`s:${v.subject}`);-- const mine = await reactions.setReaction(db, post.id, v.subject, reaction);-- reactLimiter.record(`s:${v.subject}`);-- const list = (await reactions.reactionsFor(db, [post.id], v.subject)).get(post.id) || [];-- return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };-- },---- /**-- * Crosspost a thread to another space { to: slug } — people. The new thread links back to the original and-- * quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).-- */-- async crosspost(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- const w = writer(v);-- if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');-- await requireMembership(v, space, thread);-- if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');-- const target = await spaceFor(v, String(body.to || ''));-- if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');-- mayPostIn(v, target);-- await requireMembership(v, target);-- const kind = target.thread_kind || 'discussion';-- if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');-- if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);-- const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);-- const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');-- const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;-- const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,-- kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });-- 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) };-- },---- /** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */-- async quote(v, postId) {-- const { post, thread, space } = await postFor(v, postId);-- await requireMembership(v, space, thread);-- const projections = await authors.projectionsFor([post.author_subject]);-- const a = authors.author(post.author_subject, post.origin, projections, post.relay_author);-- const name = a ? (a.username ? `@${a.username}` : a.display_name || 'Someone') : 'Someone';-- const quoted = String(post.body_markdown || '').split('\n').slice(0, 40).map((l) => `> ${l}`).join('\n');-- return { markdown: `**${name}** wrote:\n${quoted}\n\n` };-- },---- /** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */-- async recordView(threadId, viewerKey) {-- const key = `${threadId}|${viewerKey || '?'}`;-- const now = Date.now();-- const seen = viewSeen.get(key);-- if (seen && now - seen < 30 * 60 * 1000) return false;-- if (viewSeen.size > 50000) viewSeen.clear();-- viewSeen.set(key, now);-- await store.bumpViews(db, threadId);-- return true;-- },---- /** The categories of a space. */-- async categories(v, spaceSlug) {-- const space = await spaceFor(v, spaceSlug);-- return { space: space.slug, categories: (await store.listCategories(db, space.id)).map(shapeCategory) };-- },---- /** Create or change a category { slug, name, description?, position? } — moderators. */-- async putCategory(v, spaceSlug, slug, body = {}) {-- const space = await moderatedSpace(v, spaceSlug, 'manage categories');-- if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');-- const name = cleanTitle(body.name);-- 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;-- 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. */-- async deleteCategory(v, spaceSlug, slug) {-- const space = await moderatedSpace(v, spaceSlug, 'manage categories');-- if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');-- pingSpace(space);-- return { ok: true };-- },---- /** Move a thread to a category { category: slug | null } — its author or a moderator. */-- async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');-- await requireMembership(v, space, thread);-- const category = body.category ? await store.getCategory(db, space.id, body.category) : null;-- 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) };-- },---- /** A request's or roadmap item's status { status } — moderators. */-- async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');-- const statuses = STATUSES[thread.kind] || [];-- 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) };-- },---- /** A thread with a page of its posts. ?page= */-- async getThread(v, spaceSlug, threadSlug, q = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- await requireMembership(v, space, thread);-- const page = Math.max(parseInt(q.page, 10) || 1, 1);-- const { rows, total } = await store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });-- const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);-- const votes = await myVotes(db, 'thread', [thread.id], v && v.subject);-- const ids = rows.map((p) => p.id);-- const att = await attachmentsOf(ids);-- const rated = space.reactions !== 0 ? await reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();-- const authorSubjects = rows.map((p) => p.author_subject);-- const stats = await store.authorStats(db, authorSubjects);-- const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();-- const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));-- const mod = await moderates(v, space);-- return {-- space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },-- thread: await shapeThread(thread, space, v, projections, votes),-- posts: rows.map((p) => {-- const sp = shapePost(p, v, projections, att, mod);-- const st = p.author_subject ? stats.get(p.author_subject) : null;-- sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;-- sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,-- raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));-- sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || mod);-- return sp;-- }),-- reactions: space.reactions !== 0 ? reactions.REACTIONS : [],-- crosspost: await crosspostInfo(v, thread),-- categories: (await store.listCategories(db, space.id)).map(shapeCategory),-- attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },-- page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,-- viewer: {-- signed_in: person(v),-- can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || mod) && (space.visibility !== 'staff' || moderator(v)),-- can_vote: person(v) && !thread.locked && space.votes !== 0,-- can_moderate: mod,-- can_delete: mod || (person(v) && thread.author_subject === v.subject),-- can_gate: mod || (person(v) && thread.author_subject === v.subject),-- },-- };-- },---- /**-- * New thread { title, body, members_only? } → { thread, post }. members_only: true gates it to-- * the author's own VIP members; { owner } names the creator (moderators, or the author themselves).-- */-- async createThread(v, spaceSlug, body = {}) {-- const space = await spaceFor(v, spaceSlug);-- const w = writer(v);-- mayPostIn(v, space);-- await requireMembership(v, space);-- const gate = gateOwner(v, body.members_only, w.author, await moderates(v, space));-- const kind = space.thread_kind || 'discussion';-- if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');-- const category = body.category ? await store.getCategory(db, space.id, body.category) : null;-- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');-- const title = cleanTitle(body.title);-- if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);-- const text = cleanBody(body.body != null ? body.body : body.body_markdown);-- const images = await claimable(w, body.attachments);-- if (w.key) {-- threadLimiter.check(w.key, title);-- if (threadsPerDay > 0 && await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);-- }-- const { thread, post } = await store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,-- kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });-- threadLimiter.record(w.key, title);-- await attach(images, post.id);-- 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])) };-- },---- /** Reply { body } → { post } */-- async reply(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- const w = writer(v);-- mayPostIn(v, space);-- await requireMembership(v, space, thread);-- if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');-- // Platform blocks: nobody replies in a thread whose author blocked them.-- await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');-- const text = cleanBody(body.body != null ? body.body : body.body_markdown);-- const images = await claimable(w, body.attachments);-- postLimiter.check(w.key, text);-- const post = await store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });-- postLimiter.record(w.key, text);-- await attach(images, post.id);-- if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));-- const projections = await authors.projectionsFor([post.author_subject]);-- // Where the new post lands: its page in the thread (posts are numbered in id order).-- const position = (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])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };-- },---- /** Edit { body } — the author (not on a locked thread) or a moderator. */-- async editPost(v, postId, body = {}) {-- const { post, thread, space } = await postFor(v, postId);-- await requireMembership(v, space, thread);-- const mine = person(v) && post.author_subject === v.subject;-- const mod = await moderates(v, space);-- if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');-- if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');-- 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, null, null, mod) };-- },---- /** Edit history — the author or a moderator. */-- async postVersions(v, postId) {-- const { post, thread, space } = await postFor(v, postId);-- await requireMembership(v, space, thread);-- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');-- const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));-- return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };-- },---- /** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */-- async deletePost(v, postId) {-- const { post, thread, space } = await postFor(v, postId);-- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');-- if (post.is_opening) return await removeThread(v, thread, space);-- await db.tx(async () => {-- await store.softDeletePost(db, post.id);-- if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.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 };-- },---- /** Delete a thread — its author or a moderator. */-- async deleteThread(v, spaceSlug, threadSlug) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- return await removeThread(v, thread, space);-- },---- /** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */-- async voteThread(v, spaceSlug, threadSlug, body = {}) {-- const value = parseVote(body.value);-- if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');-- if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');-- await requireMembership(v, space, thread);-- if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');-- voteLimiter.check(`s:${v.subject}`);-- const out = await applyVote(db, 'thread', thread.id, v.subject, value);-- voteLimiter.record(`s:${v.subject}`);-- return { thread_id: thread.id, ...out };-- },---- /** Pin / lock { pinned?, locked? } — moderators. */-- async moderateThread(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');-- const flags = {};-- if (body.pinned !== undefined) flags.pinned = !!body.pinned;-- if (body.locked !== undefined) flags.locked = !!body.locked;-- if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');-- const next = await db.tx(async () => {-- const r = await store.setThreadFlags(db, thread.id, flags);-- if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });-- return r;-- });-- const projections = await authors.projectionsFor([next.author_subject]);-- pingThread(space, next);-- return { thread: await shapeThread(next, space, v, projections, null) };-- },---- /**-- * Members-only for a thread { owner: 'usr_…' | null } (or { members_only: … }). Its author gates-- * it to themselves (and opens it again); moderators name any creator. Gating cancels pending-- * Discord relay deliveries.-- */-- async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- const mine = person(v) && thread.author_subject === v.subject;-- const mod = await moderates(v, space);-- if (!mine && !mod) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');-- await requireMembership(v, space, null);-- const requested = body.members_only !== undefined ? body.members_only : body.owner;-- const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject, mod);-- if (!owner && thread.members_only_owner && !mod && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');-- 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) };-- },---- /** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */-- async setSpaceMembersOnly(v, spaceSlug, body = {}) {-- const space = await moderatedSpace(v, spaceSlug, 'make it members-only');-- const requested = body.members_only !== undefined ? body.members_only : body.owner;-- const owner = gateOwner(v, requested === undefined ? null : requested, null, true);-- 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)) };-- },---- /** Latest threads in public spaces, with their opening post (sitemap, feeds). */-- async recentPublic({ limit = 50, space = null } = {}) {-- const rows = await store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });-- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');-- return (await Promise.all(rows.map(async (t) => ({ ...t, opening: (await opening.get(t.id) || {}).body_markdown || '' }))));-- },---- /** Public, open spaces (sitemap, feeds): members-only spaces are left out. */-- async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },-- };--}----module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage, STYLES, REACTIONS: reactions.REACTIONS };ddeleted file mode 100644@@@ -1,330 +0,0 @@--'use strict';----/**-- * Forum store — pure SQL over spaces / threads / posts / post_versions (server/db.js). Policy-- * lives in service.js, votes in server/votes.js.-- *-- * A thread's opening post is its first post (is_opening = 1); reply_count counts the other-- * live posts. Deletes are soft: a deleted post keeps its place (a tombstone) so the posts-- * after it keep their numbering; a deleted thread disappears from every listing.-- */----const SORTS = ['hot', 'new', 'top', 'active'];----// ── Spaces ───────────────────────────────────────────────────----async function listSpaces(db, visibilities) {-- return await db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,-- ps.slug AS parent_slug, ps.name AS parent_name,-- (SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,-- (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,-- (SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at-- FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id-- WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})-- ORDER BY g.position IS NULL, g.position, s.position, s.id ASC`).all(...visibilities);--}----/**-- * The newest live post in each space (the board index's "Last post"), members-only threads never.-- * → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }-- */--async function lastPosts(db, spaceIds) {-- const out = new Map();-- const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id-- FROM posts p JOIN threads t ON t.id = p.thread_id-- WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL-- ORDER BY p.id DESC LIMIT 1`);-- for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }-- return out;--}----async function listGroups(db) {-- return await db.prepare('SELECT * FROM space_groups ORDER BY position, lower(name)').all();--}----/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */--async function updateSpace(db, id, fields) {-- const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];-- const keys = Object.keys(fields).filter((k) => allowed.includes(k));-- if (keys.length) await db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });-- return await getSpaceById(db, id);--}----async function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {-- await db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);-- return await getSpace(db, slug);--}----/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */--async function authorStats(db, subjects) {-- const out = new Map();-- const list = [...new Set(subjects.filter(Boolean))];-- if (!list.length) return out;-- const rows = await db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts-- WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);-- for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });-- return out;--}----/** One more view of a thread (the forum's Views column). */--async function bumpViews(db, threadId) {-- await db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);--}----async function getSpace(db, slug) {-- return await db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;--}----async function getSpaceById(db, id) {-- return await db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;--}----// ── Threads ──────────────────────────────────────────────────----/** URL slug from a title: lowercase ascii words joined by '-', at most 60 characters. */--function slugify(title) {-- const s = String(title || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()-- .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '');-- return s || 'thread';--}----async function uniqueSlug(db, spaceId, base) {-- const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');-- if (!await taken.get(spaceId, base)) return base;-- for (let n = 2; n < 10_000; n++) {-- const candidate = `${base.slice(0, 54)}-${n}`;-- if (!await taken.get(spaceId, candidate)) return candidate;-- }-- throw new Error('Could not find a free thread slug');--}----async function getThread(db, id) {-- return await db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;--}----async function getThreadBySlug(db, spaceId, slug) {-- return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;--}----/** New thread + its opening post, in one transaction. → { thread, post } */--async function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {-- return await db.tx(async () => {-- const slug = await uniqueSlug(db, space_id, slugify(title));-- const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')-- .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);-- const threadId = info.lastInsertRowid;-- const p = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?) RETURNING id')-- .run(threadId, author_subject, origin, body_markdown);-- const thread = await getThread(db, threadId);-- const space = await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);-- // A members-only (VIP) space's threads are not public either.-- await require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // space.thread.created-- return { thread, post: await getPost(db, p.lastInsertRowid) };-- });--}----/**-- * One page of a space's threads. Pinned threads lead every sort.-- * hot ov_hot(score, age in hours at `now`) — see server/db.js for the formula-- * new newest first-- * top highest score first-- * → { rows, total }-- */--async function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {-- const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);-- const order = {-- hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',-- new: 'pinned DESC, created_at DESC, id DESC',-- top: 'pinned DESC, score DESC, created_at DESC, id DESC',-- active: 'pinned DESC, last_activity_at DESC, id DESC',-- }[SORTS.includes(sort) ? sort : 'hot'];-- const where = 'space_id = @space AND deleted_at IS NULL AND (@category::bigint IS NULL OR category_id = @category) AND (@status::text IS NULL OR status = @status)';-- const params = { space: spaceId, category: categoryId, status };-- // last_* : the newest live post, for the forum's "Last post" column.-- const rows = await db.prepare(`SELECT threads.*,-- (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,-- (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,-- (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id-- FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)-- .all({ ...params, now: nowSql, limit, offset });-- const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);-- return { rows, total };--}----// ── Categories (WS-J task 1) ─────────────────────────────────----async function listCategories(db, spaceId) {-- return await db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count-- FROM categories c WHERE c.space_id = ? ORDER BY c.position, lower(c.name)`).all(spaceId);--}----async function getCategory(db, spaceId, slug) {-- return await db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;--}----async function getCategoryById(db, id) {-- return id ? await db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;--}----async function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {-- await db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)-- .run(spaceId, slug, name, description, position);-- return await getCategory(db, spaceId, slug);--}----/** Threads keep their place; they just lose the category. */--async function deleteCategory(db, spaceId, slug) {-- return await db.tx(async () => {-- const c = await getCategory(db, spaceId, slug);-- if (!c) return false;-- await db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);-- await db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);-- return true;-- });--}----async function setThreadCategory(db, id, categoryId) {-- await db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);-- return await getThread(db, id);--}----async function setThreadStatus(db, id, status) {-- await db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);-- return await getThread(db, id);--}----async function getThreadByKey(db, spaceId, key) {-- return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;--}----/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */--async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {-- return await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id-- WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (?::text IS NULL OR s.slug = ?)-- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL-- ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);--}----async function setThreadFlags(db, id, { pinned, locked }) {-- const sets = [], params = [];-- if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }-- if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }-- if (sets.length) await db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);-- return await getThread(db, id);--}----/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */--async function setThreadMembersOnly(db, id, owner) {-- await db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);-- return await getThread(db, id);--}--async function setSpaceMembersOnly(db, id, owner) {-- await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);-- return await getSpaceById(db, id);--}----async function softDeleteThread(db, id) {-- return (await db.prepare('UPDATE threads SET deleted_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(id)).changes;--}----/** Threads a subject opened since `sinceSql` (a datetime modifier, e.g. '-1 day'); deleted ones count. */--async function countThreadsSince(db, subject, sinceSql) {-- return (await db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;--}----// ── Posts ────────────────────────────────────────────────────----async function getPost(db, id) {-- return await db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;--}----/**-- * A reply; bumps the thread's reply_count and last activity. relay_author: the name a reply written-- * on Discord carries (origin 'discord', no author subject; server/relay/inbound.js).-- */--async function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {-- return await db.tx(async () => {-- const info = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?) RETURNING id')-- .run(thread_id, author_subject, origin, body_markdown, relay_author);-- await db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = ov_now() WHERE id = ?').run(thread_id);-- const post = await getPost(db, info.lastInsertRowid);-- const thread = await getThread(db, thread_id);-- const space = thread && await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);-- if (thread) await require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // space.post.created-- return post;-- });--}----/** Page of a thread's posts in order (tombstones included). → { rows, total } */--async function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {-- const rows = await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);-- const { total } = await db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);-- return { rows, total };--}----/**-- * Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original-- * as revision 1, so the history is complete without copying posts nobody edits (as pastes do).-- */--async function editPost(db, id, body, editedBy = null) {-- return await db.tx(async () => {-- const cur = await getPost(db, id);-- if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;-- if (!await db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {-- await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')-- .run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);-- }-- await db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = ov_now() WHERE id = ?').run(body, id);-- const next = await getPost(db, id);-- await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);-- return next;-- });--}----async function listPostVersions(db, postId) {-- return await db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);--}----/** Soft delete (body scrubbed); the thread's reply_count is recounted. */--async function softDeletePost(db, id) {-- return await db.tx(async () => {-- const p = await getPost(db, id);-- if (!p || p.deleted_at) return 0;-- await db.prepare("UPDATE posts SET deleted_at = ov_now(), body_markdown = '', updated_at = ov_now() WHERE id = ?").run(id);-- await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);-- return 1;-- });--}--// ── per-space moderators ─────────────────────────────────────--async function listModerators(db, spaceId) {-- return await db.prepare('SELECT subject_id, added_by, created_at FROM space_moderators WHERE space_id = ? ORDER BY created_at, subject_id').all(spaceId);--}----async function moderatesSpace(db, spaceId, subject) {-- if (!subject) return false;-- return !!await db.prepare('SELECT 1 FROM space_moderators WHERE space_id = ? AND subject_id = ?').get(spaceId, subject);--}----async function addModerator(db, spaceId, subject, addedBy) {-- await db.prepare('INSERT INTO space_moderators (space_id, subject_id, added_by) VALUES (?, ?, ?) ON CONFLICT (space_id, subject_id) DO NOTHING').run(spaceId, subject, addedBy || null);--}----async function removeModerator(db, spaceId, subject) {-- return (await db.prepare('DELETE FROM space_moderators WHERE space_id = ? AND subject_id = ?').run(spaceId, subject)).changes > 0;--}----async function countPostsSince(db, subject, sinceSql) {-- return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;--}----module.exports = {-- SORTS, slugify,-- listSpaces, getSpace, getSpaceById,-- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,-- listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,-- lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,-- listModerators, moderatesSpace, addModerator, removeModerator,-- getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,--};ddeleted file mode 100644@@@ -1,111 +0,0 @@--'use strict';----/**-- * Shared pieces of the /api/v1/* routers (spaces, posts, space-groups, relay): errors as RFC 9457-- * problems (contracts errors.problem@1, which keeps the legacy { error } field), capability-- * guards for service tokens, JSON bodies, cursors and CORS for the embeddable read/write APIs.-- */--const express = require('express');--const contracts = require('openvibe-contracts');--const { checkCapability } = require('../identity/capabilities');----/** A refusal with a stable problem code (e.g. 404 'thread.not_found'). */--class ApiError extends Error {-- constructor(status, code, detail, extra) {-- super(detail || code);-- this.name = 'ApiError';-- this.status = status;-- this.code = code;-- this.extra = extra || null;-- }--}--const fail = (status, code, detail, extra) => { throw new ApiError(status, code, detail, extra); };----/** Wrap a handler: its return value is the JSON body; ApiErrors become problems. */--function run(fn, status = 200) {-- return async (req, res) => {-- try {-- const out = await fn(req, res);-- if (out === undefined || res.headersSent) return;-- res.status(typeof status === 'function' ? status(out) : status).json(out);-- } catch (err) {-- if (res.headersSent) return;-- if (err instanceof ApiError) {-- if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));-- return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });-- }-- console.error('[API]', err && err.stack ? err.stack : err);-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });-- }-- };--}----/** A service token must hold `cap`; browsers and anonymous callers pass (judged by identity later). */--function serviceCap(cap) {-- return (req, res, next) => {-- const v = req.viewer;-- if (!v || v.kind !== 'service') return next();-- const c = checkCapability(v.claims, cap);-- if (c.allowed) return next();-- return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });-- };--}----/** A service token must hold at least one of `caps` (reads: a token meant for something else is no pass). */--function serviceAnyCap(caps) {-- return (req, res, next) => {-- const v = req.viewer;-- if (!v || v.kind !== 'service' || caps.some((c) => checkCapability(v.claims, c).allowed)) return next();-- return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `needs one of ${caps.join(', ')}`, ctx: req.ov });-- };--}----const jsonParser = express.json({ limit: '256kb' });--/** JSON body parser whose syntax errors are problems too. */--function jsonBody(req, res, next) {-- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));--}----// ── Cursors: opaque base64url of a small JSON tuple ──────────--function encodeCursor(values) { return Buffer.from(JSON.stringify(values)).toString('base64url'); }--function decodeCursor(cursor, arity) {-- if (cursor == null || cursor === '') return null;-- try {-- const v = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));-- if (Array.isArray(v) && v.length === arity) return v;-- } catch { /* fall through */ }-- return fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');--}----/**-- * CORS for the embeddable APIs: other OpenVibe sites call them from the browser with the-- * visitor's Network JWT as a Bearer token (never with cookies — no credentials mode).-- */--function cors(origins) {-- const allowed = new Set(origins);-- return (req, res, next) => {-- const origin = req.get('origin');-- if (origin && allowed.has(origin)) {-- res.set('Access-Control-Allow-Origin', origin);-- res.set('Vary', 'Origin');-- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');-- res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');-- res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');-- res.set('Access-Control-Max-Age', '600');-- }-- if (req.method === 'OPTIONS') return res.status(204).end();-- next();-- };--}----const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);----/** Text timestamps 'YYYY-MM-DD HH:MM:SS' (UTC) ↔ ISO. */--function sqlTime(date = new Date()) { return new Date(date).toISOString().replace('T', ' ').slice(0, 19); }--function isoTime(v) {-- if (!v) return null;-- const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);-- return Number.isNaN(d.getTime()) ? null : d.toISOString();--}----module.exports = { ApiError, fail, run, serviceCap, serviceAnyCap, jsonBody, encodeCursor, decodeCursor, cors, intIn, sqlTime, isoTime };ddeleted file mode 100644@@@ -1,154 +0,0 @@--'use strict';--/**-- * Account export and deletion → Space (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at the events-- * consumer (POST /internal/events) and are applied once per export or deletion (account_data_events); the delivery is-- * answered after Network took the part or the confirmation, so a failure is redelivered without erasing twice.-- *-- * network.account.export_requested Space's part (POST /internal/account-exports/:id/parts with a service token):-- * threads, posts, attachments, votes and reactions, blocks, the cached profile-- * and the spaces they moderate.-- * network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with-- * someone else's reply anywhere beneath it stays as an authorless tombstone-- * ("[deleted]"), so the replies keep their place:-- * - a thread others posted in, and its opening post.-- * Votes and reactions go and cached counts are recomputed. Blocks both ways, the-- * cached profile and their place as a space's moderator go. Spaces the person-- * created, and moderators they added, stay without them. Space then confirms with counts.-- */--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;--const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;--const TOPICS = ['network.account.export_requested', 'network.account.deleted'];--const TOMBSTONE = '[deleted]';--const ROW_LIMIT = 5000;----const hasTable = async (db, t) => !!await db.prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?').get(t);--const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);--const inList = (xs) => `(${xs.map(() => '?').join(',')})`;------// ── Export ─────────────────────────────────────────────────────----const EXPORTS = [-- ['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],-- ['thread_votes.json', 'thread_votes', 'subject_id'], ['post_reactions.json', 'post_reactions', 'subject_id'],-- ['blocks.json', 'network_blocks', 'blocker_subject'],-- ['space_moderators.json', 'space_moderators', 'subject_id'],--];----async function exportPart(db, subject) {-- const files = [];-- const truncated = [];-- for (const [name, table, col] of EXPORTS) {-- if (!await hasColumn(db, table, col)) continue;-- // Newest first: by id where the table has one, else by its timestamp.-- const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at' : 'updated_at';-- const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);-- if (!rows.length) continue;-- if (rows.length > ROW_LIMIT) truncated.push(name);-- files.push({ name, content: rows.slice(0, ROW_LIMIT) });-- }-- return { files, truncated, note: 'Post images are downloaded from their URLs.' };--}----// ── Deletion ───────────────────────────────────────────────────----async function erase(db, subjects, { now = new Date().toISOString() } = {}) {-- const erased = {};-- const retained = {};-- const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };-- const S = inList(subjects);-- await db.tx(async () => {-- // Votes and reactions first, so the counts recomputed below see only what stays.-- const recount = [];-- for (const [table, item] of [['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {-- if (!await hasTable(db, table)) continue;-- const items = (await db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects)).map((r) => r.i);-- add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);-- recount.push([table, items]);-- }-- // Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.-- if (await hasTable(db, 'threads')) {-- for (const t of await db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {-- const others = await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);-- if (others) { await db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }-- else { await db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }-- }-- }-- if (await hasTable(db, 'posts')) {-- for (const p of await db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {-- const others = p.is_opening && await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);-- if (others) { await db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, 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 posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }-- }-- if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();-- }-- if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);-- for (const [table, where, key] of [['subject_projection', `subject_id IN ${S}`, 'profile'],-- ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {-- if (!await hasTable(db, table)) continue;-- const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;-- add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);-- }-- if (await hasColumn(db, 'spaces', 'created_by')) await db.prepare(`UPDATE spaces SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);-- if (await hasColumn(db, 'space_moderators', 'added_by')) await db.prepare(`UPDATE space_moderators SET added_by = NULL WHERE added_by IN ${S}`).run(...subjects);-- // The cached counts and scores follow the rows that stay.-- for (const [table, items] of recount) {-- for (const i of items) {-- if (table === 'thread_votes') await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);-- }-- }-- });-- return { erased, retained };--}----// ── Events ─────────────────────────────────────────────────────----function createSender({ config, fetchImpl = globalThis.fetch } = {}) {-- const { serviceAuth } = require('openvibe-contracts');-- const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });-- return async function send(path, body, retried = false) {-- const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });-- if (res.status === 401 && !retried) { tokens.invalidate(); return await send(path, body, true); }-- return res;-- };--}----/** One envelope → 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>'; throws to be redelivered. */--async function apply(db, ev, { send, log = console } = {}) {-- if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';-- if (ev.source !== 'network') return 'ignored:source';-- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};-- if (ev.event_type === 'network.account.export_requested') {-- if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- const seen = await db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);-- if (seen && seen.sent_at) return 'unchanged';-- const part = await exportPart(db, p.subject);-- const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });-- const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);-- if (!outcome) throw new Error(`export part refused: ${res.status}`);-- await db.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`)-- .run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());-- return outcome;-- }-- if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- let rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);-- let result = 'confirmed';-- if (!rec) {-- const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];-- const counts = await erase(db, subjects);-- await db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));-- log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);-- rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);-- result = 'erased';-- }-- if (rec.sent_at) return 'unchanged';-- const o = JSON.parse(rec.outcome || '{}');-- const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });-- if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);-- await db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);-- return result;--}----module.exports = { apply, exportPart, erase, createSender, TOPICS };ddeleted file mode 100644@@@ -1,44 +0,0 @@--'use strict';----/**-- * Author display for threads and posts: Network subjects → names and pictures from-- * subject_projection (refreshed through the Network's resolve-batch, see network.js). AI output is-- * labelled as AI and never attributed to a person (roadmap §33).-- */--const projections = require('./projection');----const AI_DISPLAY_NAME = 'OpenVibe AI';----function createAuthors({ db, network = null }) {-- /** Map subject → projection row (bounded wait for missing ones when the Network is there). */-- async function projectionsFor(subjects) {-- const list = (subjects || []).filter(Boolean);-- if (!list.length) return new Map();-- if (!network) return await projections.getProjections(db, list);-- try { return await network.projections(list); } catch { return await projections.getProjections(db, list); }-- }---- /**-- * { subject, username, display_name, avatar_url, profile_color } for a person, a fixed AI-- * label for AI output, the Discord name (relayName; is_relay) for a reply written on Discord,-- * null for anonymous writes.-- */-- function author(subject, origin, projections, relayName = null) {-- if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };-- if (origin === 'discord') return { subject: null, username: null, display_name: relayName || 'Discord', avatar_url: null, profile_color: null, is_relay: true, relay: 'discord' };-- if (origin === 'system' && !subject) return { subject: null, username: null, display_name: 'OpenVibe', avatar_url: null, profile_color: null, is_system: true };-- if (!subject) return null;-- const p = projections.get(subject);-- return {-- subject,-- username: p ? p.username : null,-- display_name: p ? (p.display_name || p.username) : null,-- avatar_url: p ? p.avatar_url : null,-- profile_color: p ? p.profile_color : null,-- };-- }---- return { projectionsFor, author };--}----module.exports = { createAuthors, AI_DISPLAY_NAME };ddeleted file mode 100644@@@ -1,53 +0,0 @@--'use strict';--/**-- * Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed).-- *-- * People block each other once, on OpenVibe.Network, and Community honours it: nobody replies in a forum-- * thread whose author blocked them, replies to a comment whose author blocked them, or comments on a paste-- * (or a forum post's comment thread) whose owner blocked them. Network announces every change as-- * network.block.changed (blocker, blocked, active, a per-pair revision); ../pulse/consumer.js applies each-- * one here, keeping only the newest revision per (blocker, blocked), so a late or replayed event never-- * undoes a newer one. Moderation (hide, lock, delete, statuses) is never affected by a block.-- */--const { validate } = require('openvibe-contracts');--const { fail } = require('../http/v1');----const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const ready = new WeakSet();------/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */--function payloadOf(event) {-- if (!event || event.source !== 'network') return 'ignored:source';-- const p = event.payload;-- if (!p || typeof p !== 'object' || !validate('network.block.changed@1', p).valid) return 'ignored:payload';-- return p;--}----/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */--async function apply(db, p, now = Date.now()) {-- const r = await db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at-- WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);-- if (!r.changes) return 'blocks:unchanged';-- return p.active ? 'blocks:blocked' : 'blocks:unblocked';--}----/** Did `blocker` block `blocked` (subjects)? */--async function hasBlocked(db, blocker, blocked) {-- if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;-- return !!await db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);--}----/**-- * Refuse (403 space.blocked) when any of `owners` blocked `writer`. `what` names the thing in the-- * message: "You cannot <what>: its author blocked you".-- */--async function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {-- if (!writer) return;-- for (const owner of owners) {-- if (await hasBlocked(db, owner, writer)) fail(403, 'space.blocked', `You cannot ${what}: ${whose} blocked you`);-- }--}----module.exports = { payloadOf, apply, hasBlocked, refuseIfBlocked };ddeleted file mode 100644@@@ -1,38 +0,0 @@--'use strict';----/**-- * Capability checks for service tokens, through openvibe-contracts' manifests.-- */--const { capabilities } = require('openvibe-contracts');----/** → { allowed, code, reason } like capabilities.check(). */--function checkCapability(claims, capabilityId) {-- return capabilities.check(claims, capabilityId);--}----/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */--function serviceHas(viewer, capabilityId) {-- return !!(viewer && viewer.kind === 'service' && checkCapability(viewer.claims, capabilityId).allowed);--}----/**-- * Staff for the forum: a browser holding staff.moderation.discussions, or a service that vouches-- * for a staff person with X-OV-Staff: 1 and holds space.forum.manage.-- */--function discussionStaff(viewer) {-- if (!viewer) return false;-- if (viewer.kind === 'user') return !!(viewer.discussionStaff !== undefined ? viewer.discussionStaff : viewer.staff);-- return viewer.kind === 'service' && !!viewer.vouchesStaff && serviceHas(viewer, 'space.forum.manage');--}----/**-- * A forum moderator: discussion staff, or a service holding space.forum.manage that acts as itself-- * (no X-OV-Subject) — e.g. an owner service hiding the thread of an entity it took down. A service-- * acting for a person moderates only when it vouches that person is staff. A space's own moderators-- * (space_moderators) are people, and forum/service.js asks for them separately.-- */--function discussionModerator(viewer) {-- return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'space.forum.manage'));--}----module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator };ddeleted file mode 100644@@@ -1,179 +0,0 @@--'use strict';----/**-- * OpenVibe.Network identity client — who a subject is, for display.-- *-- * Space stores people as Network subject ids (usr_… / gst_…). Names and pictures come from-- * the Network's resolve-batch endpoint (capability identity.subject.resolve, called with a-- * client-credentials service token) and are cached in subject_projection. The cache is never-- * authority: an entry older than PROJECTION_TTL_MS is refreshed in the background, a missing one-- * is fetched before the response (bounded by RESOLVE_WAIT_MS so a slow Network only costs names).-- *-- * POST /internal/identity/resolve-batch-- * { subject_ids: [...] } or { system, type: 'user', ids: [...] } (≤ 500 per call)-- * → { results: { <key>: { subject: {type,id}, network_user_id, username, display_name, avatar_url, banned } | null } }-- */--const { serviceAuth } = require('openvibe-contracts');--const store = require('./projection');----const BATCH = 500;--const PROJECTION_TTL_MS = 6 * 60 * 60 * 1000;--const RESOLVE_WAIT_MS = 2500;----function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}) {-- const base = config.networkInternalUrl;-- const tokens = serviceAuth.createTokenClient({-- tokenUrl: `${base}/oauth/token`,-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- audience: 'openvibe.network',-- scope: 'identity.subject.resolve',-- fetchImpl,-- });---- /** Relative avatar paths the Network hands out are relative to the Network. */-- const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);---- async function post(body, retried = false) {-- const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },-- body: JSON.stringify(body),-- signal: AbortSignal.timeout(8000),-- });-- // A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.-- if (res.status === 401 && !retried) { tokens.invalidate(); return await post(body, true); }-- const data = await res.json().catch(() => null);-- if (!res.ok || !data || typeof data.results !== 'object') {-- const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);-- err.status = res.status;-- throw err;-- }-- return data.results;-- }---- /** Resolve many keys in ≤500 chunks. `shape(chunk)` builds the request body. → Map key → projection|null */-- async function resolveChunks(keys, shape) {-- const out = new Map();-- const list = [...new Set(keys.map(String))];-- for (let i = 0; i < list.length; i += BATCH) {-- const chunk = list.slice(i, i + BATCH);-- const results = await post(shape(chunk));-- for (const k of chunk) out.set(k, results[k] || null);-- }-- return out;-- }---- /** Store a Network projection in the cache. */-- async function remember(p) {-- if (!p || !p.subject || !p.subject.id) return;-- await store.upsertProjection(db, {-- subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,-- avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,-- });-- }---- /** Subject ids → projections (and cache them). */-- async function resolveSubjects(subjectIds) {-- const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));-- for (const p of map.values()) await remember(p);-- return map;-- }---- /** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */-- async function resolveLegacy(system, ids, { cache = true } = {}) {-- const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));-- if (cache) for (const p of map.values()) await remember(p);-- return map;-- }---- /**-- * The subject of a Network account id, for user JWTs issued before tokens carried-- * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).-- */-- async function subjectForNetworkUser(networkUserId) {-- if (networkUserId == null || networkUserId === '') return null;-- const hit = await store.mapGet(db, 'network', 'user', networkUserId);-- if (hit && hit.target_type === 'subject') return hit.target_id;-- const map = await resolveLegacy('network', [networkUserId]);-- const p = map.get(String(networkUserId));-- if (!p || !p.subject || p.subject.type !== 'user') return null;-- await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);-- return p.subject.id;-- }---- /** What a verified user JWT says about its holder is a fresh projection too. */-- async function rememberClaims(subjectId, claims) {-- if (!subjectId || !claims) return;-- await store.upsertProjection(db, {-- subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,-- avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,-- });-- }---- /**-- * The user subject an @username names right now (a leading @ and case are ignored), or null when nobody holds it-- * or the account is banned or deleted (Network answers 404). Remembered in the projection cache like any lookup.-- */-- async function subjectForUsername(username, retried = false) {-- const name = String(username || '').trim().replace(/^@/, '');-- if (!/^[A-Za-z0-9_.-]{1,40}$/.test(name)) return null;-- const res = await fetchImpl(`${base}/internal/identity/resolve?username=${encodeURIComponent(name)}`, {-- headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },-- signal: AbortSignal.timeout(8000),-- });-- if (res.status === 401 && !retried) { tokens.invalidate(); return await subjectForUsername(name, true); }-- if (res.status === 404) return null;-- const p = await res.json().catch(() => null);-- if (!res.ok || !p || !p.subject) {-- const err = new Error(`resolve ${res.status}: ${(p && (p.detail || p.error)) || 'bad response'}`);-- err.status = res.status;-- throw err;-- }-- if (p.subject.type !== 'user') return null;-- await remember(p);-- return p.subject.id;-- }---- let inflight = null;-- const pending = new Set();-- /** Background refresh of stale entries, batched and deduplicated. */-- function refreshLater(ids) {-- for (const id of ids) pending.add(id);-- if (inflight) return;-- inflight = Promise.resolve().then(async () => {-- while (pending.size) {-- const batch = [...pending].slice(0, BATCH);-- batch.forEach((id) => pending.delete(id));-- try { await resolveSubjects(batch); } catch (err) { console.warn('[Identity] projection refresh failed:', err.message); pending.clear(); }-- }-- }).finally(() => { inflight = null; });-- }---- /**-- * Projections for display. Missing entries are fetched now (bounded wait); stale ones are-- * served as they are and refreshed behind the response. → Map subject_id → projection row-- */-- async function projections(subjectIds) {-- const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];-- if (!ids.length) return new Map();-- let map = await store.getProjections(db, ids);-- const missing = ids.filter((id) => !map.has(id));-- const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);-- if (stale.length) refreshLater(stale);-- if (missing.length) {-- let timer;-- const wait = new Promise((resolve) => { timer = setTimeout(resolve, RESOLVE_WAIT_MS); });-- // The lookup keeps running (and fills the cache) if the wait runs out first.-- const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));-- await Promise.race([lookup, wait]);-- clearTimeout(timer);-- map = await store.getProjections(db, ids);-- }-- return map;-- }---- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };--}----module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };ddeleted file mode 100644@@@ -1,55 +0,0 @@--'use strict';----/**-- * The identity bookkeeping Space keeps locally (moved here from Community's pastes/store.js when the-- * forum became its own service): a display cache of what the Network says about a subject, and the-- * map from pre-subject Network numeric ids to the subjects they became. Neither is authority — the-- * Network is (ADR-009); both are only ever read to render a name or resolve an old id.-- */----/** Map subject id → projection row. */--async function getProjections(db, subjectIds) {-- const out = new Map();-- const ids = [...new Set((subjectIds || []).filter(Boolean))];-- const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');-- for (const id of ids) { const r = await stmt.get(id); if (r) out.set(id, r); }-- return out;--}----/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */--async function upsertProjection(db, p) {-- if (!p || !p.subject_id) return;-- await db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)-- VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, ov_now())-- ON CONFLICT(subject_id) DO UPDATE SET-- username = COALESCE(excluded.username, subject_projection.username),-- display_name = COALESCE(excluded.display_name, subject_projection.display_name),-- avatar_url = COALESCE(excluded.avatar_url, subject_projection.avatar_url),-- profile_color = COALESCE(excluded.profile_color, subject_projection.profile_color),-- refreshed_at = ov_now()`)-- .run({-- subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,-- avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,-- });--}----/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */--async function subjectsByUsername(db, username) {-- return (await db.prepare('SELECT subject_id FROM subject_projection WHERE lower(username) = lower(?) ORDER BY refreshed_at DESC')-- .all(String(username))).map((r) => r.subject_id);--}----// ── Legacy id map ────────────────────────────────────────────----async function mapGet(db, system, type, id) {-- return await db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')-- .get(String(system), String(type), String(id)) || null;--}----async function mapSet(db, system, type, id, targetType, targetId) {-- await db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)-- .run(String(system), String(type), String(id), String(targetType), String(targetId));--}----module.exports = { getProjections, upsertProjection, subjectsByUsername, mapGet, mapSet };ddeleted file mode 100644@@@ -1,70 +0,0 @@--'use strict';--/**-- * network.subject.merged → Space (roadmap WS-B task 5, ADR-029; Contracts 0.69.0). Two Network accounts became-- * one: `from` is an alias of `into`. In one transaction Space repoints what it keys by subject:-- * authorship threads, posts, attachments, spaces a moderator was added by-- * one per person per item thread votes and post reactions: the survivor's stays where both-- * had one (the other is dropped), then the item's cached score is recomputed from its rows-- * the block projection: the survivor's stays; blocking oneself is dropped-- * space moderators the folded-in account's spaces become the survivor's (one row per space and person)-- * Idempotent: the events consumer's inbox applies an event once, and a second run finds nothing under `from`.-- */--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;----const AUTHORSHIP = [-- ['threads', 'author_subject'], ['posts', 'author_subject'], ['attachments', 'owner_subject'],-- ['space_moderators', 'added_by'],--];--// Per person per item: [table, item column, recompute(db, itemId)]--const PER_ITEM = [-- ['thread_votes', 'thread_id', async (db, id) => await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],-- ['post_reactions', 'post_id', null],--];----const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);----/** The payload, or 'ignored:<why>'. */--function payloadOf(event) {-- if (!event || event.event_type !== 'network.subject.merged') return 'ignored:type';-- if (event.source !== 'network') return 'ignored:source';-- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};-- if (!MERGE_RE.test(String(p.merge_id || '')) || !SUBJECT_RE.test(String(p.from || '')) || !SUBJECT_RE.test(String(p.into || '')) || p.from === p.into) return 'ignored:payload';-- return { merge_id: p.merge_id, from: p.from, into: p.into };--}----/** Apply one merge → 'merge:applied' (with counts logged). */--async function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {-- const counts = await db.tx(async () => {-- const c = { authored: 0, moved: 0, dropped: 0 };-- for (const [table, col] of AUTHORSHIP) {-- if (await hasColumn(db, table, col)) c.authored += (await db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from)).changes;-- }-- for (const [table, item, recompute] of PER_ITEM) {-- if (!await hasColumn(db, table, 'subject_id')) continue;-- for (const r of await db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {-- const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);-- if (clash) { await db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }-- else { await db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }-- }-- }-- if (await hasColumn(db, 'space_moderators', 'subject_id')) {-- await db.prepare('DELETE FROM space_moderators WHERE subject_id = ? AND space_id IN (SELECT space_id FROM space_moderators WHERE subject_id = ?)').run(from, into);-- c.moved += (await db.prepare('UPDATE space_moderators SET subject_id = ? WHERE subject_id = ?').run(into, from)).changes;-- }-- if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {-- for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {-- for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {-- const clash = b.o === into || await db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);-- if (clash) await db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);-- else await db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);-- }-- }-- }-- return c;-- });-- log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);-- return 'merge:applied';--}----module.exports = { payloadOf, apply };ddeleted file mode 100644@@@ -1,155 +0,0 @@--'use strict';----/**-- * Who is calling — resolved once per request into `req.viewer`:-- *-- * { kind: 'anonymous' }-- * { kind: 'user', subject: 'usr_…'|null, staff, user: <JWT claims> }-- * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from-- * the token's subject_id; older tokens without one are resolved through the Network.-- * role admin/global_mod makes the viewer staff. X-OV-* headers are ignored for browsers.-- * { kind: 'service', service: 'svc:live', claims, subject: 'usr_…'|'gst_…'|null, origin, sourceRef, vouchesStaff }-- * A first-party service holding a Network client-credentials token for audience-- * openvibe.space. It names the person it acts for in X-OV-Subject; with no subject the-- * write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a-- * person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).-- * X-OV-Staff: 1 vouches that the acting person is staff; it needs space.forum.manage.-- * Developer-app (app:…) and module (mod:…) tokens act only for the person in their-- * on_behalf_of claim: X-OV-Subject naming anyone else is refused, and sandbox tokens are too.-- *-- * Identity never comes from a request body or query. A request that presents a service token is-- * judged on that token alone: a bad one is refused, never downgraded to anonymous.-- */--const contracts = require('openvibe-contracts');--const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');--const { checkCapability } = require('./capabilities');----const { ids, capabilities, serviceAuth, http } = contracts;--const { staff: staffMap } = require('openvibe-contracts');--// The roles that hold staff moderation today (kept for callers; gates ask staffMap.can instead).--const STAFF_ROLES = new Set(['admin', 'global_mod']);--const PRINCIPAL_SUB = /^(svc|app|mod):/;--const AUDIENCE = 'openvibe.space';----class ViewerError extends Error {-- constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }--}----const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });----function isActingSubject(v) {-- return typeof v === 'string' && (ids.isSubjectId('user', v) || ids.isSubjectId('guest', v));--}----/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */--function hasCap(viewer, capabilityId) {-- return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);--}----function createViewerResolver({ auth, config, network, revocations = null }) {-- async function fromServiceToken(req, token) {-- const publicKey = await auth.ensureKey();-- if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });-- if (!r.ok) throw new ViewerError(401, r.code, r.reason);-- const claims = r.claims;-- // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person-- // who authorized them (the token's on_behalf_of), never for whoever X-OV-Subject names.-- // Only first-party service principals (svc:…) are trusted to name the acting subject.-- const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');-- if (!firstParty && claims.env !== undefined && claims.env !== 'production') {-- throw new ViewerError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.space');-- }---- const originHeader = req.get('x-ov-origin');-- if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');-- const origin = originHeader === 'ai' ? 'ai' : 'user';---- const subjectHeader = req.get('x-ov-subject');-- let subject = null;-- if (subjectHeader) {-- if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');-- if (!firstParty && subjectHeader !== claims.on_behalf_of) {-- throw new ViewerError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');-- }-- // AI output is never attributed to a person, even if the caller also names one.-- subject = origin === 'ai' ? null : subjectHeader;-- } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {-- subject = origin === 'ai' ? null : claims.on_behalf_of;-- }---- let sourceRef = null;-- const refHeader = req.get('x-ov-source-ref');-- if (refHeader) {-- try { sourceRef = JSON.parse(refHeader); } catch { sourceRef = undefined; }-- if (!sourceRef || !contracts.validate('common.entity-ref@1', sourceRef).valid) {-- throw new ViewerError(400, 'request.invalid_source_ref', 'X-OV-Source-Ref must be a JSON EntityRef {service, type, id}');-- }-- }---- // X-OV-Staff: 1 vouches that the acting person is forum staff; space.forum.manage is what makes-- // the header acceptable (identity/capabilities.js discussionStaff).-- let vouchesStaff = false;-- if (req.get('x-ov-staff') === '1') {-- const c = checkCapability(claims, 'space.forum.manage');-- if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs space.forum.manage');-- vouchesStaff = true;-- }-- return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff: false, vouchesStaff };-- }---- async function fromUserToken(token) {-- const claims = await auth.verify(token);-- if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;-- // Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).-- if (revocations && revocations.isRevoked(claims)) return null;-- let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;-- if (!subject && network && claims.sub != null) {-- try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }-- }-- if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }-- const user = claimsToUser(claims);-- if (subject && !user.subject_id) user.subject_id = subject;-- // Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.-- // Space is the forum, so discussion staff is the grant that matters (there are no pastes here).-- const discussionStaff = staffMap.can(claims, 'staff.moderation.discussions');-- return { kind: 'user', subject, staff: discussionStaff, discussionStaff, origin: 'user', user, token };-- }---- /**-- * Resolve the caller. opts.services=false (server-rendered pages) treats a service token as-- * no identity at all: pages are for browsers.-- */-- async function resolve(req, opts = {}) {-- const header = String(req.headers.authorization || '');-- if (header.startsWith('Bearer ')) {-- const token = header.slice(7).trim();-- const payload = decodeJwtPayload(token);-- if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {-- if (opts.services === false) return ANONYMOUS;-- return await fromServiceToken(req, token);-- }-- }-- const token = extractToken(req);-- if (!token) return ANONYMOUS;-- return (await fromUserToken(token)) || ANONYMOUS;-- }---- /** Express middleware: sets req.viewer (and req.user/req.token for the page renderers). */-- function middleware(opts = {}) {-- return async (req, res, next) => {-- try {-- req.viewer = await resolve(req, opts);-- if (req.viewer.kind === 'user') { req.user = req.viewer.user; req.token = req.viewer.token; }-- next();-- } catch (err) {-- if (!(err instanceof ViewerError)) return next(err);-- http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });-- }-- };-- }---- return { resolve, middleware };--}----module.exports = { createViewerResolver, hasCap, isActingSubject, ANONYMOUS, STAFF_ROLES, ViewerError };@@@ -8,59 +8,28 @@ const config = require('./config');const { createApp } = require('./app');(async () => {-- // PostgreSQL first (migrations run as the owner), then the app: its revocation cutoffs load before it serves.++ // PostgreSQL first (migrations run as the owner). Space holds no user content yet, but the++ // database carries the shared schema and the readiness check reports it.await require('./db').initDb(config);const app = await createApp();-- if (config.discordRelay.enabled) {-- const rs = await app.locals.relay.status();-- const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);-- console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);-- }-- // Space → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.-- try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }-- // The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).-- try {-- const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());-- if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);-- } catch (err) { console.warn('[Roadmap] not synced:', err.message); }const server = app.listen(config.port, config.host, () => {console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);-- console.log(`[Space] forums: spaces, threads and posts (PostgreSQL); identity via ${config.networkUrl}`);++ console.log(`[Space] code and dynamic pages (nothing published here yet); the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);});server.keepAliveTimeout = 65_000;-- // Subscribe to the network events Space applies at /internal/events (idempotent; off without EVENTS_URL / SPACE_EVENTS_SECRET).-- let subscriptions = null;-- try {-- const secret = String(process.env.SPACE_EVENTS_SECRET || '').split(',')[0].trim();-- subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret });-- } catch (err) { console.warn('[Events consumer] not subscribed:', err.message); }// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────-- // SIGTERM: the subscription retries stop (nothing new starts); the server stops taking connections,-- // closes idle keep-alive ones (it keeps them 65 s otherwise) and lets requests in flight finish-- // (4 s at most); then the Discord relay's drain and its Events worker's page (the gateway closes at-- // once) and the events outbox's send finish (unsent rows stay in their tables for the next start),-- // space.db closes, and the process exits 0, within the manifest's 5 s.-- const { gracefulStop, within } = require('openvibe-sdk/service');++ // SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s++ // otherwise) and lets requests in flight finish (4 s at most); then space.db closes and the++ // process exits 0, within the manifest's 5 s.++ const { gracefulStop } = require('openvibe-sdk/service');gracefulStop({name: 'Space', server,drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,-- stop: [-- () => { if (subscriptions) subscriptions.stop(); },-- ],++ stop: [],close: [-- () => within(1000, app.locals.relay && app.locals.relay.stop()),-- () => within(1500, require('./events').stop()),async () => {-- const ev = await require('./events').backlog().catch(() => require('./events').status());-- let relay = 'off';-- if (config.discordRelay.enabled) {-- let rs = null;-- try { rs = await app.locals.relay.status(); } catch { /* the database is gone */ }-- relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';-- }-- console.log(`[Space] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);-- if (app.locals.valkey) app.locals.valkey.close().catch(() => {});++ console.log('[Space] stopped');return await require('./db').closeDb();},],ddeleted file mode 100644@@@ -1,56 +0,0 @@--'use strict';----/**-- * Per-person write limits for the forum (in memory, per process). Keyed by the acting subject, so a-- * person is limited-- * the same whether they write from a browser or through a service naming them in X-OV-Subject.-- *-- * cooldownSec minimum gap between two writes-- * perMinute writes per rolling minute-- * duplicate refuse the same text twice in a row-- *-- * Anonymous writes are limited per address by the app's shared anonymous-write limiter instead.-- */--const { ApiError } = require('./http/v1');----function createPersonLimiter({ cooldownSec = 0, perMinute = 0, duplicate = true, noun = 'posts' } = {}) {-- const recent = new Map(); // subject → [{ at, text }] newest first---- function list(key, now) {-- const l = (recent.get(key) || []).filter((e) => now - e.at < 60_000);-- recent.set(key, l);-- return l;-- }---- /** Throws a 429/400 ApiError when this write would go over; call record() after it succeeds. */-- function check(key, text = null) {-- if (!key) return;-- const now = Date.now();-- const l = list(key, now);-- if (cooldownSec > 0 && l.length && now - l[0].at < cooldownSec * 1000) {-- const wait = Math.ceil((cooldownSec * 1000 - (now - l[0].at)) / 1000);-- throw new ApiError(429, 'request.rate_limited', `Please wait ${wait}s before writing again`, { retry_after: wait });-- }-- if (perMinute > 0 && l.length >= perMinute) {-- throw new ApiError(429, 'request.rate_limited', `Too many ${noun}. Please slow down.`, { retry_after: Math.ceil((60_000 - (now - l[l.length - 1].at)) / 1000) });-- }-- if (duplicate && text != null && l.length && l[0].text === text) throw new ApiError(400, 'request.duplicate', 'Duplicate — you just posted that');-- }---- function record(key, text = null) {-- if (!key) return;-- const l = recent.get(key) || [];-- l.unshift({ at: Date.now(), text });-- recent.set(key, l.slice(0, Math.max(perMinute, 1) + 1));-- }---- const sweep = setInterval(() => {-- const now = Date.now();-- for (const [k, l] of recent) if (!l.length || now - l[0].at > 120_000) recent.delete(k);-- }, 10 * 60_000);-- if (sweep.unref) sweep.unref();---- return { check, record };--}----module.exports = { createPersonLimiter };ddeleted file mode 100644@@@ -1,67 +0,0 @@--'use strict';----/**-- * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2). Space keeps a-- * `med_` reference and the public URL; the bytes live in Media under the `space` tenant, owned by-- * the person who attached them (X-OV-Subject), with Space's service token (media.object.upload,-- * namespace space):-- *-- * POST /api/v2/space/objects init { kind, visibility: 'unlisted', size_bytes, mime_type, filename, content_hash }-- * PUT /api/v2/space/objects/:id/content the bytes-- * POST /api/v2/space/objects/:id/complete Media checks size, hash and type → ready-- *-- * Public bytes are served at <media>/o/:id.-- */--const crypto = require('crypto');--const { serviceAuth } = require('openvibe-contracts');----function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {-- const configured = !!(config.oauth && config.oauth.clientSecret);-- const tokens = configured ? serviceAuth.createTokenClient({-- tokenUrl: `${config.networkInternalUrl}/oauth/token`,-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- audience: 'openvibe.media',-- scope: 'media.object.upload',-- fetchImpl,-- }) : null;-- const base = `${config.mediaInternalUrl}/api/v2/space/objects`;---- async function call(method, path, { json, body, headers = {} } = {}, retried = false) {-- const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };-- if (json !== undefined) h['Content-Type'] = 'application/json';-- const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });-- if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }-- const data = await res.json().catch(() => null);-- if (!res.ok) {-- const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);-- err.status = res.status; err.code = data && data.code;-- throw err;-- }-- return data || {};-- }---- /**-- * Store one image. → { id (med_…), url, size_bytes, mime }-- * owner: the person (usr_…) the object belongs to, when there is one; kind: 'file' (post images) or-- * 'screenshot' (screenshot pastes); visibility 'unlisted': served to anyone with the link (the object id is-- * unguessable), never listed on openvibe.media — the post or paste decides who sees the link.-- */-- async function uploadImage({ buffer, mime, filename, owner = null, kind = 'file', visibility = 'unlisted', source = 'space.attachment' }) {-- if (!configured) { const e = new Error('Attachments need Community\'s service principal (OV_OAUTH_CLIENT_SECRET)'); e.status = 503; throw e; }-- const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');-- const init = await call('POST', '', {-- json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },-- headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},-- });-- const id = init.id || (init.object && init.object.id);-- if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });-- await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });-- await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });-- return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };-- }---- return { configured, uploadImage };--}----module.exports = { createMediaObjects };ddeleted file mode 100644@@@ -1,85 +0,0 @@--'use strict';----/**-- * Drop embedded metadata (EXIF incl. GPS, XMP, text chunks) from an uploaded image without-- * re-encoding it. Media used to do this by re-encoding with sharp; Community has no image-- * library, and the pixels don't need touching — only the side-channel segments do.-- *-- * JPEG — APP1 (EXIF/XMP) and APP13 (IPTC/Photoshop) segments and COM comments are removed;-- * APP0 (JFIF), APP2 (ICC colour profile) and APP14 (Adobe) stay.-- * PNG — eXIf, tEXt, zTXt, iTXt and tIME chunks are removed.-- * WebP — EXIF and XMP chunks are removed and the VP8X flags updated.-- * GIF — left alone (animations survive; GIF carries no EXIF).-- * Anything that doesn't parse cleanly is returned unchanged.-- */----function stripJpeg(buf) {-- if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return buf;-- const parts = [buf.subarray(0, 2)];-- let i = 2;-- while (i + 4 <= buf.length) {-- if (buf[i] !== 0xff) return buf;-- const marker = buf[i + 1];-- if (marker === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts); } // start of scan: rest is image data-- if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue; }-- const len = buf.readUInt16BE(i + 2);-- if (len < 2 || i + 2 + len > buf.length) return buf;-- const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;-- if (!drop) parts.push(buf.subarray(i, i + 2 + len));-- i += 2 + len;-- }-- return buf;--}----const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);--const PNG_DROP = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);--function stripPng(buf) {-- if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) return buf;-- const parts = [PNG_SIG];-- let i = 8;-- while (i + 12 <= buf.length) {-- const len = buf.readUInt32BE(i);-- const type = buf.toString('latin1', i + 4, i + 8);-- const end = i + 12 + len;-- if (end > buf.length) return buf;-- if (!PNG_DROP.has(type)) parts.push(buf.subarray(i, end));-- i = end;-- if (type === 'IEND') return Buffer.concat(parts);-- }-- return buf;--}----function stripWebp(buf) {-- if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf;-- const chunks = [];-- let i = 12;-- while (i + 8 <= buf.length) {-- const type = buf.toString('latin1', i, i + 4);-- const len = buf.readUInt32LE(i + 4);-- const end = i + 8 + len + (len & 1);-- if (i + 8 + len > buf.length) return buf;-- if (type !== 'EXIF' && type !== 'XMP ') chunks.push(Buffer.from(buf.subarray(i, Math.min(end, buf.length))));-- i = end;-- }-- for (const c of chunks) {-- // VP8X flags byte: bit 3 = EXIF present, bit 2 = XMP present.-- if (c.toString('latin1', 0, 4) === 'VP8X' && c.length > 8) c[8] &= ~0x0c;-- }-- const body = Buffer.concat(chunks);-- const head = Buffer.alloc(12);-- head.write('RIFF', 0, 'latin1');-- head.writeUInt32LE(body.length + 4, 4);-- head.write('WEBP', 8, 'latin1');-- return Buffer.concat([head, body]);--}----function stripImageMetadata(buf, mime) {-- try {-- if (mime === 'image/jpeg') return stripJpeg(buf);-- if (mime === 'image/png') return stripPng(buf);-- if (mime === 'image/webp') return stripWebp(buf);-- } catch { /* fall through: unchanged */ }-- return buf;--}----module.exports = { stripImageMetadata, stripJpeg, stripPng, stripWebp };@@@ -3,27 +3,15 @@* Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).** db required a real query on Space's PostgreSQL database (the schema is there and answers)-- * 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-- * media optional Media answers /healthz (image attachments on posts)++ * network_jwks optional the Network signing key has loaded. Without it the home page and the++ * discovery files still work, but nobody can sign in, so it degrades++ * rather than fails*-- * Request metrics come from openvibe-shared/metrics in app.js. Content counts (spaces, threads,-- * posts) are deliberately not metrics.++ * Request metrics come from openvibe-shared/metrics in app.js.*/const { createReadiness } = require('openvibe-shared/ready');--const PING_TTL_MS = 15_000;----function probe(url, fetchImpl) {-- return async () => {-- const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });-- try { await res.body?.cancel(); } catch { /* not needed */ }-- return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };-- };--}----function createSpaceReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch, valkey = null }) {++function createSpaceReadiness({ db, auth, config, release = null } = {}) {const checks = [{name: 'db', required: true,@@@ -35,33 +23,17 @@ function createSpaceReadiness({ db, auth, config, relay = null, release = null,return n > 0 ? { ok: true, detail: r.detail } : 'database has no tables (migrations did not run)';},},-- { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => {if (auth.client.publicKey) return true;// Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.auth.ensureKey().catch(() => {});-- return 'Network signing key not loaded yet: sign-in and signed-in writes are unavailable';++ return 'Network signing key not loaded yet: sign-in is unavailable';},},];-- checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });-- return createReadiness({-- service: 'space',-- release,-- checks,-- details: async (body) => {-- 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();-- const brief = (x) => (x.enabled ? { enabled: true, state: x.state || (x.running ? 'running' : 'stopped'), last_error: x.last_error || null, ...(x.lag != null ? { lag: x.lag } : {}) } : { enabled: false, reason: x.reason });-- out.discord_relay = { enabled: true, deliveries: st.deliveries, creates_from: st.creates_from, events_worker: brief(st.events_worker), inbound: brief(st.inbound), inbound_failures: st.inbound_failures };-- }-- return out;-- },-- });++ return createReadiness({ service: 'space', release, checks });}module.exports = { createSpaceReadiness };ddeleted file mode 100644@@@ -1,103 +0,0 @@--'use strict';----/**-- * /api/v1/relay — Discord relay administration, staff only (an admin/global_mod browser, or a-- * service holding space.forum.manage: acting as itself, or vouching with X-OV-Staff).-- *-- * GET /status the queue by status, the Events worker (cursor, lag,-- * last error, gaps) and the inbound gateway's state-- * GET /deliveries?status=pending|delivered|failed|dropped|skipped&action=create|edit|delete&limit=-- * what was sent, what is failing and why (failed = the dead letters)-- * POST /deliveries/:id/retry queue a delivery again, with a fresh attempt budget-- * POST /deliveries/:id/drop give up on a waiting or failed one (kept as 'dropped')-- * GET /inbound?all=1&limit= messages from Discord that did not become posts, and why-- * POST /inbound/:id/dismiss mark one seen-- * GET /mappings space → webhook variable name, Discord channel/thread, inbound-- * POST /mappings { space, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? }-- * map a space (the URL stays in the env; allow-listed names only)-- * PUT /mappings/:id { enabled?, inbound?, discord_channel_id?, discord_thread_id? }-- *-- * Responses never contain a webhook URL or the bot token, only variable names and whether they are set.-- */--const express = require('express');--const contracts = require('openvibe-contracts');--const { run, jsonBody, fail, intIn } = require('../http/v1');--const { discussionModerator } = require('../identity/capabilities');--const forumStore = require('../forum/store');--const { SNOWFLAKE, DELIVERY_STATUSES } = require('./discord');----function createRelayApi({ relay, db, viewers, inbound = null, limits }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());---- router.use((req, res, next) => {-- const v = req.viewer;-- if (discussionModerator(v)) return next();-- if (v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'Sign in as staff', ctx: req.ov });-- return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'Relay administration is for staff', ctx: req.ov });-- });-- // Per-actor limits (server/actor-limits.js), after the staff check: reads take the defaults, and-- // retries, drops, dismissals and mapping changes are a person's clicks, 30 a minute at most.-- router.use(limits.reads('space.relay.read'));-- const manage = limits('space.relay.manage', { minute: 30, hour: 300 });---- const idOf = (req) => (/^\d{1,15}$/.test(req.params.id) ? Number(req.params.id) : fail(404, 'route.not_found', 'Not found'));-- /** Discord ids in a mapping body: digits, or null/'' to clear; undefined when absent. */-- function discordIds(b) {-- const out = {};-- for (const k of ['discord_channel_id', 'discord_thread_id']) {-- if (b[k] === undefined) continue;-- if (b[k] !== null && b[k] !== '' && !SNOWFLAKE.test(String(b[k]))) fail(400, 'relay.invalid_discord_id', `${k} is a Discord id (15 to 21 digits), or null`);-- out[k] = b[k] === '' ? null : b[k];-- }-- return out;-- }---- router.get('/status', run(async () => await relay.status()));-- router.get('/deliveries', run(async (req) => {-- const status = DELIVERY_STATUSES.includes(req.query.status) ? req.query.status : null;-- const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;-- return { enabled: relay.enabled, deliveries: await relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };-- }));-- router.post('/deliveries/:id/retry', manage, run(async (req) => {-- if (!await relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');-- return { ok: true };-- }));-- router.post('/deliveries/:id/drop', manage, run(async (req) => {-- if (!await relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');-- return { ok: true };-- }));-- router.get('/inbound', run(async (req) => ({-- enabled: !!inbound, failures: inbound ? await inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],-- })));-- router.post('/inbound/:id/dismiss', manage, run(async (req) => {-- if (!inbound || !await inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');-- return { ok: true };-- }));-- router.get('/mappings', run(async () => ({ enabled: relay.enabled, mappings: await relay.listMappings() })));-- router.post('/mappings', manage, jsonBody, run(async (req) => {-- const b = req.body || {};-- const space = await forumStore.getSpace(db, String(b.space || ''));-- if (!space) fail(400, 'relay.invalid_space', 'Unknown space');-- const ref = String(b.webhook_url_ref || '');-- if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');-- if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');-- const ids = discordIds(b);-- return { mapping: await relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };-- }, 201));-- router.put('/mappings/:id', manage, jsonBody, run(async (req) => {-- const b = req.body || {};-- const fields = { ...discordIds(b) };-- if (b.enabled !== undefined) fields.enabled = !!b.enabled;-- if (b.inbound !== undefined) fields.inbound = !!b.inbound;-- const m = await relay.updateMapping(idOf(req), fields);-- if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');-- return { mapping: m };-- }));---- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----module.exports = { createRelayApi };ddeleted file mode 100644@@@ -1,242 +0,0 @@--'use strict';----/**-- * A minimal Discord gateway client (API v10, JSON, no compression) for the relay's inbound side-- * (roadmap WS-J task 6; DISCORD_RELAY_INBOUND=on with DISCORD_BOT_TOKEN).-- *-- * One session: HELLO → IDENTIFY (or RESUME on resume_gateway_url after a drop); heartbeats at the-- * interval Discord asks for, the first one jittered, and a heartbeat that was not ACKed by the next-- * one means a zombied connection (closed, then resumed); op 1 asks for a heartbeat now; op 7-- * RECONNECT resumes; op 9 INVALID_SESSION identifies again (or resumes when Discord says it may).-- * Reconnects back off exponentially (minBackoffMs · 2^n, at most maxBackoffMs), reset once READY or-- * RESUMED. Close codes that reconnecting cannot heal (4004 a wrong token; 4010–4014 shard, version-- * or intents) stop it, with the reason shown to staff; 4007 and 4009 start a fresh session.-- *-- * Intents: GUILD_MESSAGES | MESSAGE_CONTENT (privileged: turn it on for the bot in the Developer-- * Portal). MESSAGE_CREATE / MESSAGE_UPDATE / MESSAGE_DELETE / MESSAGE_DELETE_BULK go to onDispatch-- * (relay/inbound.js); everything else is dropped here. The WebSocket is the client built into-- * Node 22 (no dependency); without one the gateway stays off and says so. The token never leaves-- * the IDENTIFY/RESUME payloads and is never logged.-- */--const INTENTS = { GUILD_MESSAGES: 1 << 9, MESSAGE_CONTENT: 1 << 15 };--const DEFAULT_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';--const FATAL = {-- 4004: 'authentication failed: DISCORD_BOT_TOKEN is wrong',-- 4010: 'invalid shard',-- 4011: 'sharding required (the bot is in too many servers for one connection)',-- 4012: 'invalid gateway API version',-- 4013: 'invalid intents',-- 4014: 'disallowed intents: turn on the MESSAGE CONTENT intent for the bot in the Discord Developer Portal',--};--const FRESH = new Set([4007, 4009]);--const DISPATCH = new Set(['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK']);--const OPEN = 1;----/** The gateway URL with the version and encoding this client speaks (a resume URL comes without them). */--function withQuery(u) {-- try {-- const url = new URL(u);-- url.searchParams.set('v', '10');-- url.searchParams.set('encoding', 'json');-- return url.toString();-- } catch { return u; }--}----function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = globalThis.WebSocket, onDispatch = () => {}, intents = INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT, minBackoffMs = 1000, maxBackoffMs = 60_000, helloTimeoutMs = 30_000, invalidSessionMs = 1000, jitter = Math.random, now = () => Date.now() } = {}) {-- let reason = null;-- if (!token) reason = 'DISCORD_BOT_TOKEN is not set';-- else if (typeof WebSocketImpl !== 'function') reason = 'this Node has no built-in WebSocket client (Node 22 or later)';-- const on = !reason;-- const state = { status: on ? 'idle' : 'off', session_id: null, seq: null, resume_url: null, bot_user_id: null, last_error: null, last_reconnect: null, ready_at: null, connects: 0, reconnects: 0, dispatched: 0 };-- let ws = null;-- let firstBeat = null;-- let beats = null;-- let reconnectTimer = null;-- let helloTimer = null;-- let acked = true;-- let failures = 0;-- let stopped = true;---- const iso = () => new Date(now()).toISOString();-- function send(obj) {-- if (ws && ws.readyState === OPEN) ws.send(JSON.stringify(obj));-- }-- function clearHeartbeat() {-- if (helloTimer) clearTimeout(helloTimer);-- helloTimer = null;-- if (firstBeat) clearTimeout(firstBeat);-- if (beats) clearInterval(beats);-- firstBeat = null;-- beats = null;-- }-- function beat() {-- acked = false;-- send({ op: 1, d: state.seq });-- }-- function heartbeatEvery(ms) {-- clearHeartbeat();-- acked = true;-- const interval = Math.max(Number(ms) || 41_250, 10);-- firstBeat = setTimeout(() => {-- firstBeat = null;-- beat();-- beats = setInterval(() => {-- if (!acked) return reconnect('no heartbeat ACK (zombied connection)');-- beat();-- }, interval);-- if (beats.unref) beats.unref();-- }, Math.floor(interval * jitter()));-- if (firstBeat.unref) firstBeat.unref();-- }---- function connect() {-- reconnectTimer = null;-- if (stopped) return;-- const resuming = !!(state.session_id && state.resume_url);-- state.status = resuming ? 'resuming' : 'connecting';-- state.connects++;-- let sock;-- try {-- sock = new WebSocketImpl(resuming ? withQuery(state.resume_url) : url);-- } catch (err) {-- state.last_error = `cannot open the gateway: ${err.message}`;-- return scheduleReconnect();-- }-- ws = sock;-- helloTimer = setTimeout(() => { helloTimer = null; if (ws === sock) reconnect('no HELLO from the gateway'); }, helloTimeoutMs);-- if (helloTimer.unref) helloTimer.unref();-- sock.addEventListener('message', (ev) => { if (ws === sock) onMessage(ev.data); });-- sock.addEventListener('close', (ev) => { if (ws === sock) onClose(ev.code, ev.reason); });-- sock.addEventListener('error', () => { /* a close event follows */ });-- }---- let dispatching = Promise.resolve();-- let pendingDispatches = 0;-- function onMessage(raw) {-- let p;-- try { p = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8')); } catch { return; }-- if (!p || typeof p !== 'object') return;-- switch (p.op) {-- case 10: // HELLO-- heartbeatEvery(p.d && p.d.heartbeat_interval); // also clears the HELLO timeout-- if (state.session_id && state.seq != null) send({ op: 6, d: { token, session_id: state.session_id, seq: state.seq } });-- else send({ op: 2, d: { token, intents, properties: { os: process.platform, browser: 'openvibe-space', device: 'openvibe-space' } } });-- break;-- case 11: // HEARTBEAT_ACK-- acked = true;-- break;-- case 1: // HEARTBEAT (asked for one now)-- beat();-- break;-- case 7: // RECONNECT-- reconnect('Discord asked to reconnect');-- break;-- case 9: // INVALID_SESSION (d: may resume)-- if (!p.d) { state.session_id = null; state.seq = null; state.resume_url = null; }-- reconnect('invalid session', invalidSessionMs + Math.floor(jitter() * 4 * invalidSessionMs)); // Discord: wait 1 to 5 s-- break;-- case 0: // DISPATCH-- if (p.s != null) state.seq = p.s;-- dispatch(p.t, p.d);-- break;-- default:-- break;-- }-- }---- function dispatch(t, d) {-- if (t === 'READY') {-- state.session_id = d && d.session_id ? String(d.session_id) : null;-- state.resume_url = d && d.resume_gateway_url ? String(d.resume_gateway_url) : null;-- state.bot_user_id = d && d.user && d.user.id ? String(d.user.id) : null;-- state.status = 'ready';-- state.ready_at = iso();-- state.last_error = null;-- failures = 0;-- return;-- }-- if (t === 'RESUMED') {-- state.status = 'ready';-- state.last_error = null;-- failures = 0;-- return;-- }-- if (!DISPATCH.has(t)) return;-- state.dispatched++;-- // One at a time, in arrival order: a MESSAGE_UPDATE never overtakes its MESSAGE_CREATE (handling is async).-- // Idle, the handler starts at once; otherwise the event waits behind the one in progress.-- const ctx = { botUserId: state.bot_user_id };-- const go = () => { try { return Promise.resolve(onDispatch(t, d || {}, ctx)); } catch (err) { return Promise.reject(err); } };-- const run = pendingDispatches === 0 ? go() : dispatching.then(go);-- pendingDispatches++;-- dispatching = run.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message)).finally(() => { pendingDispatches--; });-- }---- function onClose(code, why) {-- clearHeartbeat();-- ws = null;-- if (stopped) { state.status = 'stopped'; return; }-- if (FATAL[code]) {-- state.status = 'failed';-- state.last_error = `Discord closed the gateway (${code}): ${FATAL[code]}`;-- console.warn(`[Relay] ${state.last_error}; inbound stays off until a restart`);-- return;-- }-- if (FRESH.has(code)) { state.session_id = null; state.seq = null; state.resume_url = null; }-- state.last_error = `gateway closed (${code}${why ? `: ${String(why).slice(0, 200)}` : ''})`;-- state.last_reconnect = { reason: state.last_error, at: iso() };-- scheduleReconnect();-- }---- /** Drop this connection (keeping the session, so the next one resumes) and connect again. */-- function reconnect(why, delayMs = null) {-- const sock = ws;-- ws = null;-- clearHeartbeat();-- state.last_error = why;-- state.last_reconnect = { reason: why, at: iso() };-- if (sock) { try { sock.close(4000, why.slice(0, 120)); } catch { /* already closed */ } }-- scheduleReconnect(delayMs);-- }---- function scheduleReconnect(delayMs = null) {-- if (stopped || reconnectTimer) return;-- failures++;-- state.reconnects++;-- state.status = 'waiting';-- const delay = delayMs != null ? delayMs : Math.min(minBackoffMs * Math.pow(2, Math.max(failures - 1, 0)), maxBackoffMs);-- reconnectTimer = setTimeout(connect, delay);-- if (reconnectTimer.unref) reconnectTimer.unref();-- }---- function start() {-- if (!on || !stopped) return;-- stopped = false;-- failures = 0;-- connect();-- }-- /** Close the connection (1000) and cancel every timer. */-- function stop() {-- stopped = true;-- clearHeartbeat();-- if (reconnectTimer) clearTimeout(reconnectTimer);-- reconnectTimer = null;-- const sock = ws;-- ws = null;-- if (sock) { try { sock.close(1000, 'stopping'); } catch { /* already closed */ } }-- if (on) state.status = 'stopped';-- return Promise.resolve();-- }---- function status() {-- if (!on) return { enabled: false, reason };-- return {-- enabled: true, state: state.status, session: !!state.session_id, bot_user_id: state.bot_user_id,-- ready_at: state.ready_at, connects: state.connects, reconnects: state.reconnects, last_reconnect: state.last_reconnect, events_handled: state.dispatched, last_error: state.last_error,-- };-- }---- return { enabled: on, reason, start, stop, status, _state: state };--}----module.exports = { createDiscordGateway, INTENTS, DEFAULT_URL, FATAL };ddeleted file mode 100644@@@ -1,517 +0,0 @@--'use strict';----/**-- * Discord relay (DISCORD_RELAY_ENABLED; off by default), roadmap WS-J tasks 5 and 6.-- *-- * Out: a new thread in a public space with an enabled relay_mappings row is announced through the-- * mapping's Discord webhook; its replies follow (into the same channel, or the Discord thread the-- * mapping names); edits and deletes follow them through the external message map (relay_message_map).-- * In: with the gateway on, replies written on Discord come back as posts (server/relay/inbound.js).-- *-- * The queue is relay_deliveries, one row per dedupe key:-- * create thread:<id>:mapping:<m> | post:<id>:mapping:<m> queued by the Events worker from-- * space.thread.created / space.post.created (server/relay/events-worker.js), or by the-- * forum itself while that worker is off-- * edit edit:<thread|post>:<id>:mapping:<m>:r<revision> an edited opening post or reply that is on Discord-- * delete delete:<thread|post>:<id>:mapping:<m> deleted, moderated away, or gated members-only-- * A create is POSTed with ?wait=true, so Discord answers with the message and its id goes into the map;-- * an edit or delete PATCHes or DELETEs /messages/<id> of the same webhook (?thread_id= when it was sent-- * into a Discord thread). Nothing already in the map is created twice, and a reply waits for its-- * thread's own message (a reply to a thread that is not on Discord through that mapping is skipped).-- *-- * - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an-- * environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at-- * send time. Only allow-listed names: DISCORD_RELAY_WEBHOOK_VARS (exact names) when set, else-- * DISCORD_WEBHOOK_*. Staff can therefore never point the relay at the URL in some other-- * variable (an internal service's base URL), nor learn which other variables are set; a-- * mapping outside the list is refused when made and never sent.-- * - Visibility: public spaces only, never members-only (VIP) spaces or threads, checked when-- * queueing and again when sending. Gating a thread later deletes what the relay had sent.-- * - Loop prevention: a thread or post whose origin is 'discord' is never relayed out, checked both-- * when queueing and when sending (inbound.js adds the other half).-- * - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with-- * exponential backoff (baseMs · 2^(attempt−1), capped at an hour; a 429's retry_after is-- * honoured, and the rest of that webhook's queue waits for the next pass) up to maxAttempts.-- * Then the delivery is 'failed': the dead letter, with its error, that staff retry or drop.-- * Other 4xx fail at once. A delete that finds the message already gone counts as delivered.-- * - Mentions are disabled (allowed_mentions.parse = []), so nothing relayed can ping anyone.-- * Staff see and act on all of it at /api/v1/relay (relay/api.js).-- */--const projections = require('../identity/projection');--const forumStore = require('../forum/store');--const { markdownToText } = require('../render/markdown');--const { sqlTime, isoTime } = require('../http/v1');--const { AI_DISPLAY_NAME } = require('../identity/authors');----const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;--const WEBHOOK_VAR = /^DISCORD_WEBHOOK_[A-Z0-9_]+$/;--const SNOWFLAKE = /^\d{15,21}$/;--const MAX_BACKOFF_MS = 60 * 60_000;--const SEND_TIMEOUT_MS = 10_000;--const POSTS_PER_PAGE = 50; // forum/service.js: a reply's link names its page--const UNKNOWN_MESSAGE = 10008; // Discord's error code: the message is gone--const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skipped'];----/** Discord markdown in names/titles shown as plain text. */--const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');----/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */--function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, kickMs = 250, now = () => Date.now(), webhookVars = null } = {}) {-- const base = (config.baseUrl || '').replace(/\/$/, '');-- const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;-- /** May a mapping name this variable? */-- const refAllowed = (name) => typeof name === 'string' && ENV_NAME.test(name) && (allowed ? allowed.has(name) : WEBHOOK_VAR.test(name));-- let timer = null;-- let draining = null;-- let stopped = false;-- let kickTimer = null;-- // While the Events worker runs it queues every create from the event stream; the forum's direct-- // calls then queue nothing (the dedupe keys would make them harmless anyway).-- let createsFromEvents = false;-- const parts = { worker: null, gateway: null, inbound: null };---- const keys = {-- create: (type, id, m) => `${type}:${id}:mapping:${m}`,-- edit: (type, id, m, rev) => `edit:${type}:${id}:mapping:${m}:r${rev}`,-- remove: (type, id, m) => `delete:${type}:${id}:mapping:${m}`,-- };-- /** Kept for callers of the first relay: the create key of a thread. */-- function deliveryKey(threadId, mappingId) { return keys.create('thread', threadId, mappingId); }---- const insertStmt = db.prepare(`INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at)-- VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING`);-- async function insert(row) {-- return (await insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) })).changes;-- }-- const outMappings = async (spaceId) => await db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);-- const mapRow = async (mappingId, type, id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;---- /** Why this thread (and a post of it) may not leave the site, or null. */-- function refusal(thread, space, post = null) {-- if (thread.origin === 'discord' || (post && post.origin === 'discord')) return `loop prevention: ${post && post.origin === 'discord' ? 'post' : 'thread'} came from Discord`;-- if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted space)';-- return null;-- }-- /** Queue-time filter: the same rules, quietly. */-- const mayLeave = (thread, space, post = null) => !!(thread && space && !thread.deleted_at && !(post && post.deleted_at) && !refusal(thread, space, post));---- // ── queueing ─────────────────────────────────────────────-- /**-- * A new thread, for every enabled out-mapping of its space. source 'events' (the Events worker,-- * eventId) or 'direct' (the forum; ignored while the worker queues creates). → number queued-- */-- async function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {-- if (!enabled || !thread || !space) return 0;-- if (source === 'direct' && createsFromEvents) return 0;-- if (!mayLeave(thread, space)) return 0;-- let queued = 0;-- for (const m of await outMappings(space.id)) {-- queued += await insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });-- }-- if (queued) kick();-- return queued;-- }---- /**-- * A new reply: to every mapping its thread went to (sent, or still waiting to be). The opening post-- * travels with its thread. → number queued-- */-- async function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {-- if (!enabled || !post || !thread || !space || post.is_opening) return 0;-- if (source === 'direct' && createsFromEvents) return 0;-- if (!mayLeave(thread, space, post)) return 0;-- let queued = 0;-- for (const m of await outMappings(space.id)) {-- const parent = await db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));-- if (!parent && !await mapRow(m.id, 'thread', thread.id)) continue;-- queued += await insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });-- }-- if (queued) kick();-- return queued;-- }---- /**-- * An edited post: the Discord message showing it (the thread's, for an opening post) is edited.-- * Only what is already on Discord; a create still waiting carries the new text anyway.-- */-- async function enqueueEdit(post) {-- if (!enabled || !post) return 0;-- const type = post.is_opening ? 'thread' : 'post';-- const id = post.is_opening ? post.thread_id : post.id;-- const rows = await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);-- let queued = 0;-- for (const r of rows) {-- queued += await insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });-- }-- if (queued) kick();-- return queued;-- }---- /**-- * A thread (postId null: with every reply of it) or one reply left the public site — deleted,-- * moderated away or gated members-only: what is still waiting is skipped and what Discord shows is-- * deleted. Queued even while the relay is off, so it happens once it is on again.-- */-- async function enqueueDelete(threadId, postId = null, reason = 'deleted') {-- const note = `skipped: ${reason} before it was sent`;-- return await db.tx(async () => {-- if (postId == null) {-- await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);-- } else {-- await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);-- }-- const rows = postId == null-- ? await db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)-- : await db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);-- let queued = 0;-- for (const r of rows) {-- queued += await insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });-- }-- if (queued && enabled) kick();-- return queued;-- });-- }-- /** Threads gated members-only (or otherwise no longer public): take them off Discord. */-- async function hideThreads(threadIds, reason = 'made members-only') {-- let n = 0;-- for (const id of threadIds) n += await enqueueDelete(id, null, reason);-- return n;-- }---- // A drain soon after the change commits (never before: it would not see the row), kickMs later so that changes made-- // together (an edit and the delete right after it) go out as one pass, the edit skipped.-- function kick() {-- db.afterCommit(() => {-- if (kickTimer || stopped) return;-- kickTimer = setTimeout(() => { kickTimer = null; drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, kickMs);-- if (kickTimer.unref) kickTimer.unref();-- });-- }---- // ── messages ─────────────────────────────────────────────-- async function authorName(subject, origin) {-- if (origin === 'ai') return AI_DISPLAY_NAME;-- if (origin === 'system' && !subject) return 'OpenVibe';-- if (subject) {-- const p = (await projections.getProjections(db, [subject])).get(subject);-- if (p) return p.display_name || p.username || 'someone';-- }-- return 'someone';-- }-- const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;-- async function postUrl(post, thread, space) {-- 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);-- return `${threadUrl(thread, space)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}`;-- }---- /** The thread's message: who started it, the title, an excerpt of the opening post, the link. */-- async function message(thread, space) {-- const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);-- const url = threadUrl(thread, space);-- return {-- username: 'OpenVibe.Space',-- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,-- allowed_mentions: { parse: [] },-- embeds: [{-- title: String(thread.title).slice(0, 256),-- url,-- description: markdownToText(opening ? opening.body_markdown : '', 300),-- footer: { text: `OpenVibe.Space · s/${space.slug}` },-- timestamp: isoTime(thread.created_at) || undefined,-- }],-- };-- }---- /** A reply's message: who replied to which thread, an excerpt, the link to the post. */-- async function postMessage(post, thread, space) {-- const url = await postUrl(post, thread, space);-- const title = String(thread.title).slice(0, 200);-- return {-- username: 'OpenVibe.Space',-- content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,-- allowed_mentions: { parse: [] },-- embeds: [{-- title: `Re: ${title}`.slice(0, 256),-- url,-- description: markdownToText(post.body_markdown, 500),-- footer: { text: `OpenVibe.Space · s/${space.slug}` },-- timestamp: isoTime(post.created_at) || undefined,-- }],-- };-- }---- // ── sending ──────────────────────────────────────────────-- function backoff(attempts, retryAfterSec) {-- const exp = Math.min(baseMs * Math.pow(2, Math.max(attempts - 1, 0)), MAX_BACKOFF_MS);-- return Math.max(exp, retryAfterSec ? retryAfterSec * 1000 : 0);-- }---- /** One send happened: delivered, retried later, or failed (the dead letter). */-- async function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {-- const attempts = d.attempts + 1;-- if (ok) {-- await db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = ov_now(), updated_at = ov_now() WHERE id = ?")-- .run(attempts, status, note, d.id);-- return 'delivered';-- }-- const giveUp = !retry || attempts >= maxAttempts;-- await db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = ov_now() WHERE id = ?')-- .run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);-- return giveUp ? 'failed' : 'retry';-- }-- /** Nothing was sent: already done (delivered) or nothing left to do (skipped). */-- async function settle(d, status, reason) {-- await db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = ov_now(),-- delivered_at = CASE WHEN ? = 'delivered' THEN ov_now() ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);-- return status === 'delivered' ? 'delivered' : 'skipped';-- }-- /** Not yet (a reply waiting for its thread's message): later, without using an attempt. */-- async function defer(d, untilSql, reason) {-- const until = Math.max(Date.parse(`${String(untilSql || '').replace(' ', 'T')}Z`) || 0, now() + 1000);-- await db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = ov_now() WHERE id = ?').run(sqlTime(until), reason, d.id);-- return 'retry';-- }---- async function send(d) {-- const m = await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);-- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);-- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;-- const post = d.post_id ? await forumStore.getPost(db, d.post_id) : null;-- const type = d.post_id ? 'post' : 'thread';-- const localId = d.post_id || d.thread_id;-- const gone = !thread || thread.deleted_at || !space || (d.post_id && (!post || post.deleted_at));-- const mapped = await mapRow(d.mapping_id, type, localId);---- if (d.action === 'create') {-- if (gone) return await settle(d, 'skipped', `the ${type} was deleted before it was sent`);-- const why = refusal(thread, space, post);-- if (why) return await record(d, { ok: false, error: why });-- if (mapped) return await settle(d, 'delivered', 'already on Discord');-- if (type === 'post' && !await mapRow(d.mapping_id, 'thread', thread.id)) {-- const parent = await db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));-- if (parent && parent.status === 'pending') return await defer(d, parent.next_attempt_at, "waiting for the thread's own message");-- return await settle(d, 'skipped', 'the thread is not on Discord through this mapping');-- }-- } else {-- if (!mapped || mapped.direction !== 'out') return await settle(d, 'skipped', 'not on Discord');-- if (mapped.external_deleted_at) return await settle(d, 'skipped', 'already deleted on Discord');-- if (d.action === 'edit') {-- if (gone) return await settle(d, 'skipped', `the ${type} was deleted`);-- const why = refusal(thread, space, post);-- if (why) return await settle(d, 'skipped', why);-- }-- }---- if (!m || !refAllowed(m.webhook_url_ref)) return await record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });-- const raw = env[m.webhook_url_ref];-- if (!raw) return await record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });-- let url = null;-- try { url = new URL(raw); } catch { /* below */ }-- if (!url || !/^https?:$/.test(url.protocol)) return await record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });---- let method = 'POST';-- let body = null;-- let threadTarget = null;-- if (d.action === 'create') {-- threadTarget = (SNOWFLAKE.test(m.discord_thread_id || '') ? m.discord_thread_id : null) || url.searchParams.get('thread_id') || null;-- url.searchParams.set('wait', 'true');-- if (threadTarget) url.searchParams.set('thread_id', threadTarget);-- body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);-- } else {-- method = d.action === 'edit' ? 'PATCH' : 'DELETE';-- url.pathname = `${url.pathname.replace(/\/+$/, '')}/messages/${mapped.external_message_id}`;-- url.search = '';-- if (mapped.external_thread_id) url.searchParams.set('thread_id', mapped.external_thread_id);-- if (d.action === 'edit') body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);-- }---- let res;-- try {-- res = await fetchImpl(url.toString(), {-- method,-- headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), 'User-Agent': 'OpenVibe.Space relay' },-- body: body ? JSON.stringify(body) : undefined,-- signal: AbortSignal.timeout(SEND_TIMEOUT_MS),-- });-- } catch (err) {-- return await record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });-- }-- let answer = null;-- if (res.status !== 204) { try { answer = await res.json(); } catch { /* no JSON */ } }-- if (res.ok) {-- if (d.action === 'create') return await created(d, res.status, answer, { type, localId, threadTarget, mapping: m });-- if (d.action === 'delete') await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);-- else await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(mapped.id);-- return await record(d, { ok: true, status: res.status });-- }-- // The message itself is gone on Discord (someone deleted it there): a delete is done, an edit has nothing to edit.-- if (res.status === 404 && d.action !== 'create' && answer && Number(answer.code) === UNKNOWN_MESSAGE) {-- await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);-- return d.action === 'delete' ? await record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : await settle(d, 'skipped', 'the message is gone on Discord');-- }-- const detail = answer && (answer.message || answer.error) ? String(answer.message || answer.error) : '';-- let retryAfter = Number(res.headers.get('retry-after')) || 0;-- if (answer && answer.retry_after) retryAfter = Math.max(retryAfter, Number(answer.retry_after) || 0);-- const retry = res.status === 429 || res.status === 408 || res.status >= 500;-- const outcome = await record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });-- return res.status === 429 ? { outcome, rateLimited: true } : outcome;-- }---- /** A create was answered: map the message (both ways unique) and learn the mapping's channel. */-- async function created(d, status, msg, { type, localId, threadTarget, mapping }) {-- const id = msg && SNOWFLAKE.test(String(msg.id || '')) ? String(msg.id) : null;-- const channel = msg && SNOWFLAKE.test(String(msg.channel_id || '')) ? String(msg.channel_id) : null;-- if (!id || !channel) return await record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });-- return await db.tx(async () => {-- await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)-- VALUES ('discord', ?, 'out', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)-- .run(mapping.id, type, localId, d.thread_id, channel, threadTarget ? channel : null, id, msg.webhook_id ? String(msg.webhook_id) : null);-- if (!threadTarget) await db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);-- return await record(d, { ok: true, status });-- });-- }---- /**-- * Send every due delivery once, oldest first. One drain at a time. A 429 leaves the rest of that-- * webhook's queue for the next pass. → { delivered, retry, failed, skipped }-- */-- function drain() {-- if (draining) return draining;-- if (stopped) return Promise.resolve({ delivered: 0, retry: 0, failed: 0, skipped: 0 });-- draining = (async () => {-- const summary = { delivered: 0, retry: 0, failed: 0, skipped: 0 };-- if (!enabled) return summary;-- // Deletes go out even for a disabled mapping: what left the site leaves Discord too.-- const due = await db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id-- WHERE d.status = 'pending' AND (m.enabled = 1 OR d.action = 'delete') AND d.next_attempt_at <= ?-- ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));-- const paused = new Set();-- for (const d of due) {-- if (stopped) break;-- if (paused.has(d.mapping_id)) continue;-- const r = await send(d);-- if (r && typeof r === 'object') { paused.add(d.mapping_id); summary[r.outcome]++; } else summary[r]++;-- }-- return summary;-- })().finally(() => { draining = null; });-- return draining;-- }---- // ── lifecycle ────────────────────────────────────────────-- /** The Events worker, the gateway and the inbound handler (app.js), started and stopped with the relay. */-- function attach({ worker = null, gateway = null, inbound = null } = {}) { Object.assign(parts, { worker, gateway, inbound }); }-- /** The Events worker says it queues creates (true) or no longer does. */-- function useEventsForCreates(on) { createsFromEvents = !!on; }---- function start() {-- if (!enabled || timer) return;-- stopped = false;-- timer = setInterval(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, pollMs);-- if (timer.unref) timer.unref();-- kick();-- if (parts.worker) parts.worker.start();-- if (parts.gateway) parts.gateway.start();-- }-- /** Graceful stop: no further drains or reads; resolves when the drain and page in progress finished (the rest stay queued). */-- function stop() {-- stopped = true;-- if (timer) clearInterval(timer);-- timer = null;-- if (kickTimer) clearTimeout(kickTimer);-- kickTimer = null;-- const waits = [draining || Promise.resolve()];-- if (parts.worker) waits.push(parts.worker.stop());-- if (parts.gateway) waits.push(parts.gateway.stop());-- return Promise.all(waits).then(() => undefined);-- }---- // ── admin (staff) ────────────────────────────────────────-- function shapeMapping(m) {-- return {-- id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: refAllowed(m.webhook_url_ref) && !!env[m.webhook_url_ref],-- enabled: !!m.enabled, discord_channel_id: m.discord_channel_id || null, discord_thread_id: m.discord_thread_id || null, inbound: !!m.inbound, created_at: isoTime(m.created_at),-- };-- }-- const mappingQuery = 'SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id';-- async function listMappings() { return (await db.prepare(`${mappingQuery} ORDER BY m.id`).all()).map(shapeMapping); }-- const snowflakeOrNull = (v, name) => {-- if (v === undefined) return undefined;-- if (v === null || v === '') return null;-- if (!SNOWFLAKE.test(String(v))) throw new Error(`${name} is a Discord id (digits)`);-- return String(v);-- };-- /** { space_id, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? } → mapping (made, or updated) */-- async function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {-- if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);-- const channel = snowflakeOrNull(discord_channel_id, 'discord_channel_id');-- const thread = snowflakeOrNull(discord_thread_id, 'discord_thread_id');-- await db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)-- ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0, channel || null, thread || null, inbound ? 1 : 0);-- const row = await db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);-- return await updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });-- }-- /** { enabled?, inbound?, discord_channel_id?, discord_thread_id? } → mapping, or null when there is no such mapping */-- async function updateMapping(id, fields = {}) {-- const sets = [];-- const params = [];-- if (fields.enabled !== undefined) { sets.push('enabled = ?'); params.push(fields.enabled ? 1 : 0); }-- if (fields.inbound !== undefined) { sets.push('inbound = ?'); params.push(fields.inbound ? 1 : 0); }-- for (const k of ['discord_channel_id', 'discord_thread_id']) {-- const v = snowflakeOrNull(fields[k], k);-- if (v !== undefined) { sets.push(`${k} = ?`); params.push(v); }-- }-- if (sets.length && !(await db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)).changes) return null;-- const row = await db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);-- return row ? shapeMapping(row) : null;-- }-- async function setMappingEnabled(id, on) { return await updateMapping(id, { enabled: on }); }---- async function listDeliveries({ status = null, action = null, limit = 50 } = {}) {-- return (await db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug-- FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id-- WHERE (?::text IS NULL OR d.status = ?) AND (?::text IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit))-- .map((d) => ({-- id: d.id, dedupe_key: d.dedupe_key, action: d.action, source: d.source, event_id: d.event_id || null,-- status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,-- next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),-- mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },-- thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },-- post: d.post_id ? { id: d.post_id } : null,-- }));-- }-- /** Put a delivery that was not delivered back in the queue now, with a fresh attempt budget. */-- async function retry(id) {-- const n = (await db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ov_now() WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id)).changes;-- if (n) kick();-- return n;-- }-- /** Staff give up on a waiting or dead delivery: 'dropped', kept for the record. */-- async function drop(id) {-- return (await db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = ov_now() WHERE id = ? AND status IN ('pending', 'failed')").run(id)).changes;-- }---- /** What staff (and /api/ready) see: the queue by status, and the Events worker's and the gateway's state. */-- async function status() {-- const deliveries = Object.fromEntries(DELIVERY_STATUSES.map((s) => [s, 0]));-- for (const r of await db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;-- const off = (reason) => ({ enabled: false, reason });-- return {-- enabled,-- creates_from: createsFromEvents ? 'events' : 'forum',-- deliveries,-- dead_letters: deliveries.failed,-- messages_mapped: (await db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get()).n,-- events_worker: parts.worker ? await parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),-- inbound: parts.gateway ? { ...parts.gateway.status(), ...(parts.inbound ? { handled: parts.inbound.stats() } : {}) } : off(enabled ? 'DISCORD_RELAY_INBOUND is off' : 'the relay is off'),-- inbound_failures: (await db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get()).n,-- };-- }---- return {-- enabled, enqueueThread, enqueuePost, enqueueEdit, enqueueDelete, hideThreads, drain, start, stop, attach, useEventsForCreates, status,-- listMappings, addMapping, updateMapping, setMappingEnabled, listDeliveries, retry, drop, deliveryKey, ENV_NAME, refAllowed, message, postMessage,-- };--}----module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME, WEBHOOK_VAR, SNOWFLAKE, DELIVERY_STATUSES };ddeleted file mode 100644@@@ -1,164 +0,0 @@--'use strict';----/**-- * The Discord relay's Events worker (roadmap WS-J task 6): reads Space's own-- * space.thread.* and space.post.* from OpenVibe.Events' pull API (GET /api/v1/events,-- * capability events.event.read, Space's service token on audience openvibe.events) and queues-- * the relay's creates from them (relay/discord.js).-- *-- * - Cursor: the relay_cursors row 'discord-relay:events', moved in the same transaction as the-- * deliveries its page queued. A crash replays at most the page in progress, and the dedupe keys-- * make that replay queue nothing twice.-- * - First start (no cursor yet): it begins at Events' head. Threads from before are never announced.-- * - Events carry ids, never content: the worker reads the thread or post as it is now and the relay's-- * rules decide (public spaces only, never members-only, never origin 'discord'). Redacted events-- * and events from any other source are ignored.-- * - A gap (retention pruned events the worker had not read yet) is logged and shown to staff; the-- * creates in it are not relayed.-- * - While it runs, the forum's own create calls queue nothing (the worker is the one path). Off-- * without EVENTS_URL and OV_OAUTH_CLIENT_SECRET, or with DISCORD_RELAY_EVENTS=off: then the forum-- * queues creates itself, as the first relay did. Edits and deletes are queued by the forum in both-- * cases (Contracts has no space.thread/post update or delete events yet).-- */--const forumStore = require('../forum/store');----const TOPICS = ['space.thread.*', 'space.post.*'];--const CURSOR = 'discord-relay:events';--const MAX_PAGES_PER_TICK = 20;----/**-- * events: an openvibe-sdk events client (tests); otherwise one is made from eventsUrl, clientSecret and-- * tokenUrl (the Network's /oauth/token, client credentials of clientId).-- */--function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, clientSecret = null, tokenUrl = null, clientId = 'space', fetchImpl, enabled = true, pollMs = 5000, pageLimit = 100, now = () => Date.now() } = {}) {-- let reason = null;-- if (!relay || !relay.enabled) reason = 'the Discord relay is off (DISCORD_RELAY_ENABLED)';-- else if (!enabled) reason = 'DISCORD_RELAY_EVENTS=off';-- else if (!events && (!eventsUrl || !clientSecret || !tokenUrl)) reason = 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set';-- let client = events;-- if (!reason && !client) {-- const { createClient } = require('openvibe-sdk/core');-- const { createServiceTokenClient } = require('openvibe-sdk/auth');-- const { createEventsClient } = require('openvibe-sdk/events');-- const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret, fetch: fetchImpl });-- const core = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });-- client = createEventsClient(core, { source: 'space' });-- }-- const on = !reason;-- const stats = { seen: 0, queued: 0, pages: 0, last_error: null, last_error_at: null, last_ok_at: null, last_gap: null, started_at_seq: null };-- let timer = null;-- let running = null;-- let stopped = false;---- const iso = () => new Date(now()).toISOString();-- const readCursor = async () => await db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;-- async function writeCursor(cursor, latest) {-- await db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, ov_now())-- ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = ov_now()`).run(CURSOR, cursor, latest == null ? null : latest);-- }---- /** One event → deliveries queued (0 when it is not for the relay). */-- async function handle(ev) {-- stats.seen++;-- if (!ev || ev.source !== 'space' || !ev.payload || ev.payload.redacted) return 0;-- const p = ev.payload;-- if (p.visibility && p.visibility !== 'public') return 0;-- if (ev.event_type === 'space.thread.created') {-- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));-- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;-- return await relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });-- }-- if (ev.event_type === 'space.post.created') {-- const post = await forumStore.getPost(db, Number(p.post_id));-- const thread = post ? await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;-- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;-- return await relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });-- }-- return 0;-- }---- /** Read everything new (at most MAX_PAGES_PER_TICK pages). One tick at a time. */-- function tick() {-- if (!on) return Promise.resolve({ queued: 0 });-- if (running) return running;-- running = (async () => {-- let queuedNow = 0;-- try {-- let cur = await readCursor();-- if (!cur) {-- const head = await client.pull({ topic: TOPICS, afterSeq: 0, limit: 1 });-- const latest = Number(head.latest_seq) || 0;-- await writeCursor(latest, latest);-- stats.started_at_seq = latest;-- console.log(`[Relay] Events worker starts at seq ${latest} (earlier threads are not announced)`);-- cur = { cursor: latest, latest_seq: latest };-- }-- let cursor = Number(cur.cursor) || 0;-- for (let pages = 0; pages < MAX_PAGES_PER_TICK && !stopped; pages++) {-- const page = await client.pull({ topic: TOPICS, afterSeq: cursor, limit: pageLimit });-- if (stopped) break;-- if (page.gap) {-- stats.last_gap = { from_seq: page.gap.from_seq, to_seq: page.gap.to_seq, at: iso() };-- console.warn(`[Relay] Events gap: seq ${page.gap.from_seq}..${page.gap.to_seq} were pruned before the relay read them; threads and replies in it are not relayed`);-- }-- const next = Number(page.next_after_seq);-- const latest = Number(page.latest_seq);-- const moved = Number.isFinite(next) && next > cursor ? next : cursor;-- queuedNow += await db.tx(async () => {-- let n = 0;-- for (const item of page.events || []) n += await handle(item && item.event);-- await writeCursor(moved, Number.isFinite(latest) ? latest : null);-- return n;-- });-- stats.pages++;-- if (moved === cursor || !Number.isFinite(latest) || moved >= latest) break;-- cursor = moved;-- }-- stats.queued += queuedNow;-- stats.last_ok_at = iso();-- if (stats.last_error) console.log('[Relay] Events worker reading again');-- stats.last_error = null;-- stats.last_error_at = null;-- } catch (err) {-- const m = err && err.message ? err.message : String(err);-- if (m !== stats.last_error) console.warn('[Relay] Events worker cannot read (will retry):', m);-- stats.last_error = m;-- stats.last_error_at = iso();-- }-- return { queued: queuedNow };-- })().finally(() => { running = null; });-- return running;-- }---- function start() {-- if (!on || timer) return;-- stopped = false;-- relay.useEventsForCreates(true);-- timer = setInterval(() => { tick(); }, pollMs);-- if (timer.unref) timer.unref();-- tick();-- }-- /** Graceful stop: no further reads; resolves when the page in progress has been handled. */-- function stop() {-- stopped = true;-- if (timer) clearInterval(timer);-- timer = null;-- return running ? running.then(() => undefined) : Promise.resolve();-- }---- async function status() {-- if (!on) return { enabled: false, reason };-- const cur = await readCursor();-- return {-- enabled: true, running: !!timer, topics: TOPICS,-- cursor: cur ? cur.cursor : null, latest_seq: cur ? cur.latest_seq : null,-- lag: cur && cur.latest_seq != null ? Math.max(cur.latest_seq - cur.cursor, 0) : null,-- events_seen: stats.seen, deliveries_queued: stats.queued, started_at_seq: stats.started_at_seq,-- last_ok_at: stats.last_ok_at, last_error: stats.last_error, last_error_at: stats.last_error_at, last_gap: stats.last_gap,-- };-- }---- return { enabled: on, reason, tick, start, stop, status, TOPICS, CURSOR };--}----module.exports = { createRelayEventsWorker, TOPICS, CURSOR };ddeleted file mode 100644@@@ -1,199 +0,0 @@--'use strict';----/**-- * Replies from Discord (roadmap WS-J task 6, inbound; DISCORD_RELAY_INBOUND=on with DISCORD_BOT_TOKEN).-- * The gateway (relay/discord-gateway.js) hands message events here.-- *-- * What becomes a post: only messages in a mapped channel (a mapping with inbound on: its-- * discord_channel_id or discord_thread_id, or a Discord thread started from one of its messages), and-- * only replies to something in the external message map:-- * - a Discord reply (message_reference) to a mapped message in the same channel, or-- * - any message in a Discord thread started from a mapped message (that thread's id is the message's).-- * Everything else in the channel is ignored. The post goes into the mapped message's local thread with-- * origin 'discord', no author subject (never attributed to anyone on the site; the Discord display name-- * is kept in posts.relay_author and shown with a "Discord" badge), and the map gets its direction 'in'-- * row. Edits on Discord edit the post (a new revision, edited_by 'discord'); deletes on Discord delete-- * it. A delete of a message the relay sent only marks it gone there (the thread stays).-- *-- * Loop prevention: webhook messages (the relay's own among them), bots, the bot itself and system-- * messages are never taken in; a message already in the map is never taken twice (and the map's unique-- * key refuses it anyway); posts with origin 'discord' are never relayed out (relay/discord.js).-- * Limits: perMinute messages per Discord author per mapping and 10× that per mapping (a rolling-- * minute), maxChars per message (longer text is cut). Mentions, roles, channels, custom emoji and-- * timestamps become plain text and @everyone/@here are defused; the rest is Markdown, which the-- * forum's safe renderer shows (render/markdown.js: no raw HTML, safe links only). Attachments are-- * noted, never fetched. The thread must still be public, open (not locked) and not members-only.-- * What could not be applied goes to relay_inbound_failures: ids and the reason, never the text.-- */--const forumStore = require('../forum/store');--const { SNOWFLAKE } = require('./discord');----const KEEP_FAILURES = 1000;--const MESSAGE_TYPES = new Set([0, 19]); // DEFAULT, REPLY----/** A Discord display name as plain text: no control or format characters, one line, at most 32. */--function cleanName(s) {-- const t = String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F-\u009F\u200b-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, '').replace(/\s+/g, ' ').trim();-- return t.slice(0, 32);--}----/** Discord message text → forum Markdown (see the header). '' when there is nothing to show. */--function toMarkdown(d, maxChars = 4000) {-- const names = new Map((Array.isArray(d.mentions) ? d.mentions : []).map((u) => [String(u && u.id), cleanName(u && (u.global_name || u.username))]));-- let text = (typeof d.content === 'string' ? d.content : '')-- .replace(/<@!?(\d{15,21})>/g, (_, id) => `@${names.get(id) || 'someone'}`)-- .replace(/<@&\d{15,21}>/g, '@role')-- .replace(/<#\d{15,21}>/g, '#channel')-- .replace(/<\/([\w-]{1,32}(?: [\w-]{1,32}){0,2}):\d{15,21}>/g, '/$1')-- .replace(/<a?:(\w{1,32}):\d{15,21}>/g, ':$1:')-- .replace(/<t:(-?\d{1,13})(?::[tTdDfFR])?>/g, (_, sec) => {-- const t = new Date(Number(sec) * 1000);-- return Number.isNaN(t.getTime()) ? '' : `${t.toISOString().slice(0, 16).replace('T', ' ')} UTC`;-- })-- .replace(/@(everyone|here)/g, '@\u200b$1')-- .replace(/\r\n?/g, '\n')-- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')-- .trim();-- if (text.length > maxChars) text = `${text.slice(0, Math.max(maxChars - 1, 0)).trimEnd()}…`;-- const files = Array.isArray(d.attachments) ? d.attachments.length : 0;-- if (files) text += `${text ? '\n\n' : ''}_(${files} attachment${files === 1 ? '' : 's'} on Discord)_`;-- return text;--}----function createDiscordInbound({ db, perMinute = 6, maxChars = 4000, now = () => Date.now() } = {}) {-- const windows = new Map(); // key → timestamps in the last minute-- const counts = { applied: 0, ignored: 0, failed: 0 };---- function allow(key, limit) {-- const t = now();-- const list = (windows.get(key) || []).filter((x) => t - x < 60_000);-- if (list.length >= limit) { windows.set(key, list); return false; }-- list.push(t);-- windows.set(key, list);-- if (windows.size > 5000) for (const [k, l] of windows) if (!l.length || t - l[l.length - 1] >= 60_000) windows.delete(k);-- return true;-- }---- const mapByMessage = async (id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;-- const mappingById = async (id) => await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;---- function ignore(reason) { counts.ignored++; return { status: 'ignored', reason }; }-- async function failure(event, d, mappingId, error) {-- await db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)-- VALUES (?, ?, ?, ?, ?, ?)`).run(mappingId || null, event, d.channel_id ? String(d.channel_id) : null, d.id ? String(d.id) : null, d.author && d.author.id ? String(d.author.id) : null, String(error).slice(0, 300));-- await db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);-- counts.failed++;-- return { status: 'failed', error };-- }-- /** Is the local thread still somewhere a reply from Discord may land? → null or why not */-- async function closed(thread) {-- if (!thread || thread.deleted_at) return 'the thread was deleted';-- const space = await forumStore.getSpaceById(db, thread.space_id);-- if (!space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is not public';-- if (thread.locked) return 'the thread is locked';-- return null;-- }---- async function create(d, botUserId) {-- if (d.webhook_id) return ignore('a webhook message (the relay\'s own included)');-- const author = d.author || {};-- if (!author.id || author.bot || author.system || (botUserId && String(author.id) === String(botUserId))) return ignore('a bot or system message');-- if (d.type !== undefined && !MESSAGE_TYPES.has(Number(d.type))) return ignore('not a plain message or a reply');-- if (!SNOWFLAKE.test(String(d.id || '')) || !SNOWFLAKE.test(String(d.channel_id || ''))) return ignore('malformed ids');-- if (await mapByMessage(d.id)) return ignore('already relayed');-- const channel = String(d.channel_id);-- // A Discord thread started from a mapped message, else a reply to one in the same channel.-- let anchor = await mapByMessage(channel);-- // (message_reference type 1 is a forward, not a reply: never taken in.)-- if (!anchor && d.message_reference && d.message_reference.message_id && !Number(d.message_reference.type || 0)) {-- const ref = await mapByMessage(String(d.message_reference.message_id));-- if (ref && ref.external_channel_id === channel) anchor = ref;-- }-- if (!anchor) return ignore('not a reply to a relayed message');-- const mapping = await mappingById(anchor.mapping_id);-- if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');-- const mapped = [mapping.discord_channel_id, mapping.discord_thread_id].filter(Boolean);-- const inMapped = mapped.includes(channel) || (channel === anchor.external_message_id && mapped.includes(anchor.external_channel_id));-- if (!inMapped) return ignore('not a mapped channel');-- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);-- const why = await closed(thread);-- if (why) return await failure('MESSAGE_CREATE', d, mapping.id, why);-- if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return await failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');-- const body = toMarkdown(d, maxChars);-- if (!body) return await failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');-- const name = cleanName((d.member && d.member.nick) || author.global_name || author.username) || 'Discord';-- try {-- const post = await db.tx(async () => {-- const p = await forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });-- await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)-- VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)`).run(mapping.id, p.id, thread.id, channel, String(d.id));-- return p;-- });-- counts.applied++;-- return { status: 'applied', post_id: post.id };-- } catch (err) {-- if (err.code === '23505') return ignore('already relayed');-- return await failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);-- }-- }---- async function update(d) {-- if (!d.id) return ignore('malformed ids');-- const row = await mapByMessage(d.id);-- if (!row) return ignore('not a relayed message');-- if (row.direction !== 'in') return ignore('the relay\'s own message');-- if (typeof d.content !== 'string') return ignore('no text change');-- const mapping = await mappingById(row.mapping_id);-- if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');-- const post = await forumStore.getPost(db, row.local_id);-- if (!post || post.deleted_at) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');-- const why = await closed(await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));-- if (why) return await failure('MESSAGE_UPDATE', d, mapping.id, why);-- const body = toMarkdown(d, maxChars);-- if (!body) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');-- await forumStore.editPost(db, post.id, body, 'discord');-- await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(row.id);-- counts.applied++;-- return { status: 'applied', post_id: post.id };-- }---- /** A delete on Discord. Applied whatever the mapping's switches: taking something down never waits. */-- async function remove(id) {-- const row = await mapByMessage(id);-- if (!row) return ignore('not a relayed message');-- await db.tx(async () => {-- await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ? AND external_deleted_at IS NULL').run(row.id);-- if (row.direction === 'in') await forumStore.softDeletePost(db, row.local_id);-- });-- counts.applied++;-- return { status: 'applied', post_id: row.direction === 'in' ? row.local_id : null, note: row.direction === 'out' ? 'the relay\'s message was deleted on Discord; the thread stays' : undefined };-- }---- /** One gateway dispatch (type, data, { botUserId }) → result (an array for a bulk delete). */-- async function handle(type, d, { botUserId = null } = {}) {-- if (!d || typeof d !== 'object') return ignore('no data');-- if (type === 'MESSAGE_CREATE') return await create(d, botUserId);-- if (type === 'MESSAGE_UPDATE') return await update(d);-- if (type === 'MESSAGE_DELETE') return await remove(String(d.id || ''));-- if (type === 'MESSAGE_DELETE_BULK') return (await Promise.all((Array.isArray(d.ids) ? d.ids : []).map(async (id) => await remove(String(id)))));-- return ignore('not a message event');-- }---- // ── staff ────────────────────────────────────────────────-- async function listFailures({ all = false, limit = 50 } = {}) {-- return (await db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id-- WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit))-- .map((f) => ({-- id: f.id, event: f.event, error: f.error, mapping: f.mapping_id ? { id: f.mapping_id, space: f.space_slug || null } : null,-- discord: { channel_id: f.external_channel_id, message_id: f.external_message_id, author_id: f.external_author_id },-- dismissed: !!f.dismissed_at, created_at: f.created_at ? new Date(`${String(f.created_at).replace(' ', 'T')}Z`).toISOString() : null,-- }));-- }-- async function dismiss(id) {-- return (await db.prepare('UPDATE relay_inbound_failures SET dismissed_at = ov_now() WHERE id = ? AND dismissed_at IS NULL').run(id)).changes;-- }---- return { handle, listFailures, dismiss, stats: () => ({ ...counts }) };--}----module.exports = { createDiscordInbound, toMarkdown, cleanName };ddeleted file mode 100644@@@ -1,286 +0,0 @@--'use strict';----/**-- * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,-- * posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics-- * first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,-- * posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js-- * (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings and images.-- * Complete without JavaScript: every action is a link or a form post.-- */--const config = require('../config');--const ld = require('./jsonld');--const { renderPage, SITE_NAME } = require('./layout');--const { escapeHtml: esc } = require('./highlight');--const { markdownToText } = require('./markdown');--const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');--const f = require('./forum');----const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };--const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };--const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;--const trail = (space) => [['Spaces', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];--const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;----/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */--function boardRow(sp) {-- const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';-- const last = sp.last_post-- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`-- : '<span class="muted small">No posts yet</span>';-- const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';-- return `<tr>-- <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>-- <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}-- ${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}-- </div></td>-- <td class="num" data-label="Topics">${num(sp.thread_count)}</td>-- <td class="num" data-label="Posts">${num(sp.post_count)}</td>-- <td class="board-last" data-label="Last post">${last}</td>-- </tr>`;--}----function boardTable(spaces, caption) {-- return `<table class="board-table"><caption class="sr-only">${esc(caption)}</caption>-- <thead><tr><th scope="col">Space</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead>-- <tbody>${spaces.map(boardRow).join('')}</tbody></table>`;--}----// ── /s ───────────────────────────────────────────────────────--function boardIndexPage({ groups = [], user, canModerate = false }) {-- const body = `--<header class="page-head">-- ${crumbs([['Spaces', '/s']])}-- <h1>Spaces</h1>-- <p class="muted">Forums and feeds for the people of OpenVibe. Forum boards read like a classic forum, newest reply on top. Feeds rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="${config.communityUrl}/pulse">Pulse</a>.</p>-- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></p>' : ''}--</header>--${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">-- <h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>-- ${g.description ? `<p class="muted small board-group-p">${esc(g.description)}</p>` : ''}-- ${boardTable(g.spaces, g.name)}--</section>`).join('') : '<p class="empty">No spaces yet.</p>'}--${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post.</p>'}`;-- return renderPage({-- title: 'Spaces',-- description: `Forums and feeds on ${SITE_NAME}: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.`,-- canonicalPath: '/s',-- active: 'spaces',-- feeds: [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }])],-- body,-- });--}----/** A topic's page links (1 2 3 … last) for the topic table. */--function topicPages(base, pages) {-- if (pages <= 1) return '';-- const n = pages <= 5 ? Array.from({ length: pages }, (_, i) => i + 1) : [1, 2, 3, null, pages];-- return ` <span class="topic-pages">Pages: ${n.map((p) => (p ? `<a href="${esc(base)}${p > 1 ? `?page=${p}` : ''}">${p}</a>` : '…')).join(' ')}</span>`;--}----function topicRow(t, space) {-- const base = `/s/${space.slug}/t/${t.slug}`;-- const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';-- const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';-- const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');-- return `<tr class="${t.pinned ? 'sticky' : ''}">-- <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>-- <td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}-- <span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>-- <td class="num" data-label="Replies">${num(t.reply_count)}</td>-- <td class="num" data-label="Views">${num(t.views)}</td>-- <td class="board-last" data-label="Last post">${timeTag(t.last_activity_at)}<span class="small muted">by ${who(t.last_post ? t.last_post.author : t.author)}${t.last_post ? ` <a href="${esc(base)}${t.pages > 1 ? `?page=${t.pages}` : ''}#post-${Number(t.last_post.id)}" aria-label="Go to the last post">»</a>` : ''}</span></td>-- </tr>`;--}----/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */--function settingsForm(space, groups = []) {-- const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;-- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Space settings</summary>--<form class="post-form" method="post" action="/s/${esc(space.slug)}/settings">-- <label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>-- <label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>-- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>-- <label class="check"><input type="checkbox" name="votes" value="1"${space.votes ? ' checked' : ''}> Up and down votes</label>-- <label class="check"><input type="checkbox" name="reactions" value="1"${space.reactions ? ' checked' : ''}> Ratings on posts (Agree, Winner, Funny…)</label>-- <label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !space.group)}${groups.map((g) => opt(g.slug, g.name, space.group && space.group.slug === g.slug)).join('')}</select></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit">Save settings</button></div>--</form></details>`;--}----/**-- * Moderators: who moderates the space, a remove (or "Step down") button per person, and an add-by-@username form, no-- * JS. `me` is the viewer's subject; `error` is a refused add or remove, shown in the box (which then opens).-- */--function moderatorsBox(space, moderators = [], { me = null, error = null } = {}) {-- const slug = esc(space.slug);-- const rows = moderators.map((m) => {-- const name = m.username ? `@${m.username}` : (m.display_name || 'this person');-- const self = me && m.subject === me;-- return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}-- <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;-- }).join('');-- return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>--${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}--${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this space yet; discussion staff do.</p>'}--<form class="post-form" method="post" action="/s/${slug}/moderators">-- <label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>--</form></details>`;--}----// ── /s/:space (forum style) ──────────────────────────────────--function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {-- const filtered = !!(category || status);-- const indexable = space.visibility === 'public' && !space.members_only && !filtered;-- const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');-- const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;-- const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';-- const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';-- const start = viewer.can_start !== false-- ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`-- : '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';-- const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);-- const body = `--<header class="page-head">-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}-- <h1>${esc(space.name)}${f.visBadge(space)}</h1>-- ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}--</header>--${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}--${chatRoom}--<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>--${tags}${statuses}--<section data-results>--${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>-- <thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>-- <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}--${pagerHtml}--</section>--${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;-- return renderPage({-- title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,-- description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on ${SITE_NAME}.`,-- canonicalPath: href({ page }),-- robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',-- active: 'spaces',-- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],-- body,-- });--}----/** The author's panel beside a post: picture, name, posts, since when, the ratings they get most. */--function authorPanel(p) {-- const a = p.author;-- if (!a) return '<aside class="postbit-author"><span class="postbit-name">Anonymous</span></aside>';-- const pic = avatarUrl(a, 96);-- const name = a.display_name || a.username || (a.is_ai ? 'AI' : a.is_system ? 'OpenVibe' : 'Someone');-- const initial = esc(String(name).trim()[0] || '?').toUpperCase();-- const st = p.author_stats;-- return `<aside class="postbit-author">-- ${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}-- <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>-- ${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}-- ${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>-- ${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}-- </aside>`;--}----function postbit(p, n, { base, page, catalogue, canReply }) {-- if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;-- const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';-- return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">-- ${authorPanel(p)}-- <div class="postbit-main">-- <header class="postbit-head small muted"><a href="#post-${p.id}">${p.is_opening ? 'Topic' : `Reply #${n - 1}`}</a> · ${esc(fmtDate(p.created_at))}${p.revision > 1 ? ` · <span title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}${quote}</header>-- <div class="md">${p.body_html}</div>-- ${f.postImages(p)}-- ${f.reactionBar(p, base, page, catalogue)}-- </div>-- </article>`;--}----// ── /s/:space/t/:slug (forum style) ──────────────────────────--function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {-- const base = `/s/${space.slug}/t/${thread.slug}`;-- const opening = posts.find((p) => p.is_opening) || null;-- const gated = !!(space.members_only || thread.members_only);-- const indexable = space.visibility === 'public' && !gated;-- const first = (page - 1) * perPage;-- const replyBlock = viewer.can_reply-- ? `<form class="post-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>-- <h2 class="h3">Reply</h2>-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>-- ${f.imageField(attachmentsEnabled)}-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>-- </form>`-- : thread.locked ? '<p class="alert">This topic is locked.</p>'-- : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';-- const pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);-- const body = `--<article class="thread forum-topic">-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}-- <header class="thread-head">-- ${space.votes === false ? '' : f.voteForm(base, thread, viewer)}-- <div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>-- ${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}-- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>-- </div>-- </header>-- ${f.crosspostBlock(crosspost, base)}-- ${f.modActions({ base, space, thread, viewer, categories })}-- ${pagerHtml}-- <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>-- ${pagerHtml}-- ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}--</article>`;-- const description = gated ? `A topic for VIP members in ${space.name} on ${SITE_NAME}.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);-- return renderPage({-- title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,-- description,-- canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,-- robots: indexable ? 'index,follow' : 'noindex,nofollow',-- ogType: 'article',-- published: thread.created_at || undefined,-- modified: (opening && opening.updated_at) || undefined,-- active: 'spaces',-- historyType: 'page',-- historyTitle: thread.title,-- footerVariant: 'compact',-- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [-- ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),-- ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),-- ],-- body,-- });--}----/** Moderators: a new space (either style), no JS. */--function newSpacePage({ groups = [], error = null, values = {} }) {-- const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;-- const body = `--<header class="page-head">-- ${crumbs([['Spaces', '/s'], ['New space', '/s/new-space']])}-- <h1>New space</h1>-- <p class="muted">A forum board reads like a classic forum: the newest reply on top, author panels and quotes. A feed ranks posts by votes, like a subreddit. You can change the style later.</p>--</header>--<form class="post-form" method="post" action="/s/new-space">-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>-- <label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>-- <label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>-- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>-- <label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>-- <label class="check"><input type="checkbox" name="reactions" value="1"${values.reactions !== false ? ' checked' : ''}> Ratings on posts</label>-- <label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit">Create space</button></div>--</form>`;-- return renderPage({ title: 'New space', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });--}----module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox };ddeleted file mode 100644@@@ -1,350 +0,0 @@--'use strict';----/**-- * Forum pages — /s (the board index), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.-- * Complete without JavaScript: sorting and pagination are links, replying, voting and the-- * moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe-- * Markdown renderer's output (render/markdown.js).-- */--const config = require('../config');--const ld = require('./jsonld');--const { renderPage, SITE_NAME } = require('./layout');--const { escapeHtml: esc } = require('./highlight');--const { markdownToText } = require('./markdown');--const { timeTag, fmtDate, num, authorHtml } = require('./pages');----const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };--const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };--/** A request's or roadmap item's status, and a thread's category. */--const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');--const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');--const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };----function who(a) {-- if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';-- if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;-- if (a.is_system) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span>`;-- if (a.is_relay) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>`;-- return authorHtml(a);--}----function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = null } = {}) {-- const q = new URLSearchParams();-- if (category) q.set('category', category);-- if (status) q.set('status', status);-- if (sort && sort !== 'hot') q.set('sort', sort);-- if (page > 1) q.set('page', String(page));-- const s = q.toString();-- return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;--}----function pager(href, page, pages, labels = ['Newer', 'Older']) {-- if (pages <= 1) return '';-- return `<nav class="pager" aria-label="Pages">-- ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}-- <span class="pager-info">Page ${page} of ${pages}</span>-- ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}-- </nav>`;--}----const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);--/** A members-only (OpenVibe.VIP) space or thread. */--const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');--const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');----// ── /s ───────────────────────────────────────────────────────--function spacesPage({ spaces }) {-- const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `-- <li class="space-item">-- <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>-- ${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}-- <p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>-- </li>`).join('')}-- </ul>` : '<p class="empty">No spaces yet.</p>';-- const body = `--<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a></nav>-- <h1>Spaces</h1>-- <p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="${config.communityUrl}/pulse">Pulse</a>.</p>--</header>--${list}`;-- return renderPage({-- title: 'Spaces',-- description: `Spaces on ${SITE_NAME}: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.`,-- canonicalPath: '/s',-- active: 'spaces',-- feeds: [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }])],-- body,-- });--}----// ── /s/:space ────────────────────────────────────────────────--function threadRow(t, space) {-- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;-- return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">-- ${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}-- <div class="thread-main">-- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}-- <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>-- </div>-- </li>`;--}----/**-- * The space's chat room on OpenVibe.Chat (a link), and for the space's owner or staff the no-JS forms to-- * attach one (by address or link) or detach it. Empty when there is none and the viewer cannot attach.-- */--function chatRoomBox(space, { canManage = false, error = null } = {}) {-- const r = space.chat_room;-- if (!r && !canManage) return '';-- const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';-- const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';-- const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <form class="post-form" method="post" action="/s/${esc(space.slug)}/chat-room">-- <label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>-- </form>-- ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}--</details>` : '';-- return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;--}----function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '', chatRoom = '' }) {-- const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');-- const filtered = !!(category || status);-- const indexable = space.visibility === 'public' && !space.members_only && !filtered;-- const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;-- const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';-- const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';-- const canStart = viewer.can_start !== false;-- const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';-- const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`-- : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;-- const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;-- const body = `--<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>-- <h1>${esc(space.name)}${visBadge(space)}</h1>-- ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}-- ${start}--</header>--${chatRoom}--${categoryNav}${statusNav}--<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>--<section data-results>-- ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}-- ${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}--</section>--${footer}`;-- return renderPage({-- title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,-- description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on ${SITE_NAME}.`,-- canonicalPath: spaceHref(space.slug, { sort, page, category, status }),-- robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',-- active: 'spaces',-- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],-- body,-- });--}----/** The pin / lock / delete / members-only / status / category buttons (no JS). */--function modActions({ base, space, thread, viewer, categories = [] }) {-- return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">-- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>-- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}-- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}-- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}-- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}-- ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}-- </div>` : '';--}----// ── /s/:space/t/:slug ────────────────────────────────────────--function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {-- if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;-- return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">-- <header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>-- <div class="md">${p.body_html}</div>-- ${postImages(p)}-- ${reactionBar(p, base, page, catalogue)}-- </article>`;--}--/**-- * Where a thread came from, where else it lives, and (signed in) a form to crosspost it to another space.-- */--function crosspostBlock(c, base) {-- if (!c) return '';-- const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';-- const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';-- const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crosspost</summary>-- <form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';-- return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';--}----/** A post's images. */--function postImages(p) {-- return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;--}--/**-- * A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,-- * and a picker with every rating (no JS: a <details> and a form). `base` is the thread's path.-- */--function reactionBar(p, base, page, catalogue = []) {-- const given = p.reactions || [];-- if (!given.length && !p.can_react) return '';-- const title = (r) => `${r.label}${r.raters && r.raters.length ? `: ${r.raters.join(', ')}${r.count > r.raters.length ? ` and ${num(r.count - r.raters.length)} more` : ''}` : ''}`;-- const chip = (r) => (p.can_react-- ? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`-- : `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);-- const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {-- const list = catalogue.filter((r) => r.group === g);-- return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';-- }).join('')}</div></details>` : '';-- const inner = `${given.map(chip).join('')}${picker}`;-- return p.can_react-- ? `<form class="reactions" method="post" action="${esc(base)}/react" aria-label="Ratings"><input type="hidden" name="post" value="${Number(p.id)}"><input type="hidden" name="page" value="${Number(page) || 1}">${inner}</form>`-- : `<div class="reactions" aria-label="Ratings">${inner}</div>`;--}----const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');----function voteForm(base, thread, viewer) {-- if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;-- const btn = (value, icon, label) => {-- const active = thread.my_vote === value;-- return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="fa-solid ${icon}" aria-hidden="true"></i></button>`;-- };-- return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;--}----function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {-- const base = `/s/${space.slug}/t/${thread.slug}`;-- const opening = posts.find((p) => p.is_opening) || null;-- const gated = !!(space.members_only || thread.members_only);-- const indexable = space.visibility === 'public' && !gated;-- const first = (page - 1) * perPage;-- const replyBlock = viewer.can_reply-- ? `<form class="post-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- <label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>-- ${imageField(attachmentsEnabled)}-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>-- </form>`-- : thread.locked ? '<p class="alert">This thread is locked.</p>'-- : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';-- const modBlock = modActions({ base, space, thread, viewer, categories });---- const body = `--<article class="thread">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>-- <header class="thread-head">-- ${space.votes === false ? '' : voteForm(base, thread, viewer)}-- <div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>-- ${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}-- <p class="thread-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>-- </div>-- </header>-- ${crosspostBlock(crosspost, base)}-- ${modBlock}-- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>-- ${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}-- ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}--</article>`;-- const description = gated ? `A thread for VIP members in ${space.name} on ${SITE_NAME}.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);-- return renderPage({-- title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,-- description,-- canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,-- robots: indexable ? 'index,follow' : 'noindex,nofollow',-- ogType: 'article',-- published: thread.created_at || undefined,-- modified: (opening && opening.updated_at) || undefined,-- active: 'spaces',-- historyType: 'page',-- historyTitle: thread.title,-- footerVariant: 'compact',-- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [-- ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),-- ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),-- ],-- body,-- });--}----// ── /s/:space/new ────────────────────────────────────────────--function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {-- const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';-- const form = user ? `<form class="post-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>-- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}-- ${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}-- <label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>-- ${categoryField}-- <label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>-- ${imageField(attachments)}-- <label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>-- <div class="form-actions">-- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>-- <span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>-- </div>--</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;-- const body = `--<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>-- <h1>New thread in ${esc(space.name)}</h1>--</header>--${form}`;-- return renderPage({-- title: `New thread · ${space.name}`,-- description: `Start a thread in ${space.name} on ${SITE_NAME}.`,-- canonicalPath: `/s/${space.slug}/new`,-- robots: 'noindex,follow',-- active: 'spaces',-- footerVariant: 'compact',-- body,-- });--}----// ── members-only teaser (403) ────────────────────────────────--/**-- * What someone without the creator's VIP membership sees: the space (and thread title), why, and-- * the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).-- */--function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {-- const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';-- const why = reason === 'not_signed_in' || !user-- ? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`-- : reason === 'vip_unavailable' || reason === 'entitlement_unknown'-- ? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'-- : '<p class="muted">Your account does not have an active membership for this.</p>';-- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;-- const body = `--<header class="page-head">-- ${crumbs}-- <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>-- ${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}--</header>--<section class="members-only">-- <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>-- <p>${vipJoin(mo)}</p>-- ${why}--</section>`;-- return renderPage({-- title: `${thread ? thread.title : space.name} · members only`,-- description: `For ${creator}'s OpenVibe.VIP members on ${SITE_NAME}.`,-- canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,-- robots: 'noindex,nofollow',-- active: 'spaces',-- footerVariant: 'compact',-- body,-- });--}----module.exports = {-- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,-- pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, crosspostBlock, STATUS_LABELS,--};ddeleted file mode 100644@@@ -1,51 +0,0 @@--'use strict';----/**-- * Server-side syntax highlighting (highlight.js, common language set). Rendered into the-- * HTML so crawlers and no-JS readers get the same page as everyone else. Anything the-- * highlighter cannot name comes back escaped as plain text — never unhighlighted markup.-- */--const hljs = require('highlight.js/lib/common');----const MAX_HIGHLIGHT_BYTES = 200 * 1024; // beyond this the block is served as plain text----// Language names people type → highlight.js names (and a few aliases).--const ALIASES = {-- text: 'plaintext', txt: 'plaintext', plain: 'plaintext', log: 'plaintext',-- js: 'javascript', node: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',-- py: 'python', sh: 'bash', shell: 'bash', zsh: 'bash', console: 'shell', ps1: 'powershell',-- yml: 'yaml', md: 'markdown', 'c++': 'cpp', cc: 'cpp', h: 'c', cs: 'csharp', 'c#': 'csharp',-- rb: 'ruby', rs: 'rust', golang: 'go', kt: 'kotlin', docker: 'dockerfile', html: 'xml', htm: 'xml', vue: 'xml', svg: 'xml',-- conf: 'ini', toml: 'ini', env: 'ini', make: 'makefile', mk: 'makefile', patch: 'diff',--};----function escapeHtml(s) {-- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));--}----function resolveLanguage(name) {-- const raw = String(name || 'text').trim().toLowerCase();-- const mapped = ALIASES[raw] || raw;-- return hljs.getLanguage(mapped) ? mapped : 'plaintext';--}----/**-- * @returns {{ html: string, language: string, lines: number, highlighted: boolean }}-- * html is the inner HTML of a <code> element.-- */--function highlight(content, language) {-- const text = String(content || '');-- const lines = text.length ? text.split('\n').length : 0;-- const lang = resolveLanguage(language);-- if (lang === 'plaintext' || Buffer.byteLength(text, 'utf8') > MAX_HIGHLIGHT_BYTES) {-- return { html: escapeHtml(text), language: lang, lines, highlighted: false };-- }-- try {-- const out = hljs.highlight(text, { language: lang, ignoreIllegals: true });-- return { html: out.value, language: lang, lines, highlighted: true };-- } catch {-- return { html: escapeHtml(text), language: 'plaintext', lines, highlighted: false };-- }--}----module.exports = { highlight, escapeHtml, resolveLanguage };ddeleted file mode 100644@@@ -1,77 +0,0 @@--'use strict';----/**-- * JSON-LD builders for the pages (WebSite, breadcrumbs, threads and their authors) and the-- * two small text helpers they share with the templates. renderPage emits them through-- * openvibe-shared/seo's jsonLdTag (via headTags).-- */--const config = require('../config');--const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./layout');----function isoDate(v) {-- if (!v) return null;-- const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);-- return Number.isNaN(d.getTime()) ? null : d.toISOString();--}----function clean(s, max) {-- const t = String(s || '').replace(/\s+/g, ' ').trim();-- return t.length > max ? `${t.slice(0, max - 1)}…` : t;--}----// ── JSON-LD ──────────────────────────────────────────────────--function websiteLd() {-- return {-- '@context': 'https://schema.org', '@type': 'WebSite',-- name: SITE_NAME, url: `${config.baseUrl}/s`, description: DEFAULT_DESCRIPTION,-- publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },-- };--}----function breadcrumbLd(items) {-- return {-- '@context': 'https://schema.org', '@type': 'BreadcrumbList',-- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),-- };--}----/** Who wrote a thread or post, for JSON-LD: a person, the AI label (never a person), or anonymous. */--function discussionAuthorLd(a) {-- if (!a) return { '@type': 'Person', name: 'Anonymous' };-- if (a.is_ai) return { '@type': 'Organization', name: a.display_name || 'OpenVibe AI' };-- const name = a.display_name || a.username || 'Anonymous';-- return a.username ? { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(a.username)}` } : { '@type': 'Person', name };--}----/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */--function threadLd({ space, thread, posts, opening, description }) {-- const { markdownToText } = require('./markdown');-- const url = abs(`/s/${space.slug}/t/${thread.slug}`);-- const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);-- return {-- '@context': 'https://schema.org',-- '@type': 'DiscussionForumPosting',-- headline: clean(thread.title, 110),-- text: opening && !opening.deleted ? markdownToText(opening.body_markdown, 5000) : description,-- url,-- mainEntityOfPage: url,-- author: discussionAuthorLd(thread.author),-- datePublished: isoDate(thread.created_at) || undefined,-- dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,-- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },-- commentCount: thread.reply_count,-- interactionStatistic: [-- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },-- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/LikeAction', userInteractionCount: Math.max(Number(thread.score) || 0, 0) },-- ],-- comment: replies.map((p) => ({-- '@type': 'Comment',-- text: markdownToText(p.body_markdown, 2000),-- author: discussionAuthorLd(p.author),-- datePublished: isoDate(p.created_at) || undefined,-- url: `${url}#post-${p.id}`,-- })),-- };--}----module.exports = { isoDate, clean, websiteLd, breadcrumbLd, threadLd, discussionAuthorLd };@@@ -4,8 +4,7 @@* Page shell — every page on the site is server-rendered through openvibe-shared/shell: full <head>* SEO (description, canonical, robots, Open Graph, Twitter card, JSON-LD and the ai-summary page* summary), the shared OpenVibe Frame (theme-loader first so there is no flash, navbar.js +-- * footer.js from the Network, the SSR footer), plus this site's small stylesheet and its-- * progressive script.++ * footer.js from the Network, the SSR footer), plus this site's small stylesheet.*/const crypto = require('crypto');const ovServe = require('openvibe-shared/serve');@@@ -14,11 +13,15 @@ const appIcon = require('openvibe-shared/app-icon');const fs = require('fs');const path = require('path');const config = require('../config');--const { escapeHtml } = require('./highlight');++++/** Escape a value for HTML text or a quoted attribute. */++function escapeHtml(s) {++ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++}const SITE_NAME = 'OpenVibe.Space';const NETWORK_URL = 'https://openvibe.network';--const DEFAULT_DESCRIPTION = 'The forums of OpenVibe: public and members-only spaces, threads, replies, votes and categories — read without an account, sign in with your OpenVibe identity to post.';++const DEFAULT_DESCRIPTION = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up@@@ -59,11 +62,11 @@ function navbarInit(opts) {const cfg = {service: 'space',apiBase: NETWORK_URL,++ // Space's own navigation is the home page; the forum lives on OpenVibe.Community now (owner++ // decision, 2026-10-08), so its link points there rather than at a /s that redirects.links: [-- { label: 'Spaces', href: '/s', active: opts.active === 'spaces' },-- // Pulse is OpenVibe.Community's feed (D12): Space shows forums, the hub shows network activity.-- { label: 'Pulse', href: `${config.communityUrl}/pulse` },-- { label: 'Community', href: config.communityUrl },++ { label: 'Home', href: '/', active: opts.active === 'home' },++ { label: 'The forum', href: `${config.communityUrl}/s` },],menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },@@@ -83,15 +86,13 @@ function footerInit(opts) {mount: '#ov-footer',brandName: SITE_NAME,updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now-- tagline: 'Forums for the people of OpenVibe — free speech within the rules.',++ tagline: 'Code and dynamic pages for the people of OpenVibe.',legalBase: config.liveUrl,links: [{heading: 'Space',items: [-- { name: 'Spaces', url: '/s' },-- { name: 'Roadmap', url: '/s/roadmap' },-- { name: 'Feedback', url: '/s/feedback' },-- { name: 'Pulse', url: `${config.communityUrl}/pulse` },++ { name: 'Home', url: '/' },++ { name: 'The forum', url: `${config.communityUrl}/s` },{ name: 'OpenVibe.Community', url: config.communityUrl },{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Space' },],@@@ -102,10 +103,10 @@ function footerInit(opts) {/*** @param {object} o* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),-- * ogImage, imageAlt, jsonLd (array), alternates ([{ hreflang, href }]), body (main HTML), active ('spaces'),-- * feeds ([{ title, href }] RSS alternates; default: the latest-threads feed),-- * historyType ('page'|'thread'), historyTitle, footerVariant ('full'|'compact'), bodyClass,-- * styles (Shared stylesheets by name, linked before space.css so this site's rules win: ['showcase.css']),++ * ogImage, imageAlt, jsonLd (array), alternates ([{ hreflang, href }]), body (main HTML), active ('home'),++ * feeds ([{ title, href }] RSS alternates; default: none),++ * historyType, historyTitle, footerVariant ('full'|'compact'), bodyClass,++ * styles (Shared stylesheets by name, linked before space.css so this site's rules win),* published/modified (ISO, for article:*), noFrame (error pages during outages)*/function renderPage(o) {@@@ -113,19 +114,19 @@ function renderPage(o) {const canonical = abs(o.canonicalPath || '/');const nav = navbarInit(o);const foot = footerInit(o);-- const feeds = o.feeds || [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }];++ const feeds = o.feeds || [];// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.const html = shell.page({name: SITE_NAME, service: 'space', lang: 'en',-- title: o.title || `${SITE_NAME} — the forums of OpenVibe`,++ title: o.title || `${SITE_NAME} — code and dynamic pages`,titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,jsonLd: o.jsonLd, alternates: o.alternates,summary: description, url: canonical, updated: o.modified,-- navbar: nav, home: '/s', navLinks: nav.links.map(({ label, href }) => ({ label, href })),++ navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),footer: { service: 'space', variant: 'full', updates: `${config.communityUrl}/updates` },head: [o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',@@@ -135,7 +136,6 @@ function renderPage(o) {...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),`<link rel="stylesheet" href="${asset('css/space.css')}">`,'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',-- `<script src="${asset('js/space.js')}" defer></script>`,`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,].filter(Boolean).join('\n'),@@@ -156,4 +156,4 @@ document.addEventListener('DOMContentLoaded', function () {return o.ogImage ? html : html.replace('<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:card" content="summary">');}--module.exports = { renderPage, asset, assetVersion, abs, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };++module.exports = { renderPage, asset, assetVersion, abs, setRelease, escapeHtml, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };ddeleted file mode 100644@@@ -1,226 +0,0 @@--'use strict';----/**-- * A small, safe Markdown renderer for forum posts.-- *-- * Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;-- * the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,-- * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep-- * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".-- * Fenced code blocks go through the same highlight.js wrapper posts use (which escapes).-- *-- * Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so-- * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an-- * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare-- * https:// links. No images, tables, footnotes or raw HTML.-- */--const { escapeHtml: esc, highlight } = require('./highlight');----const MAX_SOURCE = 100_000;--const MAX_QUOTE_DEPTH = 3;--// Every pattern here runs on untrusted text on each page view, so none may backtrack--// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.--const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;--const FENCE = { test: (line) => FENCE_RE.test(line.trimEnd()) };--const HEADING_RE = /^ {0,3}(#{1,6})\s+([\s\S]*)$/;--const QUOTE = /^ {0,3}>\s?(.*)$/;--const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;--const UL = /^ {0,3}[-*+]\s+(.*)$/;--const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;--const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;----/** [, hashes, text] for an ATX heading (text without the optional closing #s), or null. */--function headingMatch(line) {-- const m = line.match(HEADING_RE);-- if (!m) return null;-- const text = m[2].trimEnd().replace(/#+$/, '').trimEnd();-- return /[\u2028\u2029]/.test(text) ? null : [m[0], m[1], text];--}--const HEADING = { test: (line) => headingMatch(line) !== null };----/**-- * Same result as s.replace(/<open>([\s\S]*?\S)<close>/g, …) for a delimiter pair, in linear time:-- * the lazy regex rescans to the end of the text for every opener that has no closer (quadratic).-- * Content runs from the end of an opener to the first closer after it; once one opener finds no-- * closer, no later opener can either, so the scan stops.-- */--function pairUp(s, open, close, wrap) {-- const o = new RegExp(open.source, 'g');-- const c = new RegExp(close.source, 'g');-- let out = '';-- let pos = 0;-- for (;;) {-- o.lastIndex = pos;-- const m = o.exec(s);-- if (!m) break;-- const start = m.index + m[0].length;-- c.lastIndex = start;-- const k = c.exec(s);-- if (!k) break;-- const end = k.index + 1; // the closer pattern starts with the content's last (non-space) character-- out += s.slice(pos, m.index) + wrap(m, s.slice(start, end));-- pos = k.index + k[0].length;-- }-- return out + s.slice(pos);--}----/**-- * Same result as s.replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, …) in O(n log n): an opener-- * is the tail of a backtick run (longest first, as the regex tries it), its closer the next whole-- * run of exactly that length not preceded by a newline, with non-empty content not starting with one.-- */--function codeSpans(s, onCode) {-- const runs = [];-- const byLen = new Map();-- const re = /`+/g;-- let m;-- while ((m = re.exec(s))) {-- runs.push([m.index, m[0].length]);-- if (s[m.index - 1] !== '\n') {-- if (!byLen.has(m[0].length)) byLen.set(m[0].length, []);-- byLen.get(m[0].length).push(m.index);-- }-- }-- const nextAt = (list, from) => {-- let lo = 0;-- let hi = list.length;-- while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid] < from) lo = mid + 1; else hi = mid; }-- return lo < list.length ? list[lo] : -1;-- };-- let out = '';-- let pos = 0;-- for (const [at, len] of runs) {-- if (at < pos) continue;-- const contentStart = at + len;-- if (contentStart >= s.length || s[contentStart] === '\n') continue;-- for (let n = len; n >= 1; n--) {-- const list = byLen.get(n);-- const close = list ? nextAt(list, contentStart + 1) : -1;-- if (close < 0) continue;-- out += s.slice(pos, at + len - n) + onCode(s.slice(contentStart, close));-- pos = close + n;-- break;-- }-- }-- return out + s.slice(pos);--}----function unescapeEntities(s) {-- return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));--}----/** Inline formatting over one block's text (raw, unescaped in; safe HTML out). */--function inline(raw) {-- const slots = [];-- const hold = (html) => `\u0000${slots.push(html) - 1}\u0000`;-- // 1. code spans keep their content literal-- let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));-- // 2. everything else is escaped before any tag is written-- s = esc(s);-- // 3. [text](url) — only safe schemes; the rest stays as text-- s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {-- if (!SAFE_URL.test(unescapeEntities(url))) return m;-- return hold(`<a href="${url}" rel="nofollow ugc noopener">${emphasis(text)}</a>`);-- });-- // 4. bare links-- s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {-- let url = m, tail = '';-- while (/[.,;:!?)\]'"]$/.test(url) || /&(quot|#39|gt|lt);$/.test(url)) {-- const ent = url.match(/&(quot|#39|gt|lt);$/);-- const cut = ent ? ent[0].length : 1;-- tail = url.slice(-cut) + tail; url = url.slice(0, -cut);-- }-- return url.length > 8 ? hold(`<a href="${url}" rel="nofollow ugc noopener">${url}</a>`) + tail : m;-- });-- s = emphasis(s);-- s = s.replace(/\n/g, '<br>\n');-- // Restore held pieces (a link's text may itself hold a code span).-- for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => slots[Number(n)] || '');-- return s;--}----/** Bold / italic / strike over already-escaped text. */--function emphasis(s) {-- s = pairUp(s, /\*\*(?=\S)/, /\S\*\*/, (_m, t) => `<strong>${t}</strong>`);-- s = pairUp(s, /(^|[^\w])__(?=\S)/, /\S__(?!\w)/, (m, t) => `${m[1]}<strong>${t}</strong>`);-- s = s-- .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')-- .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>');-- return pairUp(s, /~~(?=\S)/, /\S~~/, (_m, t) => `<del>${t}</del>`);--}----function isBlockStart(line) {-- return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);--}----function blocks(lines, depth) {-- const out = [];-- let i = 0;-- while (i < lines.length) {-- const line = lines[i];-- if (!line.trim()) { i++; continue; }---- let m = line.trimEnd().match(FENCE_RE);-- if (m) {-- const fence = m[1], lang = m[2];-- const closing = new RegExp(`^ {0,3}\\${fence[0]}{${fence.length},}\\s*$`);-- const body = [];-- i++;-- while (i < lines.length && !closing.test(lines[i])) body.push(lines[i++]);-- i++; // closing fence (or end of input)-- const code = body.join('\n');-- const hl = lang ? highlight(code, lang) : { html: esc(code), language: 'plaintext' };-- out.push(`<pre class="md-code"><code class="hljs language-${esc(hl.language)}">${hl.html}</code></pre>`);-- continue;-- }-- if (HR.test(line)) { out.push('<hr>'); i++; continue; }-- m = headingMatch(line);-- if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }-- if (QUOTE.test(line)) {-- const inner = [];-- while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);-- out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);-- continue;-- }-- if (UL.test(line) || OL.test(line)) {-- const ordered = !UL.test(line);-- const re = ordered ? OL : UL;-- const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;-- const items = [];-- while (i < lines.length && lines[i].trim()) {-- const mm = lines[i].match(re);-- if (mm) items.push(ordered ? mm[2] : mm[1]);-- else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;-- else break;-- i++;-- }-- const tag = ordered ? 'ol' : 'ul';-- out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);-- continue;-- }-- const para = [];-- while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);-- out.push(`<p>${inline(para.join('\n'))}</p>`);-- }-- return out.join('\n');--}----/** Markdown source → safe HTML (see the header for what is supported). */--function renderMarkdown(source) {-- const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');-- return blocks(text.split('\n'), 0);--}----/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */--function markdownToText(source, max = 300) {-- const t = String(source == null ? '' : source).replace(/\u0000/g, '')-- .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')-- .replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')-- .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')-- .replace(/(\*\*|__|~~|`)/g, '')-- .replace(/\s+/g, ' ')-- .trim();-- return t.length > max ? `${t.slice(0, max - 1)}…` : t;--}----module.exports = { renderMarkdown, markdownToText };@@@ -1,61 +1,29 @@'use strict';/**-- * Page templates. Plain template strings, every user value escaped, no framework. The markup-- * is the whole page for crawlers and no-JS readers; public/js/space.js only adds the-- * comforts (in-place pagination, the reply form's upload path).++ * Page templates. Plain template strings, every value escaped, no framework. The markup is the whole++ * page for crawlers and no-JS readers; the shared Frame adds the navbar, footer and theme.*-- * What is left of the shared page layer after the forum moved off Community: the author and time-- * helpers every forum template uses, and the error page. Pastes, comments, submissions and their-- * pages stayed on OpenVibe.Community.++ * Space publishes one page of its own while it is rebuilt: the home page, which says what Space will++ * host and points at the forum on OpenVibe.Community. It claims nothing Space does not have.*/const config = require('../config');--const ld = require('./jsonld');--const { renderPage } = require('./layout');--const { escapeHtml: esc } = require('./highlight');++const { renderPage, escapeHtml: esc } = require('./layout');--// ── Small helpers ────────────────────────────────────────────--function timeAgo(v) {-- const iso = ld.isoDate(v);-- if (!iso) return '';-- const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);-- if (s < 60) return 'just now';-- if (s < 3600) return `${Math.floor(s / 60)}m ago`;-- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;-- if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;-- if (s < 86400 * 365) return `${Math.floor(s / (86400 * 30))}mo ago`;-- return `${Math.floor(s / (86400 * 365))}y ago`;--}--function fmtDate(v) {-- const iso = ld.isoDate(v);-- if (!iso) return '';-- return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');--}--function timeTag(v) {-- const iso = ld.isoDate(v);-- return iso ? `<time datetime="${esc(iso)}" title="${esc(fmtDate(v))}">${esc(timeAgo(v))}</time>` : '';--}--function num(n) { return Number(n || 0).toLocaleString('en-US'); }--function authorName(p) { return p.display_name || p.username || 'Anonymous'; }--/** A person's picture at `size` px: their own, else the network's avatar address (a generated initial when none). */--function avatarUrl(p, size = 96) {-- if (!p) return null;-- if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;-- return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;--}--function authorHtml(p, { link = true } = {}) {-- const name = authorName(p);-- const initial = esc(name.trim()[0] || '?').toUpperCase();-- const avatarSrc = p.avatar_url ? (/^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`) : null;-- // No picture on the post's own record: the network's avatar address answers for any account (the person's-- // picture, or a generated initial), so an author looks the same here as on every other OpenVibe site.-- const netAvatar = !avatarSrc && p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=44` : null;-- const avatar = (avatarSrc || netAvatar)-- ? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`-- : `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;-- const inner = `${avatar}<span>${esc(name)}</span>`;-- if (!link || !p.username) return `<span class="author">${inner}</span>`;-- return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;++const FORUM_URL = `${config.communityUrl}/s`;++const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');++++/** The home page: what Space will host, and where the forum went. */++function homePage() {++ const body = `++<section class="hero">++ <p class="eyebrow">OpenVibe.Space</p>++ <h1>Code and dynamic pages for the OpenVibe network</h1>++ <p class="lede">Space is being rebuilt to host <strong>code</strong> and <strong>dynamic pages</strong>, and <strong>spaces</strong>: apps people build that use their OpenVibe account, the network's services and the SDK.</p>++ <p class="forum-note">The forum moved to OpenVibe.Community → <a href="${esc(FORUM_URL)}">${esc(FORUM_LABEL)}</a></p>++ <p class="muted">Nothing is published here yet.</p>++</section>`;++ return renderPage({ canonicalPath: '/', active: 'home', body });}// ── Errors ───────────────────────────────────────────────────@@@ -65,9 +33,9 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',<p class="eyebrow">${status}</p><h1>${esc(title)}</h1>${message ? `<p class="lede">${esc(message)}</p>` : ''}-- ${links ? `<p><a class="btn" href="/s">All spaces</a></p>` : ''}++ ${links ? `<p><a class="btn" href="/">Home</a> <a class="btn" href="${esc(FORUM_URL)}">The forum</a></p>` : ''}</section>`;return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });}--module.exports = { errorPage, timeAgo, timeTag, fmtDate, num, authorName, authorHtml, avatarUrl };++module.exports = { homePage, errorPage };ddeleted file mode 100644@@@ -1,67 +0,0 @@--'use strict';----/**-- * Space's seam to OpenVibe.VIP: members-only forum spaces and threads.-- *-- * gate.check({ subject, type: 'space'|'thread', id, owner }) → { allow, reason }-- * POST /api/v1/policies/evaluate through the product cache (createVipCache), with the owner-- * Space stored on the row and the product's default gate-- * { requirement: 'member', binding: 'space:members_only' } — the creator's own VIP rule for-- * the resource, if they set one, wins. The owner passes without asking VIP.-- * gate.joinUrl(owner, username) → the creator's page on openvibe.vip-- * gate.cache invalidate / handleEvent (vip.membership.changed, …) / clear-- *-- * Convergence: Space drops the member's cached answers at once when vip.membership.changed arrives-- * (server/events-consumer.js), so a cached "yes" outlives VIP's "no" by at most config.vip.ttlMs-- * (30 s by default, the bound if the event is missed); see OpenVibe.VIP's README for the end-to-end bound.-- * Fails closed: no client secret, VIP down, a refused token or a malformed answer is a "no".-- */--const { serviceAuth, ids } = require('openvibe-contracts');--const { createVipClient, createVipCache } = require('./vip-client');----const FALLBACK = Object.freeze({ requirement: 'member', binding: 'space:members_only' });----function createVipGate({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {-- const vipConfig = config.vip || {};-- const secret = config.oauth && config.oauth.clientSecret;-- const tokenClient = secret ? serviceAuth.createTokenClient({-- tokenUrl: `${config.networkInternalUrl}/oauth/token`,-- clientId: config.oauth.clientId,-- clientSecret: secret,-- audience: 'openvibe.vip',-- scope: 'vip.resource.policy.evaluate',-- fetchImpl,-- }) : null;-- const client = createVipClient({-- baseUrl: vipConfig.internalUrl || 'http://127.0.0.1:4620',-- tokenClient,-- getToken: tokenClient ? null : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set'); },-- fetch: fetchImpl,-- timeoutMs: vipConfig.timeoutMs || 2000,-- log,-- });-- const cache = createVipCache({-- vip: client,-- ttlMs: vipConfig.ttlMs || 30_000,-- denyTtlMs: vipConfig.denyTtlMs || 10_000,-- unavailableTtlMs: vipConfig.unavailableTtlMs || 2_000,-- now,-- });---- async function check({ subject, type, id, owner }) {-- if (!owner) return { allow: true, reason: 'open' };-- if (subject && subject === owner) return { allow: true, reason: 'owner' };-- if (!subject) return { allow: false, reason: 'not_signed_in' };-- const d = await cache.evaluate({ subject, resource: { service: 'space', type, id: String(id) }, owner, fallback: FALLBACK });-- return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };-- }---- const base = (vipConfig.publicUrl || 'https://openvibe.vip').replace(/\/$/, '');-- const joinUrl = (owner, username) => `${base}/${encodeURIComponent(username || owner)}`;---- return { check, joinUrl, cache, client, FALLBACK, bounds: cache.bounds };--}----const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);----module.exports = { createVipGate, isUserSubject, FALLBACK };ddeleted file mode 100644@@@ -1,4 +0,0 @@--'use strict';--// The VIP consumer seam is openvibe-sdk/vip (createVipClient, createVipCache), published once for every--// product instead of a copy of openvibe-vip/client in each. Kept at this path so callers do not change.--module.exports = require('openvibe-sdk/vip');ddeleted file mode 100644@@@ -1,55 +0,0 @@--'use strict';----/**-- * Up/down votes on forum threads — one row per (thread, person), value 1 or -1.-- *-- * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same transaction, by-- * recomputing the target's score from the vote rows. The score is never incremented or decremented,-- * so interleaved votes from any number of connections or processes cannot drift it. The transaction-- * first locks the target row (SELECT … FOR UPDATE), so votes on one target queue and each recount-- * sees every vote committed before it.-- */--const TARGETS = {-- thread: { votes: 'thread_votes', key: 'thread_id', table: 'threads', counts: false },--};----/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */--async function applyVote(db, kind, targetId, subject, value) {-- const t = TARGETS[kind];-- if (!t) throw new Error(`unknown vote target ${kind}`);-- if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');-- return await db.tx(async () => {-- await db.prepare(`SELECT id FROM ${t.table} WHERE id = ? FOR UPDATE`).get(targetId);-- if (value === 0) {-- await db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);-- } else {-- await db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)-- ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = ov_now()`)-- .run(targetId, subject, value);-- }-- const agg = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS score,-- COUNT(*) FILTER (WHERE value = 1) AS upvotes,-- COUNT(*) FILTER (WHERE value = -1) AS downvotes-- FROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);-- await db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);-- return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };-- });--}----/** The person's current votes on some targets. → Map id → 1|-1 */--async function myVotes(db, kind, targetIds, subject) {-- const t = TARGETS[kind];-- const out = new Map();-- if (!subject || !targetIds.length) return out;-- const rows = await db.prepare(`SELECT ${t.key} AS id, value FROM ${t.votes} WHERE ${t.key} = ANY(?::bigint[]) AND subject_id = ?`).all([...new Set(targetIds)], subject);-- for (const r of rows) out.set(r.id, r.value);-- return out;--}----/** Parse a request's vote value: 1, -1 or 0 (numbers or numeric strings), else null. */--function parseVote(v) {-- const n = typeof v === 'string' && /^-?[01]$/.test(v.trim()) ? Number(v) : v;-- return n === 1 || n === -1 || n === 0 ? n : null;--}----module.exports = { applyVote, myVotes, parseVote };ddeleted file mode 100644@@@ -1,147 +0,0 @@--'use strict';--/**-- * Images on forum posts (WS-J task 2): an upload goes to OpenVibe.Media's Object API v2 as a med_ object-- * the person owns (init → content → complete, Community's service token, X-OV-Subject), metadata stripped-- * and the type taken from the bytes; a thread or reply names at most 4 of the person's own unattached-- * uploads; posts show them; the no-JS forms upload and attach in one step; someone else's upload, a-- * non-image, an anonymous upload and Media being unconfigured are refused.-- */--const assert = require('assert');--const http = require('http');--const crypto = require('crypto');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const { createMediaObjects } = require('../server/media/objects');--const { sniffImage } = require('../server/forum/service');----// A 1×1 PNG with a tEXt chunk (metadata that must not reach Media), with real lengths and CRCs.--const zlib = require('zlib');--const chunk = (type, data) => {-- const len = Buffer.alloc(4); len.writeUInt32BE(data.length);-- const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);-- const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));-- return Buffer.concat([len, td, crc]);--};--const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;--const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('tEXt', Buffer.from('Comment\0secret', 'latin1')),-- chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);----(async () => {-- // ── A stub Media (Object API v2 for tenant space) ──-- const objects = new Map();-- const media = http.createServer((req, res) => {-- const chunks = [];-- req.on('data', (c) => chunks.push(c));-- req.on('end', () => {-- const body = Buffer.concat(chunks);-- const send = (status, obj) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj)); };-- if (req.url === '/oauth/token') return send(200, { access_token: 'svc-space', token_type: 'Bearer', expires_in: 300 });-- if (req.headers.authorization !== 'Bearer svc-space') return send(401, { code: 'auth.required' });-- if (req.method === 'POST' && req.url === '/api/v2/space/objects') {-- const b = JSON.parse(body);-- const id = ids.newId('media');-- objects.set(id, { ...b, owner: req.headers['x-ov-subject'], status: 'uploading' });-- return send(201, { id, object: { id } });-- }-- let m = req.url.match(/^\/api\/v2\/space\/objects\/(med_[0-9A-Z]+)\/content$/);-- if (req.method === 'PUT' && m) { const o = objects.get(m[1]); o.bytes = body; o.content_type = req.headers['content-type']; return send(200, { id: m[1], size_bytes: body.length }); }-- m = req.url.match(/^\/api\/v2\/space\/objects\/(med_[0-9A-Z]+)\/complete$/);-- if (req.method === 'POST' && m) {-- const o = objects.get(m[1]);-- const hash = crypto.createHash('sha256').update(o.bytes).digest('hex');-- if (hash !== JSON.parse(body).content_hash || o.bytes.length !== o.size_bytes) return send(400, { code: 'media.object.hash_mismatch' });-- o.status = 'ready';-- return send(200, { id: m[1], lifecycle_status: 'ready' });-- }-- send(404, {});-- });-- });-- await new Promise((r) => media.listen(0, '127.0.0.1', r));-- const mediaBase = `http://127.0.0.1:${media.address().port}`;-- const mediaObjects = createMediaObjects({ config: { oauth: { clientId: 'space', clientSecret: 'x'.repeat(40) }, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });---- const t = await boot({ appOpts: { mediaObjects, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const upload = (cookie, buf = PNG, name = 'cat.png') => {-- const fd = new FormData();-- fd.append('file', new Blob([buf], { type: 'image/png' }), name);-- return t.get('/api/v1/spaces/general/attachments', { method: 'POST', body: fd, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- };-- const json = (cookie, path, obj) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(obj), cookies: [`ov_token=${cookie}`] });---- await check('sniffing goes by the bytes', async () => {-- assert.strictEqual(sniffImage(PNG), 'image/png');-- assert.strictEqual(sniffImage(Buffer.from('GIF89a......')), 'image/gif');-- assert.strictEqual(sniffImage(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')), null);-- });---- let first;-- await check('upload: a med_ object owned by the person, metadata stripped, the public URL', async () => {-- const r = await upload(alexJwt);-- assert.strictEqual(r.status, 201, r.text);-- first = r.json().attachment;-- assert.match(first.media_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);-- assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);-- const o = objects.get(first.media_id);-- assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'unlisted', 'reachable by link, never listed on openvibe.media'); assert.strictEqual(o.status, 'ready');-- assert.ok(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');-- assert.ok(PNG.includes(Buffer.from('secret')));-- });---- await check('refused: signed out, not an image, too many, someone else\'s upload', async () => {-- assert.strictEqual((await upload(null)).status, 401);-- const svg = await upload(alexJwt, Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>x</script></svg>'), 'x.svg');-- assert.strictEqual(svg.status, 415);-- let r = await json(samJwt, '/api/v1/spaces/general/threads', { title: 'Stolen', body: 'mine now', attachments: [first.media_id] });-- assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.invalid');-- r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Too many', body: 'x', attachments: [1, 2, 3, 4, 5].map(() => first.media_id) });-- assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.too_many');-- });---- let thread;-- await check('a thread names its images; the post shows them; an attached upload cannot be reused', async () => {-- let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'My cat', body: 'Look.', attachments: [first.media_id] });-- assert.strictEqual(r.status, 201, r.text);-- thread = r.json().thread;-- assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [first.media_id]);-- const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();-- assert.strictEqual(page.posts[0].attachments[0].url, first.url);-- assert.strictEqual(page.attachments.enabled, true);-- r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });-- assert.strictEqual(r.status, 400, 'already on a post');-- const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;-- assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));-- });---- await check('no-JS reply form: the images are uploaded and attached in one step', async () => {-- const fd = new FormData();-- fd.append('body', 'Two more');-- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');-- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');-- const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });-- assert.strictEqual(r.status, 303, r.text.slice(0, 300));-- const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();-- const reply = page.posts.find((p) => p.body_markdown === 'Two more');-- assert.deepStrictEqual(reply.attachments.map((a) => a.filename), ['a.png', 'b.png']);-- assert.ok(reply.attachments.every((a) => objects.get(a.media_id).owner === sam.subject_id));-- const empty = new FormData();-- empty.append('body', '');-- empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');-- const before = objects.size;-- await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });-- assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');-- });---- await check('without the service principal, attaching is refused and the forms have no file field', async () => {-- const off = createMediaObjects({ config: { oauth: {}, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });-- assert.strictEqual(off.configured, false);-- await assert.rejects(off.uploadImage({ buffer: PNG, mime: 'image/png', filename: 'x.png', owner: alex.subject_id }), /service principal/);-- });---- media.close();-- await done(t);--})();@@@ -1,9 +1,9 @@'use strict';/**-- * The real process on a fresh database (the T10 finish line): `node server/index.js` with no-- * DATABASE_URL, so migrations run on an embedded PGlite database of its own; /api/ready is 2xx and-- * says what it serves, GET /s renders the seeded board index, / sends visitors there, /llms.txt and-- * /robots.txt answer, and SIGTERM stops it cleanly (exit 0, within the manifest's 5 s).++ * The real process on a fresh database: `node server/index.js` with no DATABASE_URL, so migrations run++ * on an embedded PGlite database of its own; /api/ready is 2xx and names the store, GET / renders the++ * home page, every forum URL redirects to OpenVibe.Community, /llms.txt, /robots.txt and /sitemap.xml++ * answer, and SIGTERM stops it cleanly (exit 0, within the manifest's 5 s).* Nothing here is mocked: this is the entry point the unit would run.*/const assert = require('assert');@@@ -31,9 +31,8 @@ async function check(name, fn) {...process.env,PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',SPACE_PGLITE_DIR: path.join(dir, 'pglite'),-- DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',++ DATABASE_URL: '', DATABASE_DIRECT_URL: '',BASE_URL: `http://127.0.0.1:${port}`,-- EVENTS_URL: '', SPACE_EVENTS_SECRET: '',},stdio: ['ignore', 'pipe', 'pipe'],});@@@ -64,31 +63,29 @@ async function check(name, fn) {assert.strictEqual(body.checks.db.detail.store, 'pglite');});-- await check('GET /s renders the board index with the seeded spaces', async () => {-- const r = await fetch(`${base}/s`);++ await check('GET / renders the home page: what Space will host, and the forum on Community', async () => {++ const r = await fetch(`${base}/`);assert.strictEqual(r.status, 200);const html = await r.text();-- assert.match(html, /<title>Spaces · OpenVibe\.Space<\/title>/);-- assert.match(html, /href="\/s\/general"/, 'a seeded space is on the board index');-- assert.match(html, /href="\/s\/showcase"/);-- assert.match(html, /OpenVibe\.Space — latest threads/, 'the threads feed is advertised');-- assert.match(html, /rel="canonical" href="http:\/\/127\.0\.0\.1:\d+\/s"/);++ assert.match(html, /<title>OpenVibe\.Space — code and dynamic pages<\/title>/);++ assert.match(html, /<h1>Code and dynamic pages for the OpenVibe network<\/h1>/);++ assert.match(html, /The forum moved to OpenVibe\.Community/);++ assert.match(html, /href="https:\/\/openvibe\.community\/s"/);});-- await check('a space and its feed render; / redirects to the board index', async () => {-- const space = await fetch(`${base}/s/general`);-- assert.strictEqual(space.status, 200);-- const html = await space.text();-- assert.match(html, /<h1>General/);-- const feed = await fetch(`${base}/s/feed.xml`);-- assert.strictEqual(feed.status, 200);-- assert.match(await feed.text(), /<rss version="2\.0"/);-- const root = await fetch(`${base}/`, { redirect: 'manual' });-- assert.strictEqual(root.status, 301);-- assert.strictEqual(root.headers.get('location'), '/s');++ await check('every forum URL is a permanent redirect to OpenVibe.Community', async () => {++ const s = await fetch(`${base}/s`, { redirect: 'manual' });++ assert.strictEqual(s.status, 301);++ assert.strictEqual(s.headers.get('location'), 'https://openvibe.community/s');++ const nested = await fetch(`${base}/s/general/t/1?x=2`, { redirect: 'manual' });++ assert.strictEqual(nested.status, 301);++ assert.strictEqual(nested.headers.get('location'), 'https://openvibe.community/s/general/t/1?x=2');++ const api = await fetch(`${base}/api/v1/spaces/general/threads`, { redirect: 'manual' });++ assert.strictEqual(api.status, 308);++ assert.strictEqual(api.headers.get('location'), 'https://openvibe.community/api/v1/spaces/general/threads');});-- await check('crawler endpoints answer: robots.txt, llms.txt, sitemap.xml', async () => {++ await check('crawler endpoints answer: robots.txt, llms.txt, sitemap.xml (the home page alone)', async () => {const robots = await fetch(`${base}/robots.txt`);assert.strictEqual(robots.status, 200);assert.match(await robots.text(), /Sitemap:/);@@@ -97,7 +94,9 @@ async function check(name, fn) {assert.match(await llms.text(), /OpenVibe\.Space/);const map = await fetch(`${base}/sitemap.xml`);assert.strictEqual(map.status, 200);-- assert.match(await map.text(), /<loc>http:\/\/127\.0\.0\.1:\d+\/s<\/loc>/);++ const xml = await map.text();++ assert.match(xml, new RegExp(`<loc>http://127\\.0\\.0\\.1:${port}/</loc>`));++ assert.doesNotMatch(xml, /\/s</, 'no forum pages in the sitemap');});await check('SIGTERM stops it cleanly (exit 0, within the manifest’s 5 s)', async () => {@@@ -105,7 +104,7 @@ async function check(name, fn) {const r = await Promise.race([exited, sleep(7000).then(() => null)]);assert.ok(r, 'the process did not exit within 7 s');assert.deepStrictEqual(r, { code: 0, signal: null }, `exit ${JSON.stringify(r)}\n${out}`);-- assert.match(out, /\[Space\] stopped: subscriptions off, relay off, outbox off/, 'the stop is logged');++ assert.match(out, /\[Space\] stopped/, 'the stop is logged');});} finally {if (child.exitCode === null) child.kill('SIGKILL');ddeleted file mode 100644@@@ -1,138 +0,0 @@--'use strict';--/**-- * Categories, feature requests and the Roadmap space (WS-J tasks 1 and 8): seeded categories, filtering-- * by category and status, a request's first status and staff-only status changes, category moves by the-- * author or staff, staff-only roadmap threads, the roadmap sync from docs/roadmap/public.json (create,-- * update in place with the history kept, unchanged, bad items skipped) and the no-JS pages.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');----(async () => {-- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });-- const call = (path, { method = 'GET', cookie, json, form } = {}) => {-- const h = {};-- let body;-- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }-- if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }-- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- };---- await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {-- const spaces = (await call('/api/v1/spaces')).json().spaces;-- assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['help', 'discussion'], ['feedback', 'request'], ['roadmap', 'roadmap'], ['showcase', 'discussion'], ['off-topic', 'discussion']]);-- assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);-- const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;-- assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);-- });---- let req;-- await check('a request starts open, in its category; unknown categories are refused', async () => {-- let r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Dark mode for clips', body: 'Please.', category: 'ideas' } });-- assert.strictEqual(r.status, 201, r.text);-- req = r.json().thread;-- assert.strictEqual(req.kind, 'request'); assert.strictEqual(req.status, 'open');-- assert.deepStrictEqual([req.category.slug, req.category.name], ['ideas', 'Ideas']);-- r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Player crashes', body: 'On Safari.', category: 'bugs' } });-- assert.strictEqual(r.status, 201);-- r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Nope', body: 'x', category: 'nope' } });-- assert.strictEqual(r.status, 404); assert.strictEqual(r.json().code, 'category.not_found');-- const plain = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Just talk', body: 'hi' } })).json().thread;-- assert.strictEqual(plain.kind, 'discussion'); assert.strictEqual(plain.status, null);-- });---- await check('filters: by category and by status; a bad status is 400', async () => {-- let r = (await call('/api/v1/spaces/feedback/threads?category=bugs')).json();-- assert.deepStrictEqual(r.threads.map((x) => x.title), ['Player crashes']);-- assert.strictEqual(r.categories.find((c) => c.slug === 'bugs').thread_count, 1);-- r = (await call('/api/v1/spaces/feedback/threads?status=open')).json();-- assert.strictEqual(r.total, 2);-- assert.strictEqual((await call('/api/v1/spaces/feedback/threads?status=shipped')).status, 400);-- assert.strictEqual((await call('/api/v1/spaces/general/threads?status=open')).status, 400, 'discussions have no status');-- });---- await check('status: staff only, from the kind\'s list', async () => {-- let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: alexJwt, json: { status: 'done' } });-- assert.strictEqual(r.status, 403);-- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'paused' } });-- assert.strictEqual(r.status, 400, 'paused is a roadmap status');-- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'planned' } });-- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().thread.status, 'planned');-- assert.deepStrictEqual((await call('/api/v1/spaces/feedback/threads?status=planned')).json().threads.map((x) => x.slug), [req.slug]);-- });---- await check('category: the author or staff move a thread; others cannot; deleting a category keeps its threads', async () => {-- let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: samJwt, json: { category: 'bugs' } });-- assert.strictEqual(r.status, 403);-- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: alexJwt, json: { category: 'questions' } });-- assert.strictEqual(r.json().thread.category.slug, 'questions');-- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: alexJwt, json: { name: 'Polls' } });-- assert.strictEqual(r.status, 403, 'only staff manage categories');-- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: modJwt, json: { name: 'Polls', position: 9 } });-- assert.strictEqual(r.status, 200, r.text);-- await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: modJwt, json: { category: 'polls' } });-- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'DELETE', cookie: modJwt });-- assert.strictEqual(r.status, 200);-- const thread = (await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread;-- assert.strictEqual(thread.category, null); assert.strictEqual(thread.status, 'planned');-- });---- await check('roadmap: people cannot start items; staff can', async () => {-- let r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: alexJwt, json: { title: 'My idea', body: 'x' } });-- assert.strictEqual(r.status, 403); assert.strictEqual(r.json().code, 'space.staff_threads');-- r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: modJwt, json: { title: 'A staff item', body: 'x', category: 'features' } });-- assert.strictEqual(r.status, 201); assert.strictEqual(r.json().thread.status, 'planned');-- });---- await check('roadmap sync: creates, updates in place (history kept), leaves unchanged items alone, skips bad ones', async () => {-- const db = t.db;-- const items = [-- { key: 'chat-calls', category: 'features', status: 'planned', title: 'Calls in Chat', summary: 'Calls move to Chat.' },-- { key: 'bad key!', status: 'planned', title: 'x', summary: 'y' },-- { key: 'nostatus', title: 'No status', summary: 'y' },-- ];-- const quiet = { warn() {} };-- assert.deepStrictEqual(await syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });-- let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');-- assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');-- assert.deepStrictEqual(await syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });-- const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];-- assert.deepStrictEqual(await syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });-- const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();-- assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');-- assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');-- const file = await syncFromFile(db, { log: quiet });-- assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));-- assert.deepStrictEqual((await syncFromFile(db, { log: quiet })).updated, 0, 'the shipped file syncs to a fixed point');-- });---- await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {-- let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });-- assert.strictEqual(r.status, 200);-- const html = r.text;-- assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));-- assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');-- r = await call('/s/roadmap', { cookie: alexJwt });-- assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));-- assert.ok(r.text.includes('badge-status status-in_progress'));-- r = await call('/s/roadmap/new', { cookie: alexJwt });-- assert.strictEqual(r.status, 403);-- r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });-- assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));-- r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });-- assert.strictEqual(r.status, 303);-- assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');-- r = await call('/s/feedback/new', { cookie: alexJwt });-- assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));-- });---- await done(t);--})();ddeleted file mode 100644@@@ -1,188 +0,0 @@--'use strict';--/**-- * A space's chat room (roadmap WS-I task 4; server/chat-rooms.js, forum/service.js attachChatRoom):-- * the space's owner (its creator, or the creator whose members' space it is) or staff attach a room on-- * OpenVibe.Chat; Community asks Chat with the person's own token and Chat answers whether they manage the-- * room. Attaching again is a no-op, another room replaces it, detach is idempotent; others, services and-- * signed-out visitors cannot attach; Chat's refusals (not the owner, a private room, Chat down) come back-- * as problems and as notices on the page; the space page links the room. A stub Chat stands in for-- * OpenVibe.Chat's POST/DELETE /api/chat/rooms/:slug/attachments.-- */--const assert = require('assert');--const http = require('http');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');----/** Stub Chat: rooms with owners (by Network subject), private rooms with members, attachments. */--function startStubChat() {-- const rooms = new Map();-- const attachments = new Map(); // `${room}|${space}` → attached_by subject-- const calls = [];-- const state = { down: false };-- const subjectOf = (auth) => {-- const tok = String(auth || '').replace(/^Bearer /, '');-- try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()).subject_id || null; } catch { return null; }-- };-- const server = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- if (state.down) { req.socket.destroy(); return; }-- const send = (status, body) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };-- const who = subjectOf(req.headers.authorization);-- let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch { /* */ }-- calls.push({ method: req.method, url: req.url, who, auth: req.headers.authorization || null, body });-- let m = /^\/api\/chat\/rooms\/([^/]+)\/attachments$/.exec(req.url);-- if (m && req.method === 'POST') {-- const room = rooms.get(m[1]);-- if (!who) return send(401, { error: 'Authentication required' });-- if (!room || (room.visibility === 'private' && !room.members.includes(who))) return send(404, { error: 'No such room', code: 'rooms.not_found' });-- if (room.owner !== who) return send(403, { error: 'Only the room\'s owner attaches it elsewhere', code: 'rooms.not_owner' });-- const key = `${room.slug}|${body.resource}`;-- const created = !attachments.has(key);-- if (created) attachments.set(key, who);-- return send(created ? 201 : 200, { attachment: { service: body.service, resource: body.resource, title: body.title }, created, room: { id: room.id, slug: room.slug, name: room.name, kind: room.kind, visibility: room.visibility } });-- }-- m = /^\/api\/chat\/rooms\/([^/]+)\/attachments\/space\/([^/]+)$/.exec(req.url);-- if (m && req.method === 'DELETE') {-- const room = rooms.get(m[1]);-- const key = `${m[1]}|${m[2]}`;-- if (!room) return send(404, { error: 'No such room' });-- if (!attachments.has(key)) return send(200, { ok: true, removed: false });-- if (room.owner !== who && attachments.get(key) !== who) return send(403, { error: 'no' });-- attachments.delete(key);-- return send(200, { ok: true, removed: true });-- }-- send(404, { error: 'Not found' });-- });-- });-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({-- url: `http://127.0.0.1:${server.address().port}`, rooms, attachments, calls, state,-- addRoom: (slug, o) => rooms.set(slug, { id: rooms.size + 1, slug, name: slug, kind: 'community', visibility: 'public', members: [], ...o }),-- close: () => new Promise((r) => server.close(r)),-- })));--}----(async () => {-- const chat = await startStubChat();-- process.env.OV_CHAT_INTERNAL_URL = chat.url;-- process.env.OV_CHAT_URL = 'https://openvibe.chat';-- const t = await boot({ });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const bossSubject = ids.newId('user');-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });-- // Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.-- await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);-- await t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);-- chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });-- chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });-- chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });-- chat.addRoom('sams-room', { name: 'Sam\'s Room', owner: sam.subject_id });-- chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });-- const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });-- const form = (jwt, path, fields, origin = 'https://openvibe.space') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });-- const rows = async () => await t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();---- await check('only the space\'s owner or staff attach, and only as themselves', async () => {-- const before = chat.calls.length;-- assert.strictEqual((await api('PUT', '/showcase/chat-room', null, { room: 'night-owls' })).status, 401);-- let r = await api('PUT', '/showcase/chat-room', samJwt, { room: 'sams-room' });-- assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'not the space\'s owner');-- const svc = net.signService({ sub: 'svc:live', cap: ['space.forum.manage', 'space.post.write'] });-- r = await api('PUT', '/showcase/chat-room', svc, { room: 'night-owls' });-- assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.person_only'], 'a service has no person\'s token to show Chat');-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });-- assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);-- assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');-- assert.deepStrictEqual(await rows(), []);-- });---- await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {-- let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'https://openvibe.chat/r/night-owls' });-- assert.strictEqual(r.status, 201, r.text);-- assert.deepStrictEqual(r.json().chat_room, { slug: 'night-owls', name: 'Night Owls', kind: 'community', visibility: 'public', url: 'https://openvibe.chat/r/night-owls', attached_at: r.json().chat_room.attached_at });-- const call = chat.calls[chat.calls.length - 1];-- assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'space', resource: 'showcase', title: 'Showcase' }]);-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });-- assert.deepStrictEqual([r.status, r.json().created], [200, false]);-- assert.strictEqual((await rows()).length, 1);-- assert.strictEqual((await rows())[0].attached_by, alex.subject_id);-- assert.strictEqual(chat.attachments.size, 1);-- assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');-- });---- await check('Chat\'s refusals: not the room\'s owner, a private room, Chat down; nothing changes', async () => {-- let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'stage' });-- assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.not_owner']);-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'secret' });-- assert.deepStrictEqual([r.status, r.json().code], [404, 'chat_room.not_found'], 'a private room is no more than a missing one');-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'no-such-room' });-- assert.strictEqual(r.json().code, 'chat_room.not_found');-- chat.state.down = true;-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });-- assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);-- const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });-- assert.strictEqual(f.status, 303);-- assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);-- const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });-- assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);-- chat.state.down = false;-- assert.strictEqual((await rows())[0].room_slug, 'night-owls');-- });---- await check('the space page links the room; its owner gets the forms; others only the link', async () => {-- let page = await t.get('/s/showcase');-- assert.strictEqual(page.status, 200);-- assert.match(page.text, /<section class="chat-room" id="chat-room"/);-- assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');-- page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');-- page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });-- assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));-- page = await t.get('/s/general');-- assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');-- page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });-- assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');-- // Forum-style spaces show it too.-- await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });-- assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);-- assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');-- });---- await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {-- // Staff replace the Showcase room with their own; Chat is told the space let go of night-owls (best effort).-- let r = await api('PUT', '/showcase/chat-room', bossJwt, { room: 'staff-room' });-- assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);-- assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/space/showcase'));-- assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');-- assert.strictEqual((await rows()).find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);-- // The owner puts theirs back and detaches it without JavaScript: both sides go.-- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });-- assert.strictEqual(r.status, 201);-- const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});-- assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);-- assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));-- assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');-- r = await api('DELETE', '/showcase/chat-room', alexJwt);-- assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');-- assert.strictEqual((await api('DELETE', '/general/chat-room', samJwt)).status, 403, 'only the owner or staff detach');-- const svc = net.signService({ sub: 'svc:live', cap: ['space.forum.manage'] });-- r = await api('DELETE', '/general/chat-room', svc);-- assert.deepStrictEqual([r.status, r.json().detached, r.json().chat], [200, true, 'unavailable'], 'a moderator service may detach (Chat keeps its side)');-- });---- await check('the creator whose members\' space it is may attach their room', async () => {-- const r = await api('PUT', '/off-topic/chat-room', samJwt, { room: 'sams-room' });-- assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.json().chat_room.name, 'Sam\'s Room');-- });---- await chat.close();-- await t.close();-- done();--})();ddeleted file mode 100644@@@ -1,200 +0,0 @@--'use strict';--/**-- * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug-- * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,-- * JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const forumStore = require('../server/forum/store');----const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));--const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);--const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });----(async () => {-- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- // These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.-- await t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });-- const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });---- await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {-- const r = await t.get('/s');-- assert.strictEqual(r.status, 200);-- has(r.text, '<title>Spaces · OpenVibe.Space</title>');-- has(r.text, '<link rel="canonical" href="https://openvibe.space/s">');-- has(r.text, '<meta name="robots" content="index,follow">');-- has(r.text, 'href="/s/feed.xml"');-- for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);-- assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));-- const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);-- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);-- });---- await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {-- const page = await t.get('/s/general/new');-- assert.strictEqual(page.status, 200);-- has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');-- has(page.text, '<meta name="robots" content="noindex,follow">');-- const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));-- assert.strictEqual(anon.status, 303);-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);-- const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));-- assert.strictEqual(bad.status, 400);-- has(bad.text, 'Titles are 3 to 200 characters');-- has(bad.text, '<img src=x onerror=alert(1)></textarea>');-- const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));-- assert.strictEqual(ok.status, 303, ok.text);-- assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');-- const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));-- assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');-- });---- const url = '/s/general/t/how-do-i-b-embed-b-comments';---- await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {-- const r = await t.get(url);-- assert.strictEqual(r.status, 200);-- has(r.text, '<title>How do I <b>embed</b> comments? · OpenVibe.Space</title>');-- has(r.text, `<link rel="canonical" href="https://openvibe.space${url}">`);-- has(r.text, '<meta property="og:type" content="article">');-- has(r.text, '<meta property="article:published_time"');-- has(r.text, '<strong>Live</strong>');-- has(r.text, '<code class="hljs language-javascript"><span class="hljs-keyword">const</span>');-- has(r.text, '<script>alert(1)</script>');-- assert.ok(!r.text.includes('<script>alert(1)</script>'));-- const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');-- assert.ok(post, 'DiscussionForumPosting JSON-LD');-- assert.strictEqual(post.headline, 'How do I <b>embed</b> comments?');-- assert.strictEqual(post.url, `https://openvibe.space${url}`);-- assert.deepStrictEqual(post.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });-- assert.ok(post.datePublished && post.text.startsWith('Asking for Live.'));-- assert.strictEqual(post.commentCount, 0);-- assert.ok(!r.text.match(/<script type="application\/ld\+json">[^<]*<\/script>/g).some((s) => /<\/(b|script)>/i.test(s.slice(35, -9))), 'no raw closing tags inside JSON-LD');-- assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList' && o.itemListElement.length === 3));-- has(r.text, `/auth/login?next=${encodeURIComponent(url)}`, 'anonymous readers get a sign-in link instead of the form');-- assert.ok(!r.text.includes(`action="${url}/reply"`));-- });---- await check('no-JS reply and vote: 303 back to the post; score and replies render; JSON-LD comments', async () => {-- const reply = await t.get(`${url}/reply`, as(samJwt, form({ body: 'Use **/api/v1/comments**.' })));-- assert.strictEqual(reply.status, 303, reply.text);-- assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));-- const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));-- assert.strictEqual(empty.status, 400);-- has(empty.text, 'Write something first');-- const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));-- assert.strictEqual(vote.status, 303);-- assert.strictEqual(vote.headers.get('location'), url);-- const r = await t.get(url, as(samJwt));-- has(r.text, `action="${url}/reply"`);-- has(r.text, `action="${url}/vote"`);-- has(r.text, 'class="vote-btn active" type="submit" name="value" value="0" aria-pressed="true"', 'pressing your vote again removes it');-- has(r.text, '<span class="vote-score" title="Score">1</span>');-- has(r.text, 'Use <strong>/api/v1/comments</strong>.');-- const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');-- assert.strictEqual(post.commentCount, 1);-- assert.strictEqual(post.comment[0].author.name, 'Sam');-- assert.ok(post.comment[0].url.includes('#post-'));-- const anonVote = await t.get(`${url}/vote`, form({ value: '1' }));-- assert.strictEqual(anonVote.status, 303);-- assert.match(anonVote.headers.get('location'), /^\/auth\/login/);-- });---- await check('moderation forms: people 403, staff pin and lock; the author deletes their thread', async () => {-- assert.strictEqual((await t.get(`${url}/state`, as(samJwt, form({ pinned: '1' })))).status, 403);-- assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ pinned: '1' })))).status, 303);-- assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ locked: '1' })))).status, 303);-- const r = await t.get(url, as(samJwt));-- has(r.text, 'This thread is locked.');-- assert.ok(!r.text.includes(`action="${url}/reply"`));-- const list = await t.get('/s/general');-- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');-- const admin = await t.get(url, as(adminJwt));-- has(admin.text, 'Unpin');-- has(admin.text, 'Unlock');-- const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));-- const mineUrl = mine.headers.get('location');-- assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);-- const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));-- assert.strictEqual(del.status, 303);-- assert.strictEqual(del.headers.get('location'), '/s/general');-- assert.strictEqual((await t.get(mineUrl)).status, 404);-- });---- await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {-- const space = await forumStore.getSpace(t.db, 'showcase');-- for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });-- const p1 = await t.get('/s/showcase?sort=new');-- assert.strictEqual(p1.status, 200);-- has(p1.text, '<link rel="canonical" href="https://openvibe.space/s/showcase?sort=new">');-- has(p1.text, 'href="/s/showcase"');-- has(p1.text, 'href="/s/showcase?sort=top"');-- has(p1.text, 'rel="next" href="/s/showcase?sort=new&page=2"');-- has(p1.text, 'Page 1 of 2');-- assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);-- const p2 = await t.get('/s/showcase?sort=new&page=2');-- has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');-- has(p2.text, '<link rel="canonical" href="https://openvibe.space/s/showcase?sort=new&page=2">');-- has(p2.text, 'Showcase item 1<');-- assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);-- assert.strictEqual((await t.get('/s/nowhere')).status, 404);-- });---- await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {-- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();-- const ins = await forumStore.getSpace(t.db, 'insiders');-- await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });-- const anon = await t.get('/s/insiders');-- assert.strictEqual(anon.status, 303);-- assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');-- const member = await t.get('/s/insiders', as(samJwt));-- assert.strictEqual(member.status, 200);-- has(member.text, '<meta name="robots" content="noindex,nofollow">');-- const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));-- has(th.text, '<meta name="robots" content="noindex,nofollow">');-- assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);-- assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);-- const feed = await t.get('/s/feed.xml');-- assert.ok(!feed.text.includes('Members only chat'));-- assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);-- require('../server/discovery').resetCaches();-- const map = await t.get('/sitemap.xml');-- assert.ok(!map.text.includes('members-only-chat'));-- });---- await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {-- const space = await forumStore.getSpace(t.db, 'feedback');-- await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });-- const r = await t.get('/s/feedback/t/weekly-summary');-- has(r.text, 'OpenVibe AI');-- has(r.text, '<span class="badge badge-ai"');-- assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });-- });---- await check('sitemap, feeds and robots.txt include the forum', async () => {-- require('../server/discovery').resetCaches();-- const map = await t.get('/sitemap.xml');-- for (const loc of ['/s', '/s/general', url]) has(map.text, `<loc>https://openvibe.space${loc}</loc>`);-- const feed = await t.get('/s/feed.xml');-- assert.strictEqual(feed.status, 200);-- has(feed.text, '<rss version="2.0"');-- has(feed.text, `<link>https://openvibe.space${url}</link>`);-- has(feed.text, '<title>How do I <b>embed</b> comments?</title>');-- const spaceFeed = await t.get('/s/showcase/feed.xml');-- has(spaceFeed.text, 'Showcase item 27');-- assert.ok(!spaceFeed.text.includes('embed'));-- const robots = await t.get('/robots.txt');-- has(robots.text, 'Disallow: /s/*/new');-- });---- await t.close();-- done();--})();ddeleted file mode 100644@@@ -1,130 +0,0 @@--'use strict';--/**-- * Forum style and the features both styles share: the board index (groups, child boards, topics, posts,-- * last post), a forum-style board (topic table, Replies/Views/Last post, sticky first, bumped by replies),-- * topics with author panels and Quote, per-space votes and ratings, Facepunch-style ratings (one per-- * person per post, toggle, replace, not your own, off per space, totals on the author panel), images on-- * posts, crossposts between a forum and a feed, space settings and new spaces (moderators only).-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');----(async () => {-- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });-- const call = (path, { method = 'GET', cookie, json, form } = {}) => {-- const h = {};-- let body;-- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }-- if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }-- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- };-- let topic;-- await check('a forum topic: votes off, a topic row with replies, views and the last post', async () => {-- let r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Welcome thread', body: 'Say hi.' } });-- assert.strictEqual(r.status, 201, r.text);-- topic = r.json().thread;-- r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });-- assert.strictEqual(r.status, 201);-- assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');-- await call('/s/general/t/welcome-thread', { cookie: samJwt });-- await call('/s/general/t/welcome-thread', { cookie: samJwt });-- await call('/s/general/t/welcome-thread', { cookie: alexJwt });-- const list = (await call('/api/v1/spaces/general/threads')).json();-- assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');-- const row = list.threads[0];-- assert.strictEqual(row.views, 2, 'one view per viewer per half hour');-- assert.strictEqual(row.last_post.author.username, 'sam');-- const page = (await call('/s/general')).text;-- assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));-- assert.ok(!page.includes('class="thread-score"'), 'no score column');-- });---- await check('the board index: groups in order, every space with topics, posts and the last post', async () => {-- const r = (await call('/api/v1/spaces')).json();-- const general = r.groups[0].spaces.find((s) => s.slug === 'general');-- assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);-- assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');-- const html = (await call('/s')).text;-- assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Forum') && html.includes('badge-style">Feed'));-- });---- await check('ratings: one per person per post, toggle, replace, not your own; totals on the author panel', async () => {-- const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0];-- const rate = (cookie, reaction) => call(`/api/v1/posts/${post.id}/reactions`, { method: 'POST', cookie, json: { reaction } });-- assert.strictEqual((await rate(alexJwt, 'winner')).json().code, 'reaction.own_post');-- let r = await rate(samJwt, 'winner');-- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().mine, 'winner');-- r = await rate(modJwt, 'winner');-- assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 2]]);-- r = await rate(samJwt, 'late');-- assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 1], ['late', 1]], 'another rating replaces yours');-- r = await rate(samJwt, 'late');-- assert.strictEqual(r.json().mine, null, 'the same one again takes it back');-- assert.strictEqual((await rate(samJwt, 'amazing')).status, 400);-- assert.strictEqual((await rate(null, 'agree')).status, 401);-- const page = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();-- assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);-- assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);-- assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;-- assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));-- r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });-- assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));-- await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();-- assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');-- assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);-- await t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();-- });---- await check('quote: ?quote= fills the reply box with the quoted post', async () => {-- const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];-- const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;-- assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');-- });---- await check('crosspost: a forum topic into a feed, both linked; members-only never', async () => {-- const r = await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'showcase' } });-- assert.strictEqual(r.status, 201, r.text);-- const cp = r.json().thread;-- assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);-- const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();-- assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');-- assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));-- const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();-- assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);-- assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));-- assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;-- assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));-- });---- await check('space settings and new spaces: moderators only; style switches the pages', async () => {-- let r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: alexJwt, json: { style: 'forum' } });-- assert.strictEqual(r.status, 403);-- r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });-- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);-- assert.ok((await call('/s/showcase')).text.includes('topic-table'));-- r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });-- assert.strictEqual(r.status, 303);-- assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));-- assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);-- r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });-- assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');-- const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');-- assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);-- r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });-- assert.strictEqual(r.status, 200, r.text);-- const idx = (await call('/api/v1/spaces')).json();-- assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');-- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');-- });---- await done(t);--})();ddeleted file mode 100644@@@ -1,210 +0,0 @@--'use strict';--/**-- * Forum rules and /api/v1/spaces + /api/v1/posts: seeded spaces, thread creation (slugs,-- * validation, limits), replies and counters, sorting (hot formula, new, top, pinned first,-- * pagination), votes, edits with versions, deletes, locks, members/staff spaces, service-- * capabilities (space.post.write, space.forum.manage) and AI authorship.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const forumStore = require('../server/forum/store');----(async () => {-- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });-- const svc = (cap, extra = {}) => net.signService({ cap, ...extra });-- const POST = 'space.post.write', MOD = 'space.forum.manage';-- const forum = t.app.locals.forum;-- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };---- const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {-- const h = { ...headers };-- if (token) h.authorization = `Bearer ${token}`;-- let body;-- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }-- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- };---- let first;---- await check('seeded spaces: on the board index in their groups, all public', async () => {-- const r = await call('/api/v1/spaces');-- assert.strictEqual(r.status, 200);-- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility, s.style]), [['general', 'public', 'forum'], ['help', 'public', 'forum'], ['feedback', 'public', 'feed'], ['roadmap', 'public', 'feed'], ['showcase', 'public', 'feed'], ['off-topic', 'public', 'forum']]);-- assert.deepStrictEqual(r.json().groups.map((g) => [g.slug, g.spaces.map((s) => s.slug)]), [['openvibe', ['general', 'help', 'feedback', 'roadmap']], ['community', ['showcase', 'off-topic']]]);-- assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);-- });---- await check('create thread: author from the JWT, slug from the title (deduplicated), opening post rendered safely', async () => {-- const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: ' Hello, World! ', body: '**hi** <script>alert(1)</script>', author_subject: sam.subject_id } });-- assert.strictEqual(r.status, 201, r.text);-- first = r.json().thread;-- assert.strictEqual(first.slug, 'hello-world');-- assert.strictEqual(first.title, 'Hello, World!');-- assert.strictEqual(first.url, '/s/general/t/hello-world');-- assert.strictEqual(first.author.subject, alex.subject_id);-- const op = r.json().post;-- assert.strictEqual(op.is_opening, true);-- assert.strictEqual(op.body_html, '<p><strong>hi</strong> <script>alert(1)</script></p>');-- const again = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world', body: 'dup title' } });-- assert.strictEqual(again.json().thread.slug, 'hello-world-2');-- const elsewhere = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world!', body: 'other space' } });-- assert.strictEqual(elsewhere.json().thread.slug, 'hello-world', 'slugs are unique per space');-- assert.strictEqual(forumStore.slugify('Ünïcödé — ☃ !!'), 'unicode');-- assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');-- });---- await check('create thread: validation and who may post', async () => {-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: ' ' } })).json().code, 'post.empty');-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: 'x'.repeat(40_001) } })).json().code, 'post.too_long');-- const noCap = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc(['space.thread.read']), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Via service', body: 'x' } });-- assert.strictEqual(noCap.status, 403);-- const noSubject = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), json: { title: 'Via service', body: 'x' } });-- assert.strictEqual(noSubject.status, 401, 'a service names the person or says it is AI');-- const ok = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'Via service', body: 'as sam' } });-- assert.strictEqual(ok.status, 201, ok.text);-- assert.strictEqual(ok.json().thread.author.username, 'sam');-- });---- await check('AI threads: origin ai, no author subject, labelled', async () => {-- const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { title: 'Stream highlights', body: 'AI summary' } });-- assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.json().thread.origin, 'ai');-- assert.strictEqual(r.json().thread.author.is_ai, true);-- assert.strictEqual(r.json().thread.author.subject, null);-- assert.strictEqual((await t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id)).author_subject, null);-- });---- await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {-- const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });-- assert.strictEqual(r1.status, 201, r1.text);-- assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);-- await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });-- const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();-- assert.strictEqual(th.thread.reply_count, 2);-- assert.deepStrictEqual(th.posts.map((p) => p.body_markdown), ['**hi** <script>alert(1)</script>', 'first reply', 'second reply']);-- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: samJwt, json: { locked: true } })).status, 403);-- const lock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: adminJwt, json: { locked: true } });-- assert.strictEqual(lock.status, 200, lock.text);-- assert.strictEqual(lock.json().thread.locked, true);-- const refused = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'let me in' } });-- assert.strictEqual(refused.status, 403);-- assert.strictEqual(refused.json().code, 'thread.locked');-- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: adminJwt, json: { body: 'mod note' } })).status, 201);-- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).status, 403);-- const svcUnlock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([MOD]), json: { locked: false } });-- assert.strictEqual(svcUnlock.status, 200, 'a moderating service acting as itself');-- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([POST]), json: { locked: false } })).status, 403);-- });---- await check('edits keep versions; only the author or a moderator edits', async () => {-- const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();-- const reply = th.posts.find((p) => p.body_markdown === 'first reply');-- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: alexJwt, json: { body: 'hijack' } })).status, 403);-- const e1 = await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'first reply (edited)' } });-- assert.strictEqual(e1.status, 200, e1.text);-- assert.strictEqual(e1.json().post.revision, 2);-- await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: adminJwt, json: { body: 'moderated' } });-- const v = await call(`/api/v1/posts/${reply.id}/versions`, { cookie: samJwt });-- assert.deepStrictEqual(v.json().versions.map((x) => [x.revision, x.body_markdown]), [[1, 'first reply'], [2, 'first reply (edited)'], [3, 'moderated']]);-- assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);-- });---- await check('votes on threads: off in a space without them; add/change/remove, score recomputed, my_vote shown', async () => {-- const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });-- const off = await vote(1, samJwt);-- assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');-- await t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();-- assert.strictEqual((await vote(1, samJwt)).json().score, 1);-- assert.strictEqual((await vote(1, alexJwt)).json().score, 2);-- assert.strictEqual((await vote(-1, samJwt)).json().score, 0);-- assert.strictEqual((await vote(0, samJwt)).json().score, 1);-- assert.strictEqual((await vote(1)).status, 401);-- assert.strictEqual((await vote('x', samJwt)).status, 400);-- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world', { cookie: alexJwt })).json().thread.my_vote, 1);-- });---- await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {-- const space = await forumStore.getSpace(t.db, 'showcase');-- const mk = async (title, score, hoursAgo) => {-- const { thread } = await forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });-- await t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")-- .run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);-- return thread;-- };-- await t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);-- await mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144-- await mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192-- await mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75-- await mk('ancient', 3, 500);-- const now = new Date('2026-09-22T12:00:00Z');-- const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);-- assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);-- assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');-- assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);-- assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);-- const ancient = (await t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get()).id;-- await t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);-- assert.strictEqual((await titles('hot'))[0], 'ancient');-- const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });-- const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });-- assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);-- const hot = (await t.db.prepare('SELECT ov_hot(5, 2) AS h').get()).h;-- assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');-- });---- await check('deletes: author or moderator; opening post deletes the thread; tombstones keep numbering', async () => {-- const th = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Delete me later', body: 'op' } })).json().thread;-- const reply = (await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'a reply' } })).json().post;-- await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'another' } });-- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);-- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: alexJwt })).status, 200);-- const after = (await call(`/api/v1/spaces/general/threads/${th.slug}`)).json();-- assert.strictEqual(after.thread.reply_count, 1);-- assert.deepStrictEqual(after.posts.map((p) => p.deleted), [false, true, false]);-- assert.strictEqual(after.posts[1].body_markdown, null);-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`, { method: 'DELETE', cookie: alexJwt })).status, 403);-- const op = after.posts[0];-- assert.strictEqual((await call(`/api/v1/posts/${op.id}`, { method: 'DELETE', cookie: samJwt })).json().deleted, 'thread');-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`)).status, 404);-- const other = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Moderated away', body: 'x' } })).json().thread;-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${other.slug}`, { method: 'DELETE', cookie: adminJwt })).status, 200);-- });---- await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {-- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();-- assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);-- assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);-- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);-- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: adminJwt })).status, 200);-- const listed = (await call('/api/v1/spaces', { cookie: samJwt })).json().spaces.map((s) => s.slug);-- assert.ok(listed.includes('insiders') && !listed.includes('mods'));-- assert.ok(!(await call('/api/v1/spaces')).json().spaces.some((s) => s.slug === 'insiders'));-- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: samJwt, json: { title: 'Sneaky', body: 'x' } })).status, 404);-- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: adminJwt, json: { title: 'Mod talk', body: 'x' } })).status, 201);-- });---- await check('person limits: thread cooldown and daily cap, reply duplicates', async () => {-- const { createForumService } = require('../server/forum/service');-- const strict = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 30 } } });-- const who = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };-- await strict.createThread(who, 'general', { title: 'One thread', body: 'x' });-- await assert.rejects(strict.createThread(who, 'general', { title: 'Two thread', body: 'y' }), (e) => e.status === 429 && e.code === 'request.rate_limited');-- const daily = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } });-- await daily.createThread(who, 'general', { title: 'Second thread', body: 'x' });-- await assert.rejects(daily.createThread(who, 'general', { title: 'Third thread', body: 'x' }), (e) => e.status === 429 && /Daily/.test(e.message));-- await daily.reply(who, 'general', 'second-thread', { body: 'same words' });-- await assert.rejects(daily.reply(who, 'general', 'second-thread', { body: 'same words' }), (e) => e.code === 'request.duplicate');-- });---- await t.close();-- done();--})();@@@ -1,23 +1,20 @@'use strict';--/** Boots the mocks, points the app at them, and returns a tiny HTTP client that keeps cookies. */++/** Boots the Network mock, points the app at it, and returns a tiny HTTP client that keeps cookies. */const http = require('http');const mockNetwork = require('./mock-network');--const mockMedia = require('./mock-media');/**-- * Boots the Network and Media mocks, points the app at them, and returns a tiny HTTP client.-- * 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.appOpts passed through to createApp (forumLimits, vipOptions, relayOptions, …).++ * Boots the Network mock (JWKS + the token endpoints sign-in needs), points the app at it, and++ * returns a tiny HTTP client. Every boot gets a migrated database of its own (./db.js: PGlite, or++ * the PostgreSQL containers under npm run test:pg).++ * opts.appOpts passed through to createApp.* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET* is also the one the Network mock accepts).*/async function boot(opts = {}) {const netSrv = await mockNetwork.start({ clientSecret: (opts.env && opts.env.OV_OAUTH_CLIENT_SECRET) || 'shh' });-- const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });process.env.NODE_ENV = 'test';process.env.BASE_URL = 'https://openvibe.space';-- process.env.OV_MEDIA_URL = 'https://openvibe.media';process.env.OV_NETWORK_URL = netSrv.url;process.env.OV_NETWORK_INTERNAL_URL = netSrv.url;process.env.OV_OAUTH_CLIENT_ID = 'space';@@@ -25,12 +22,6 @@ async function boot(opts = {}) {process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.space/auth/callback';process.env.COOKIE_SECURE = 'true';process.env.TRUST_PROXY = '1';-- process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;-- // test:pg: the Valkey container too, under a prefix of its own.-- if (process.env.SPACE_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {-- process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;-- process.env.VALKEY_PREFIX = `ov:space-test:${process.pid}:${Math.random().toString(36).slice(2, 10)}:`;-- }Object.assign(process.env, opts.env || {});for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];const { createApp } = require('../../server/app');@@@ -56,10 +47,9 @@ async function boot(opts = {}) {return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };}return {-- app, base, get, jar, network: netSrv, media: mediaSrv, db: appOpts.db || null,++ app, base, get, jar, network: netSrv, db: appOpts.db || null,close: async () => {-- await new Promise((r) => server.close(r)); await netSrv.close(); await mediaSrv.close();-- if (app.locals.valkey) await app.locals.valkey.close().catch(() => {});++ await new Promise((r) => server.close(r)); await netSrv.close();await appOpts.db.close().catch(() => {});},};ddeleted file mode 100644@@@ -1,97 +0,0 @@--'use strict';--/**-- * Stand-ins for Discord in the relay tests.-- *-- * startWebhooks(): an HTTP server speaking the webhook API the relay uses:-- * POST /api/webhooks/:id/:token?wait=true[&thread_id=] → 200 the message { id, channel_id, webhook_id } (204 without wait)-- * PATCH /api/webhooks/:id/:token/messages/:mid[?thread_id] → 200 the message-- * DELETE /api/webhooks/:id/:token/messages/:mid[?thread_id] → 204-- * an unknown or deleted message → 404 { code: 10008, message: 'Unknown Message' }-- * Message ids count up from 1100000000000000000; a webhook's channel is 2000000000000000000 + its id,-- * or the thread_id it was sent to. `plan` queues canned answers ({ status, body, headers } or-- * { hang: true }) that take precedence; `hits` records every request.-- *-- * createGateway(): a fake WebSocket class (gw.WebSocket) the gateway client is given, with the-- * server side scripted by the test: gw.sockets, gw.push(sock, payload), gw.drop(sock, code), and-- * gw.onOpen / gw.onSend hooks. Sockets open on the next tick.-- */--const http = require('http');----function startWebhooks() {-- const hits = [];-- const plan = [];-- const messages = new Map();-- let next = 1100000000000000000n;-- const server = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- const u = new URL(req.url, 'http://discord.test');-- const body = raw ? JSON.parse(raw) : null;-- hits.push({ method: req.method, path: u.pathname + u.search, pathname: u.pathname, query: Object.fromEntries(u.searchParams), body });-- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(obj === undefined ? '' : JSON.stringify(obj)); };-- const canned = plan.shift();-- if (canned) {-- if (canned.hang) return undefined;-- res.writeHead(canned.status, { 'Content-Type': 'application/json', ...(canned.headers || {}) });-- return res.end(canned.body ? JSON.stringify(canned.body) : '');-- }-- const m = u.pathname.match(/^\/api\/webhooks\/(\d+)\/([^/]+)(?:\/messages\/(\d+))?$/);-- if (!m) return json(404, { message: 'Unknown Webhook', code: 10015 });-- const [, hookId, , mid] = m;-- if (req.method === 'POST' && !mid) {-- const id = String(next++);-- const channelId = u.searchParams.get('thread_id') || String(2000000000000000000n + BigInt(hookId));-- messages.set(id, { id, channel_id: channelId, webhook_id: hookId, body, deleted: false });-- if (u.searchParams.get('wait') !== 'true') { res.writeHead(204); return res.end(); }-- return json(200, { id, channel_id: channelId, webhook_id: hookId, content: body && body.content });-- }-- const msg = mid ? messages.get(mid) : null;-- if (!msg || msg.deleted) return json(404, { message: 'Unknown Message', code: 10008 });-- if (req.method === 'PATCH') { msg.body = body; return json(200, { id: msg.id, channel_id: msg.channel_id, webhook_id: msg.webhook_id, content: body && body.content }); }-- if (req.method === 'DELETE') { msg.deleted = true; res.writeHead(204); return res.end(); }-- return json(405, { message: '405: Method Not Allowed', code: 0 });-- });-- });-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({-- url: `http://127.0.0.1:${server.address().port}`, hits, plan, messages,-- close: () => new Promise((r) => server.close(r)),-- })));--}----function createGateway() {-- const gw = { sockets: [], onOpen: null, onSend: null };-- gw.WebSocket = class FakeWebSocket extends EventTarget {-- constructor(url) {-- super();-- this.url = url;-- this.readyState = 0;-- this.sent = [];-- this.closedWith = null;-- gw.sockets.push(this);-- setImmediate(() => {-- if (this.readyState !== 0) return;-- this.readyState = 1;-- this.dispatchEvent(new Event('open'));-- if (gw.onOpen) gw.onOpen(this);-- });-- }-- send(data) {-- const p = JSON.parse(data);-- this.sent.push(p);-- if (gw.onSend) gw.onSend(this, p);-- }-- close(code = 1000, reason = '') {-- if (this.readyState === 3) return;-- this.readyState = 3;-- this.closedWith = code;-- setImmediate(() => this.dispatchEvent(Object.assign(new Event('close'), { code, reason })));-- }-- };-- gw.push = (sock, payload) => sock.dispatchEvent(Object.assign(new Event('message'), { data: JSON.stringify(payload) }));-- gw.drop = (sock, code, reason = '') => { sock.readyState = 3; sock.dispatchEvent(Object.assign(new Event('close'), { code, reason })); };-- gw.last = () => gw.sockets[gw.sockets.length - 1];-- return gw;--}----module.exports = { startWebhooks, createGateway };ddeleted file mode 100644@@@ -1,74 +0,0 @@--'use strict';--/**-- * A stand-in for OpenVibe.Events in the relay tests: the publish route Community's outbox uses and-- * the pull route the relay's Events worker reads.-- *-- * POST /api/v1/events one envelope or { events: [...] } → { event_id, seq, duplicate } | { results }-- * GET /api/v1/events?topic=a.*,b.*&after_seq=&limit= → { events: [{ seq, event }], next_after_seq, latest_seq, gap? }-- *-- * Topic patterns follow Events (`*` is one or more whole segments). Every call needs a Bearer token.-- * `failNext` answers that many calls with 503; `prunedThrough` makes seq ≤ it look pruned (a gap).-- */--const http = require('http');----function matcher(pattern) {-- return new RegExp(`^${pattern.split('.').map((s) => (s === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : s)).join('\\.')}$`);--}----function startEvents() {-- const log = []; // { seq, event }-- const calls = [];-- const state = { failNext: 0, prunedThrough: 0 };-- const server = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- const u = new URL(req.url, 'http://events.test');-- calls.push({ method: req.method, path: u.pathname + u.search, auth: req.headers.authorization || null });-- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };-- if (!/^Bearer \S+/.test(req.headers.authorization || '')) return json(401, { type: 'about:blank', code: 'auth.required', status: 401 });-- if (state.failNext > 0) { state.failNext--; return json(503, { type: 'about:blank', code: 'events.unavailable', status: 503, detail: 'down for a test' }); }-- if (u.pathname === '/api/v1/events' && req.method === 'POST') {-- const body = JSON.parse(raw || '{}');-- const batch = Array.isArray(body.events);-- const results = (batch ? body.events : [body]).map((env) => {-- const known = log.find((x) => x.event.event_id === env.event_id);-- if (known) return { event_id: env.event_id, seq: known.seq, duplicate: true };-- const seq = log.length + 1;-- log.push({ seq, event: env });-- return { event_id: env.event_id, seq, duplicate: false };-- });-- return json(201, batch ? { results } : results[0]);-- }-- if (u.pathname === '/api/v1/events' && req.method === 'GET') {-- const patterns = String(u.searchParams.get('topic') || '*').split(',').map(matcher);-- const after = Number(u.searchParams.get('after_seq') || 0);-- const limit = Number(u.searchParams.get('limit') || 100);-- const out = {};-- let from = after;-- if (state.prunedThrough && after < state.prunedThrough) { out.gap = { from_seq: after + 1, to_seq: state.prunedThrough }; from = state.prunedThrough; }-- const rows = [];-- let cursor = from;-- for (const item of log) {-- if (item.seq <= from) continue;-- cursor = item.seq;-- if (patterns.some((re) => re.test(item.event.event_type))) rows.push(item);-- if (rows.length >= limit) break;-- }-- out.events = rows;-- out.next_after_seq = cursor;-- out.latest_seq = log.length;-- return json(200, out);-- }-- return json(404, { type: 'about:blank', code: 'route.not_found', status: 404 });-- });-- });-- /** Put an event straight into the log (as if some producer had published it). → seq */-- function add(event) { log.push({ seq: log.length + 1, event }); return log.length; }-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({-- url: `http://127.0.0.1:${server.address().port}`, log, calls, state, add,-- close: () => new Promise((r) => server.close(r)),-- })));--}----module.exports = { startEvents };ddeleted file mode 100644@@@ -1,59 +0,0 @@--'use strict';--/**-- * A stand-in for OpenVibe.Media: the Object API v2 upload (POST /api/v2/space/objects →-- * PUT /:id/content → POST /:id/complete), with a Network service token for openvibe.media holding-- * media.object.upload. Keeps each finished upload's raw bytes (and its kind, visibility and owner)-- * so tests can check what left Space.-- */--const http = require('http');--const crypto = require('crypto');--const { serviceAuth, ids } = require('openvibe-contracts');----function start({ publicPem, issuer }) {-- const uploads = [];-- const pending = new Map(); // objects between init and complete-- let failNext = false;-- const server = http.createServer((req, res) => {-- const chunks = [];-- req.on('data', (c) => chunks.push(c));-- req.on('end', () => {-- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };-- if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });-- const v2 = req.url.match(/^\/api\/v2\/space\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete))?$/);-- if (!v2) return json(404, { error: 'not found' });-- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });-- if (!v.ok) return json(401, { code: v.code, error: v.reason });-- if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });-- const body = Buffer.concat(chunks);-- if (v2) {-- if (req.method === 'POST' && !v2[1]) {-- if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }-- const b = JSON.parse(body.toString() || '{}');-- const id = ids.newId('media');-- pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });-- return json(201, { id, object: { id } });-- }-- const o = pending.get(v2[1]);-- if (!o) return json(404, { code: 'media.object.not_found' });-- if (req.method === 'PUT' && v2[2] === 'content') { o.bytes = body; o.mime = String(req.headers['content-type'] || ''); return json(200, { id: v2[1], size_bytes: body.length }); }-- if (req.method === 'POST' && v2[2] === 'complete') {-- const hash = crypto.createHash('sha256').update(o.bytes || Buffer.alloc(0)).digest('hex');-- if (hash !== o.content_hash) return json(400, { code: 'media.object.hash_mismatch' });-- pending.delete(v2[1]);-- uploads.push({ key: v2[1], name: o.filename || 'upload.bin', mime: o.mime, bytes: o.bytes, claims: v.claims, kind: o.kind, visibility: o.visibility, owner: o.owner, v2: true });-- return json(200, { id: v2[1], lifecycle_status: 'ready' });-- }-- return json(404, { error: 'not found' });-- }-- return json(404, { error: 'not found' });-- });-- });-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({-- url: `http://127.0.0.1:${server.address().port}`,-- uploads,-- failNext: () => { failNext = true; },-- close: () => new Promise((r) => server.close(r)),-- })));--}----module.exports = { start };nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * What Space says about itself while it is rebuilt: the home page is honest (code and dynamic pages,++ * and "spaces" apps; nothing published yet) and links the forum on OpenVibe.Community; the discovery++ * files describe the same site and the sitemap holds the home page alone; health and readiness answer.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++++ await check('the home page says what Space will host and links the forum on OpenVibe.Community', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<h1>Code and dynamic pages for the OpenVibe network<\/h1>/);++ assert.match(r.text, /<strong>code<\/strong> and <strong>dynamic pages<\/strong>/);++ assert.match(r.text, /<strong>spaces<\/strong>/);++ assert.match(r.text, /The forum moved to OpenVibe\.Community/);++ assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);++ assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');++ });++++ await check('robots.txt points at the sitemap and keeps the private paths out', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Sitemap: https:\/\/openvibe\.space\/sitemap\.xml/);++ assert.match(r.text, /Disallow: \/api\//);++ assert.match(r.text, /Disallow: \/auth\//);++ });++++ await check('llms.txt describes Space and sends the reader to the forum', async () => {++ const r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /OpenVibe\.Space/);++ assert.match(r.text, /https:\/\/openvibe\.community\/s/);++ assert.doesNotMatch(r.text, /\/s\/feed\.xml/, 'no forum feed is advertised');++ });++++ await check('the sitemap holds the home page alone', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.deepStrictEqual(locs, ['https://openvibe.space/']);++ });++++ await check('health and readiness answer, and readiness names the store', async () => {++ const health = await t.get('/api/health');++ assert.strictEqual(health.status, 200);++ assert.strictEqual(health.json().service, 'openvibe-space');++ const ready = await t.get('/api/ready');++ assert.ok(ready.status >= 200 && ready.status < 300, `ready answered ${ready.status}`);++ const body = ready.json();++ assert.strictEqual(body.service, 'space');++ assert.strictEqual(body.checks.db.status, 'ok');++ // PGlite locally, PostgreSQL in CI (the containers): either is a real store.++ assert.ok(['pglite', 'postgresql'].includes(body.checks.db.detail.store), `store ${body.checks.db.detail.store}`);++ });++++ await check('sign-in is mounted: /auth/me answers a guest, /auth/login sends the browser to the Network', async () => {++ const me = await t.get('/auth/me');++ assert.deepStrictEqual([me.status, me.json().user], [200, null]);++ const login = await t.get('/auth/login?next=/');++ assert.strictEqual(login.status, 302);++ assert.match(login.headers.get('location'), /^https?:\/\/127\.0\.0\.1:\d+\/oauth\/authorize/);++ });++++ await check('there is no forum here: an unknown path is an honest 404 page', async () => {++ const r = await t.get('/nope');++ assert.strictEqual(r.status, 404);++ assert.match(r.text, /Page not found/);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exitCode = 1; });ddeleted file mode 100644@@@ -1,82 +0,0 @@--'use strict';--/**-- * The forum's Markdown renderer: the supported subset renders, and nothing a post contains can-- * become markup of its own — raw HTML, dangerous link schemes, attribute breakouts, entity tricks.-- */--const assert = require('assert');--const { renderMarkdown: md, markdownToText } = require('../server/render/markdown');--const { check, done } = require('./helpers/app');----(async () => {-- await check('the supported subset renders', () => {-- assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x < y</code></p>');-- assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');-- assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');-- assert.strictEqual(md('> quoted\n> more'), '<blockquote><p>quoted<br>\nmore</p></blockquote>');-- assert.strictEqual(md('- a\n- b\n continued'), '<ul><li>a</li><li>b<br>\ncontinued</li></ul>');-- assert.strictEqual(md('3. c\n4. d'), '<ol start="3"><li>c</li><li>d</li></ol>');-- assert.strictEqual(md('---'), '<hr>');-- assert.strictEqual(md('```\n<b>raw</b>\n```'), '<pre class="md-code"><code class="hljs language-plaintext"><b>raw</b></code></pre>');-- assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));-- assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');-- assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');-- assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');-- assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');-- assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');-- });---- await check('raw HTML is text, never markup', () => {-- for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {-- const out = md(evil);-- assert.ok(!/<(script|img|iframe|svg|a )|<!--/i.test(out.replace(/<a href="[^"]*" rel="nofollow ugc noopener">/g, '')), `${evil} → ${out}`);-- assert.ok(out.includes('<'), 'escaped');-- }-- });---- await check('links: only http(s), mailto, same-site paths and fragments; no attribute breakouts', () => {-- for (const bad of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', '//evil.example/x', '/\\evil.example', 'javascript:alert(1)']) {-- const out = md(`[click](${bad})`);-- assert.ok(!out.includes('<a '), `${bad} must not become a link: ${out}`);-- }-- const quote = md('[x](https://a.b/"onmouseover="alert(1))');-- assert.ok(!/\sonmouseover\s*=/i.test(quote) && !/href="[^"]*"[^ >]/.test(quote), `quotes stay inside the attribute: ${quote}`);-- const bare = md('https://a.b/"><script>alert(1)</script>');-- assert.ok(!bare.includes('<script>'), bare);-- assert.ok(!/href="[^"]*"[^ >]/.test(bare), bare);-- const nul = md('a\u0000b');-- assert.strictEqual(nul, '<p>ab</p>');-- });---- await check('pathological posts render in linear time (no ReDoS)', () => {-- let codeRuns = '';-- for (let k = 1; codeRuns.length < 39000; k++) codeRuns += `a${'`'.repeat(k)}`;-- const cases = {-- heading: `# a${' '.repeat(6000)}x`,-- fence: `\`\`\`${' '.repeat(39000)}!`,-- strong: '**x '.repeat(10000),-- underscore: ' __x'.repeat(10000),-- strike: '~~x '.repeat(10000),-- codeRuns,-- };-- for (const [name, src] of Object.entries(cases)) {-- const started = Date.now();-- md(src);-- const ms = Date.now() - started;-- assert.ok(ms < 1000, `renderMarkdown ${name} took ${ms}ms`);-- }-- const started = Date.now();-- markdownToText('['.repeat(40000));-- assert.ok(Date.now() - started < 1000, `markdownToText took ${Date.now() - started}ms`);-- assert.strictEqual(md('# Title ## '), '<h3>Title</h3>');-- assert.strictEqual(md('``a`b`` and `c` ``d`'), '<p><code>a`b</code> and <code>c</code> `<code>d</code></p>');-- assert.strictEqual(md('**a** **b **c** ~~d~~ x__y__ __z__'), '<p><strong>a</strong> <strong>b **c</strong> <del>d</del> x__y__ <strong>z</strong></p>');-- });---- await check('markdownToText strips the syntax for descriptions and feeds', () => {-- assert.strictEqual(markdownToText('# Hi\n**b** [l](http://x) `c`\n\n- item'), 'Hi b l c item');-- assert.strictEqual(markdownToText('x'.repeat(50), 10), `${'x'.repeat(9)}…`);-- assert.strictEqual(markdownToText('<script>'), '<script>', 'plain text: the caller escapes it');-- });---- done();--})();ddeleted file mode 100644@@@ -1,253 +0,0 @@--'use strict';--/**-- * Members-only discussion (OpenVibe.VIP): a forum space or a single thread gated to one creator's-- * VIP members. Reading and posting need VIP's yes (POST /api/v1/policies/evaluate with the owner-- * Space stored, through the product cache); the owner and moderators pass; signed-out, strangers,-- * VIP down and no client secret are refused (fail closed); nothing gated reaches Pulse, the sitemap,-- * feeds, JSON-LD or the SSR page of someone without access. Convergence: once VIP stops granting,-- * Space stops within the cache TTL (30 s), and at once on vip.membership.changed.-- */--const http = require('http');--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');----/** A stand-in for VIP's evaluate: answers from `allow` (member subject → true), records calls. */--function startVip() {-- const state = { allow: new Set(), down: false, calls: [] };-- const server = http.createServer((req, res) => {-- const chunks = [];-- req.on('data', (c) => chunks.push(c));-- req.on('end', () => {-- if (state.down) { req.socket.destroy(); return; }-- let body = {};-- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }-- state.calls.push({ path: req.url, auth: req.headers.authorization || '', body });-- if (req.method !== 'POST' || req.url !== '/api/v1/policies/evaluate') { res.writeHead(404); return res.end('{}'); }-- const yes = state.allow.has(`${body.subject}|${body.owner}`);-- res.writeHead(200, { 'Content-Type': 'application/json' });-- res.end(JSON.stringify(yes-- ? { allow: true, reason: 'member', rule: null, fallback: true, entitlement: { status: 'active', active: true, expires_at: '2100-01-01T00:00:00.000Z', valid_until: '2100-01-01T00:00:00.000Z' } }-- : { allow: false, reason: 'not_a_member', rule: null, fallback: true, entitlement: { status: 'inactive', active: false } }));-- });-- });-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ ...state, state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));--}----(async () => {-- const vip = await startVip();-- process.env.OV_VIP_INTERNAL_URL = vip.url;-- process.env.OV_VIP_URL = 'https://openvibe.vip';-- const clock = { t: Date.now() };-- const t = await boot({---- appOpts: {-- vipOptions: { now: () => clock.t },-- forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },-- },-- });-- const net = t.network;-- const mk = (id, username, role = 'user') => {-- const u = net.addUser({ network_user_id: id, username, display_name: username });-- return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };-- };-- const cora = mk(21, 'cora'); // the creator-- const mia = mk(22, 'mia'); // her member-- const stan = mk(23, 'stan'); // not a member-- const newbie = mk(24, 'newbie'); // a member VIP cannot confirm while down-- const conv = mk(25, 'conv'); // convergence-- const boss = mk(26, 'boss', 'global_mod');-- vip.state.allow.add(`${mia.subject_id}|${cora.subject_id}`);-- vip.state.allow.add(`${newbie.subject_id}|${cora.subject_id}`);---- const call = (path, { method = 'GET', who, json, headers = {} } = {}) => {-- const h = { ...headers };-- let body;-- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }-- return t.get(path, { method, headers: h, body, cookies: who ? [`ov_token=${who.jwt}`] : [] });-- };-- const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: who ? [`ov_token=${who.jwt}`] : [] });-- const evaluateCalls = () => vip.state.calls.filter((c) => c.path === '/api/v1/policies/evaluate').length;-- let secret; // a gated thread in a public space-- let showcaseThread; // a thread in the gated space---- await check('marking: moderators gate a space for a creator; others cannot; the owner must be a usr_ subject', async () => {-- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: cora, json: { owner: cora.subject_id } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: 'cora' } })).status, 400);-- const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: cora.subject_id } });-- assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(r.json().space.members_only, { owner: cora.subject_id, owner_username: 'cora', join_url: 'https://openvibe.vip/cora' });-- const list = (await call('/api/v1/spaces', { who: stan })).json().spaces;-- assert.strictEqual(list.find((s) => s.slug === 'showcase').members_only.join_url, 'https://openvibe.vip/cora', 'the space is listed with its join link');-- assert.strictEqual(list.find((s) => s.slug === 'general').members_only, null);-- });---- await check('a gated space: the member, the owner and moderators read and post; VIP is asked with the owner and the default gate', async () => {-- const before = vip.state.calls.length;-- const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: mia, json: { title: 'Members lounge', body: 'members-only body text' } });-- assert.strictEqual(r.status, 201, r.text);-- showcaseThread = r.json().thread;-- const asked = vip.state.calls.slice(before).find((c) => c.path === '/api/v1/policies/evaluate');-- assert.deepStrictEqual(asked.body.resource, { service: 'space', type: 'space', id: 'showcase' });-- assert.strictEqual(asked.body.owner, cora.subject_id);-- assert.strictEqual(asked.body.subject, mia.subject_id);-- assert.deepStrictEqual(asked.body.fallback, { requirement: 'member', binding: 'space:members_only' });-- assert.ok(/^Bearer /.test(asked.auth), 'a service token for openvibe.vip');-- assert.ok(net.grants.some((g) => g.audience === 'openvibe.vip' && g.scope === 'vip.resource.policy.evaluate'));-- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: mia })).status, 200);-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: mia })).json().posts[0].body_markdown, 'members-only body text');-- const n = evaluateCalls();-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: cora })).status, 200, 'the owner');-- assert.strictEqual(evaluateCalls(), n, 'the owner passes without asking VIP');-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: boss, json: { body: 'mod note' } })).status, 201, 'moderators');-- });---- await check('a gated space refuses strangers and signed-out visitors with 403 vip.members_only and the join link', async () => {-- for (const [who, reason] of [[stan, 'not_a_member'], [null, 'not_signed_in']]) {-- const r = await call('/api/v1/spaces/showcase/threads', { who });-- assert.strictEqual(r.status, 403, r.text);-- const p = r.json();-- assert.strictEqual(p.code, 'vip.members_only');-- assert.strictEqual(p.reason, reason);-- assert.strictEqual(p.members_only.join_url, 'https://openvibe.vip/cora');-- assert.ok(!r.text.includes('members-only body text'));-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who })).status, 403);-- }-- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: stan, json: { title: 'Let me in', body: 'x' } })).status, 403);-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/votes`, { method: 'POST', who: stan, json: { value: 1 } })).status, 403);-- });---- await check('VIP down: a member VIP cannot confirm is refused (fail closed)', async () => {-- vip.state.down = true;-- try {-- const r = await call('/api/v1/spaces/showcase/threads', { who: newbie });-- assert.strictEqual(r.status, 403);-- assert.strictEqual(r.json().reason, 'vip_unavailable');-- } finally { vip.state.down = false; }-- clock.t += 2_001; // failures are cached 2 s-- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: newbie })).status, 200);-- });---- await check('no client secret: nobody but the owner gets in, and VIP is never called', async () => {-- const { createVipGate } = require('../server/vip');-- const config = require('../server/config');-- const gate = createVipGate({ config: { ...config, oauth: { clientId: 'space', clientSecret: '' } } });-- const n = vip.state.calls.length;-- const d = await gate.check({ subject: mia.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id });-- assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);-- assert.strictEqual((await gate.check({ subject: cora.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id })).allow, true);-- assert.strictEqual(vip.state.calls.length, n);-- });---- await check('a gated thread in a public space: the author gates it to their members; listed by title only', async () => {-- const r = await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Backstage with Cora', body: 'secret backstage body', members_only: true } });-- assert.strictEqual(r.status, 201, r.text);-- secret = r.json().thread;-- assert.strictEqual(secret.members_only.owner, cora.subject_id);-- const listed = (await call('/api/v1/spaces/general/threads?sort=new', { who: stan })).json();-- const row = listed.threads.find((x) => x.id === secret.id);-- assert.strictEqual(row.title, 'Backstage with Cora');-- assert.strictEqual(row.members_only.join_url, 'https://openvibe.vip/cora');-- assert.ok(!JSON.stringify(listed).includes('secret backstage body'));-- const refused = await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: stan });-- assert.strictEqual(refused.status, 403);-- assert.strictEqual(refused.json().gate, 'thread');-- assert.ok(!refused.text.includes('secret backstage body'));-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: mia })).json().posts[0].body_markdown, 'secret backstage body');-- const reply = await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: mia, json: { body: 'member reply' } });-- assert.strictEqual(reply.status, 201);-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);-- // Edits need access too: stan cannot edit (not his), and an author who lost access cannot either.-- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', who: stan, json: { body: 'y' } })).status, 403);-- });---- await check('thread gating permissions: only the author (own members) or moderators; others cannot claim someone else\'s members', async () => {-- const open = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Stan open thread', body: 'open body' } })).json().thread;-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: mia, json: { owner: mia.subject_id } })).status, 403, 'not the author');-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: cora.subject_id } })).status, 403, 'the author gates to their own members only');-- const own = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: true } });-- assert.strictEqual(own.status, 200, own.text);-- assert.strictEqual(own.json().thread.members_only.owner, stan.subject_id);-- const cleared = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } });-- assert.strictEqual(cleared.json().thread.members_only, null);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Claim', body: 'x', members_only: { owner: cora.subject_id } } })).status, 403);-- const mod = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: cora.subject_id } });-- assert.strictEqual(mod.json().thread.members_only.owner, cora.subject_id, 'moderators name any creator');-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } })).status, 403, 'the author cannot open what a moderator gated to another creator');-- await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: null } });-- });---- await check('nothing gated leaks: the sitemap, the feeds, a space feed, JSON-LD', async () => {-- const sitemap = (await call('/sitemap.xml')).text;-- assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');-- const feed = (await call('/s/feed.xml')).text;-- assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));-- assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);-- const general = (await call('/s/general/feed.xml')).text;-- assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));-- });---- await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {-- const anon = await call(`/s/general/t/${secret.slug}`);-- assert.strictEqual(anon.status, 403);-- assert.ok(anon.text.includes('https://openvibe.vip/cora'));-- assert.ok(anon.text.includes('Backstage with Cora'));-- assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));-- assert.ok(/noindex/.test(anon.text));-- assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');-- const space = await call('/s/showcase', { who: stan });-- assert.strictEqual(space.status, 403);-- assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));-- assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');-- const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });-- assert.strictEqual(reply.status, 403);-- const member = await call(`/s/general/t/${secret.slug}`, { who: mia });-- assert.strictEqual(member.status, 200);-- assert.ok(member.text.includes('secret backstage body'));-- assert.ok(/noindex/.test(member.text));-- assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');-- const listing = await call('/s/general', { who: stan });-- assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));-- assert.ok(!listing.text.includes('secret backstage body'));-- // The author's no-JS toggle.-- const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;-- const page = await call(`/s/general/t/${own.slug}`, { who: cora });-- assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));-- const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });-- assert.strictEqual(on.status, 303);-- assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);-- });---- await check('convergence: VIP stops granting → Space refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {-- vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);-- const path = `/api/v1/spaces/general/threads/${secret.slug}`;-- assert.strictEqual((await call(path, { who: conv })).status, 200);-- vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`); // VIP applied the change-- clock.t += 29_999;-- assert.strictEqual((await call(path, { who: conv })).status, 200, 'inside the bound the cached yes may still answer');-- clock.t += 2;-- assert.strictEqual((await call(path, { who: conv })).status, 403, 'past 30 s VIP is asked again and says no');-- // With the event: at once.-- vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);-- clock.t += 10_001; // the cached "no" expires-- assert.strictEqual((await call(path, { who: conv })).status, 200);-- vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`);-- const understood = t.app.locals.vip.cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: conv.subject_id }, creator: { type: 'user', id: cora.subject_id }, active: false } });-- assert.strictEqual(understood, true);-- assert.strictEqual((await call(path, { who: conv })).status, 403, 'no clock movement needed');-- assert.strictEqual(t.app.locals.vip.bounds.grantMs, 30_000);-- });---- await check('a moderator opens a space again; guest subjects and ids are never owners', async () => {-- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: ids.newId('guest') } })).status, 400);-- const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });-- assert.strictEqual(r.json().space.members_only, null);-- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);-- assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));-- });---- await t.close();-- await vip.close();-- done();--})();nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/**++ * The forum is OpenVibe.Community's again (owner decision, 2026-10-08), so every forum URL here is a++ * permanent redirect to the same path and query on openvibe.community: /s and /s/* answer 301 for++ * GET and HEAD (the method is kept) and 308 for anything else, and the forum APIs always answer 308.++ * Everything that is not a forum path stays on Space.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++const COMMUNITY = 'https://openvibe.community';++++(async () => {++ const t = await boot();++++ await check('GET and HEAD of /s and its nested paths answer 301 to Community, path and query intact', async () => {++ for (const path of ['/s', '/s?sort=new', '/s/general', '/s/general/t/123?x=1&y=two', '/s/feed.xml', '/s/general/feed.xml']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 301, path);++ assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${path}`, path);++ }++ const head = await t.get('/s?sort=top', { method: 'HEAD' });++ assert.strictEqual(head.status, 301);++ assert.strictEqual(head.headers.get('location'), `${COMMUNITY}/s?sort=top`);++ });++++ await check('other methods of /s and /s/* answer 308 (the method and body are preserved)', async () => {++ const post = await t.get('/s/general/threads?q=1', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });++ assert.strictEqual(post.status, 308);++ assert.strictEqual(post.headers.get('location'), `${COMMUNITY}/s/general/threads?q=1`);++ const del = await t.get('/s/general/t/123/posts/9', { method: 'DELETE' });++ assert.strictEqual(del.status, 308);++ assert.strictEqual(del.headers.get('location'), `${COMMUNITY}/s/general/t/123/posts/9`);++ });++++ await check('the forum APIs answer 308 to Community, path and query intact', async () => {++ for (const path of ['/api/v1/spaces', '/api/v1/spaces/general/threads?sort=top', '/api/v1/posts', '/api/v1/posts/42', '/api/v1/space-groups', '/api/v1/space-groups/2', '/api/v1/relay', '/api/v1/relay/status']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 308, path);++ assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${path}`, path);++ }++ const post = await t.get('/api/v1/spaces/general/threads', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });++ assert.strictEqual(post.status, 308, 'a write is never replayed as a GET');++ });++++ await check('only the forum paths redirect: the home page, the sitemap, /shared and the session stay here', async () => {++ assert.strictEqual((await t.get('/')).status, 200);++ assert.strictEqual((await t.get('/sitemap.xml')).status, 200);++ assert.strictEqual((await t.get('/robots.txt')).status, 200);++ assert.strictEqual((await t.get('/auth/me')).status, 200);++ assert.strictEqual((await t.get('/api/health')).status, 200);++ const shared = await t.get('/shared/navbar.js');++ assert.notStrictEqual(shared.status, 301, '/shared is not a forum path');++ assert.notStrictEqual(shared.status, 308);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exitCode = 1; });ddeleted file mode 100644@@@ -1,207 +0,0 @@--'use strict';--/**-- * The Discord relay as an Events worker (relay/events-worker.js): creates come from Space's own-- * space.thread.created / space.post.created read back from OpenVibe.Events' pull API with a-- * stored cursor. First start begins at the head (history is never announced); new events queue-- * deliveries (source 'events', the event id) that are sent once; a replay queues nothing twice; the-- * cursor survives a restart; members-only, Discord-origin, redacted and foreign events are ignored;-- * Events being down or a retention gap shows in the status; while the worker runs the forum's own-- * calls queue no creates. Last, the whole path in the app: an API write → Space's outbox → a fake-- * Events → the worker → a fake Discord webhook.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { createClient } = require('openvibe-sdk/core');--const { createEventsClient } = require('openvibe-sdk/events');--const { boot, check, done } = require('./helpers/app');--const { startWebhooks } = require('./helpers/fake-discord');--const { startEvents } = require('./helpers/fake-events');--const { testDb } = require('./helpers/db');--const forumStore = require('../server/forum/store');--const { createDiscordRelay } = require('../server/relay/discord');--const { createRelayEventsWorker, CURSOR } = require('../server/relay/events-worker');----let evn = 0;--function envelope(type, payload, extra = {}) {-- evn++;-- return { event_id: `evt_TEST${String(evn).padStart(22, '0')}`, event_type: type, source: 'space', version: 1, timestamp: new Date().toISOString(), payload, ...extra };--}--const threadEvent = (t, visibility = 'public') => envelope('space.thread.created', { thread_id: t.id, space: 'general', visibility, author: null, url: null });--const postEvent = (p, t, visibility = 'public') => envelope('space.post.created', { post_id: p.id, thread_id: t.id, space: 'general', visibility, author: null, url: null });----(async () => {-- const hook = await startWebhooks();-- const events = await startEvents();-- const db = await testDb();-- const general = await forumStore.getSpace(db, 'general');-- const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` };-- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env, enabled: true, baseMs: 1000, maxAttempts: 3 });-- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });-- const client = createEventsClient(createClient({ baseUrls: { events: events.url }, getToken: async () => 'test-token', retries: 0 }), { source: 'space' });-- const worker = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000, pageLimit: 2 });-- relay.attach({ worker });-- const alex = ids.newId('user');-- const newThread = async (title, extra = {}) => (await forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra })).thread;-- const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();-- const settle = () => new Promise((r) => setImmediate(r));---- await check('off without Events or the client secret, or with DISCORD_RELAY_EVENTS=off; the forum queues creates then', async () => {-- const a = createRelayEventsWorker({ db, relay, eventsUrl: null, clientSecret: 'x', tokenUrl: 'http://127.0.0.1:9/oauth/token' });-- assert.deepStrictEqual([a.enabled, (await a.status()).reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);-- const b = createRelayEventsWorker({ db, relay, eventsUrl: events.url, clientSecret: '', tokenUrl: 'http://127.0.0.1:9/oauth/token' });-- assert.strictEqual(b.enabled, false);-- const c = createRelayEventsWorker({ db, relay, events: client, enabled: false });-- assert.deepStrictEqual([c.enabled, (await c.status()).reason], [false, 'DISCORD_RELAY_EVENTS=off']);-- const d = createRelayEventsWorker({ db, relay: createDiscordRelay({ db, env }), events: client });-- assert.match((await d.status()).reason, /relay is off/);-- c.start();-- assert.strictEqual((await relay.status()).creates_from, 'forum');-- assert.deepStrictEqual(await c.tick(), { queued: 0 });-- });---- let before;-- await check('first start begins at the head: threads from before are never announced', async () => {-- const old1 = await newThread('Older one');-- const old2 = await newThread('Older two');-- events.add(threadEvent(old1));-- events.add(threadEvent(old2));-- events.add(envelope('live.stream.started', { paste_id: 'x' }));-- relay.start(); // starts the worker too (its interval is an hour here: ticks are driven by the test)-- await worker.tick();-- assert.strictEqual((await relay.status()).creates_from, 'events');-- const st = await worker.status();-- assert.deepStrictEqual([st.enabled, st.cursor, st.latest_seq, st.started_at_seq, st.lag], [true, 3, 3, 3, 0]);-- assert.strictEqual((await deliveries()).length, 0);-- assert.deepStrictEqual(await db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);-- before = events.calls.length;-- });---- let t1;-- let p1;-- await check('new events queue creates (source events, the event id) that are sent once; the forum\'s own calls queue none', async () => {-- t1 = await newThread('From the stream');-- assert.strictEqual(await relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');-- p1 = await forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });-- assert.strictEqual(await relay.enqueuePost(p1, t1, general), 0);-- const e1 = threadEvent(t1);-- const e2 = postEvent(p1, t1);-- events.add(e1);-- events.add(envelope('space.comment.created', { comment_id: 1 })); // not on the relay's topics: never returned-- events.add(e2);-- assert.deepStrictEqual(await worker.tick(), { queued: 2 });-- const rows = await deliveries();-- assert.deepStrictEqual(rows.map((d) => [d.action, d.source, d.event_id, d.post_id]), [['create', 'events', e1.event_id, null], ['create', 'events', e2.event_id, p1.id]]);-- assert.ok(events.calls.slice(before).every((c) => /topic=space\.thread\.\*%2Cspace\.post\.\*/.test(c.path) && c.auth === 'Bearer test-token'), JSON.stringify(events.calls.slice(before)));-- await settle(); await relay.drain(); await relay.drain();-- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);-- assert.match(hook.hits[1].body.content, /replied to \*\*From the stream\*\*/);-- assert.deepStrictEqual([(await worker.status()).cursor, (await worker.status()).lag], [6, 0]);-- });---- await check('a replay (cursor moved back) queues nothing twice and posts nothing twice', async () => {-- await db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);-- assert.deepStrictEqual(await worker.tick(), { queued: 0 });-- await relay.drain();-- assert.strictEqual(hook.hits.length, 2);-- assert.strictEqual((await worker.status()).cursor, 6);-- });---- await check('the cursor survives a restart: a new worker carries on from it (no second "start at head")', async () => {-- const t2 = await newThread('While restarting');-- events.add(threadEvent(t2));-- const again = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000 });-- assert.deepStrictEqual(await again.tick(), { queued: 1 });-- assert.strictEqual((await again.status()).started_at_seq, null);-- assert.strictEqual((await deliveries()).pop().thread_id, t2.id);-- });---- await check('members-only, Discord-origin, redacted and foreign events queue nothing', async () => {-- const n = (await deliveries()).length;-- const gated = await newThread('Gated', { members_only_owner: alex });-- const fromDiscord = await forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });-- const t3 = await newThread('Looks public');-- events.add(threadEvent(gated, 'members'));-- events.add(threadEvent(gated)); // even a (wrongly) public-looking event: the row decides-- events.add(postEvent(fromDiscord, t1));-- events.add({ ...threadEvent(t3), source: 'live' });-- events.add({ ...threadEvent(t3), payload: { redacted: true } });-- assert.deepStrictEqual(await worker.tick(), { queued: 0 });-- assert.strictEqual((await deliveries()).length, n);-- });---- await check('Events down: the error shows, the cursor stays, and it catches up once Events answers', async () => {-- const t4 = await newThread('After the outage');-- events.add(threadEvent(t4));-- const cursor = (await worker.status()).cursor;-- events.state.failNext = 1;-- assert.deepStrictEqual(await worker.tick(), { queued: 0 });-- const st = await worker.status();-- assert.ok(st.last_error, 'the error is shown');-- assert.strictEqual(st.cursor, cursor);-- assert.strictEqual(st.lag, st.latest_seq - cursor);-- assert.deepStrictEqual(await worker.tick(), { queued: 1 });-- assert.strictEqual((await worker.status()).last_error, null);-- });---- await check('a retention gap is logged and shown to staff; reading carries on after it', async () => {-- await db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);-- events.state.prunedThrough = 5;-- await worker.tick();-- assert.deepStrictEqual([(await worker.status()).last_gap.from_seq, (await worker.status()).last_gap.to_seq], [2, 5]);-- assert.strictEqual((await worker.status()).cursor, events.log.length);-- events.state.prunedThrough = 0;-- assert.strictEqual((await relay.status()).events_worker.last_gap.to_seq, 5);-- });---- await check('the whole path in the app: API write → outbox → Events → worker → Discord, and back out on edit and delete', async () => {-- const hook2 = await startWebhooks();-- const events2 = await startEvents();-- process.env.EVENTS_URL = events2.url;-- process.env.DISCORD_RELAY_EVENTS_POLL_MS = '40';-- try {-- const t = await boot({---- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook2.url}/api/webhooks/7/app` } }, forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } },-- });-- const outbox = require('../server/events').init(t.db, { eventsUrl: events2.url, intervalMs: 40 });-- assert.ok(outbox, 'the outbox publishes to the fake Events');-- const net = t.network;-- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });-- const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (await fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };-- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 201);-- await waitFor(async () => (await t.app.locals.relay.status()).events_worker.cursor != null, 'the worker\'s first read');-- const st = (await call('/api/v1/relay/status', { cookie: adminJwt })).json();-- assert.deepStrictEqual([st.creates_from, st.events_worker.enabled], ['events', true]);-- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Round trip', body: 'through Events' } });-- assert.strictEqual(th.status, 201, th.text);-- await waitFor(() => hook2.hits.length >= 1, 'the thread on Discord');-- assert.strictEqual(hook2.hits[0].path, '/api/webhooks/7/app?wait=true');-- assert.ok(events2.log.some((x) => x.event.event_type === 'space.thread.created'), 'it went through Events');-- const d = await t.db.prepare('SELECT * FROM relay_deliveries').get();-- assert.deepStrictEqual([d.source, /^evt_/.test(d.event_id)], ['events', true]);-- const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'and a reply' } });-- await waitFor(() => hook2.hits.length >= 2, 'the reply on Discord');-- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'and a reply, edited' } })).status, 200);-- await waitFor(() => hook2.hits.length >= 3, 'the edit on Discord');-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}`, { method: 'DELETE', cookie: samJwt })).status, 200);-- await waitFor(() => hook2.hits.length >= 5, 'the deletes on Discord');-- assert.deepStrictEqual(hook2.hits.map((h) => h.method), ['POST', 'POST', 'PATCH', 'DELETE', 'DELETE']);-- await new Promise((r) => setTimeout(r, 200));-- assert.strictEqual(hook2.hits.length, 5, 'nothing was sent twice');-- await t.app.locals.relay.stop();-- await require('../server/events').stop();-- await t.close();-- } finally {-- delete process.env.EVENTS_URL; delete process.env.DISCORD_RELAY_EVENTS_POLL_MS;-- await hook2.close(); await events2.close();-- }-- });---- await relay.stop();-- await hook.close();-- await events.close();-- done();--})();ddeleted file mode 100644@@@ -1,346 +0,0 @@--'use strict';--/**-- * Discord relay, inbound (relay/discord-gateway.js + relay/inbound.js), against a fake gateway:-- * HELLO → IDENTIFY (token, intents GUILD_MESSAGES | MESSAGE_CONTENT), heartbeats with the last seq,-- * RECONNECT and zombied connections resume, INVALID_SESSION identifies again, fatal close codes stop-- * it with a reason, a missing HELLO reconnects, stop() closes. Then what becomes a post: replies to-- * relayed messages (or messages in a Discord thread started from one) in mapped channels only, with-- * origin 'discord', no author subject and the Discord name; mentions defused; size and rate limits;-- * edits and deletes through the map; webhook/bot/own/system messages, unmapped channels and-- * non-replies ignored; closed threads and empty messages recorded as failures; nothing from Discord-- * ever relayed back out. Last, the app: status and failures at /api/v1/relay (staff, no token), and-- * the post on the thread page with its Discord badge.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const { startWebhooks, createGateway } = require('./helpers/fake-discord');--const { testDb } = require('./helpers/db');--const forumStore = require('../server/forum/store');--const { createDiscordRelay } = require('../server/relay/discord');--const { createDiscordGateway, DEFAULT_URL, INTENTS } = require('../server/relay/discord-gateway');--const { createDiscordInbound, toMarkdown } = require('../server/relay/inbound');--const { createForumService } = require('../server/forum/service');----const sleep = (ms) => new Promise((r) => setTimeout(r, ms));--const BOT = '4000000000000000001';--const KIM = '4000000000000000002';--const LEE = '4000000000000000003';--let snow = 5000000000000000000n;--const nextId = () => String(snow++);----/** A scripted gateway: HELLO on open, READY on IDENTIFY, RESUMED on RESUME, ACK on heartbeat (unless paused). */--function scriptedGateway() {-- const gw = createGateway();-- gw.ack = true;-- gw.seq = 0;-- gw.onOpen = (sock) => gw.push(sock, { op: 10, d: { heartbeat_interval: 30 } });-- gw.onSend = (sock, p) => {-- if (p.op === 2) gw.push(sock, { op: 0, t: 'READY', s: ++gw.seq, d: { session_id: `sess-${gw.sockets.length}`, resume_gateway_url: 'wss://resume.discord.test', user: { id: BOT, bot: true } } });-- if (p.op === 6) gw.push(sock, { op: 0, t: 'RESUMED', s: ++gw.seq, d: {} });-- if (p.op === 1 && gw.ack) gw.push(sock, { op: 11 });-- };-- gw.dispatch = (t, d) => gw.push(gw.last(), { op: 0, t, s: ++gw.seq, d });-- return gw;--}--const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };--const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op === op);----(async () => {-- // ── the gateway client ───────────────────────────────────-- const opts = { token: 'test-bot-token', minBackoffMs: 5, maxBackoffMs: 40, helloTimeoutMs: 150, invalidSessionMs: 5, jitter: () => 0 };---- await check('off without a token or a WebSocket client, and says why', async () => {-- const a = createDiscordGateway({ token: '' });-- assert.deepStrictEqual([a.enabled, a.status()], [false, { enabled: false, reason: 'DISCORD_BOT_TOKEN is not set' }]);-- const b = createDiscordGateway({ token: 'x', WebSocketImpl: null });-- assert.match(b.status().reason, /no built-in WebSocket/);-- b.start();-- assert.strictEqual(typeof globalThis.WebSocket, 'function', 'Node 22 has the client the gateway uses');-- });---- await check('HELLO → IDENTIFY with the token and intents; heartbeats carry the last seq and are ACKed; READY is ready', async () => {-- const gw = scriptedGateway();-- const seen = [];-- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket, onDispatch: (t, d) => { seen.push([t, d.id]); } });-- g.start();-- await waitFor(() => g.status().state === 'ready', 'READY');-- assert.strictEqual(gw.sockets[0].url, DEFAULT_URL);-- const [identify] = sent(gw, 2);-- assert.deepStrictEqual([identify.d.token, identify.d.intents], ['test-bot-token', INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT]);-- assert.strictEqual(INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT, 33280);-- await waitFor(() => sent(gw, 1).length >= 2, 'two heartbeats');-- assert.strictEqual(sent(gw, 1).pop().d, 1, 'the last seq');-- gw.dispatch('MESSAGE_CREATE', { id: '1' });-- gw.dispatch('GUILD_CREATE', { id: '2' }); // not a message event: dropped here-- gw.dispatch('MESSAGE_DELETE', { id: '3' });-- await new Promise((r) => setImmediate(r)); // handling is async: events queue in order-- assert.deepStrictEqual(seen, [['MESSAGE_CREATE', '1'], ['MESSAGE_DELETE', '3']]);-- const st = g.status();-- assert.deepStrictEqual([st.state, st.session, st.bot_user_id, st.events_handled], ['ready', true, BOT, 2]);-- assert.ok(!JSON.stringify(st).includes('test-bot-token'), 'the token never shows');-- await g.stop();-- assert.strictEqual(gw.sockets[0].closedWith, 1000);-- assert.strictEqual(g.status().state, 'stopped');-- await sleep(60);-- assert.strictEqual(gw.sockets.length, 1, 'no reconnect after stop()');-- });---- await check('RECONNECT, a zombied connection and an abnormal close all RESUME on the resume URL with the session and seq', async () => {-- const gw = scriptedGateway();-- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });-- g.start();-- await waitFor(() => g.status().state === 'ready', 'READY');-- gw.dispatch('MESSAGE_CREATE', { id: '10' });-- gw.push(gw.sockets[0], { op: 7, d: null });-- await waitFor(() => gw.sockets.length === 2 && sent(gw, 6).length === 1, 'a resume');-- assert.strictEqual(gw.sockets[0].closedWith, 4000, 'closed with a non-1000 code, so the session stays resumable');-- assert.strictEqual(gw.sockets[1].url, 'wss://resume.discord.test/?v=10&encoding=json');-- assert.deepStrictEqual(sent(gw, 6)[0].d, { token: 'test-bot-token', session_id: 'sess-1', seq: 2 });-- await waitFor(() => g.status().state === 'ready', 'RESUMED');-- // No ACK: the next heartbeat finds the last one unanswered and reconnects.-- gw.ack = false;-- await waitFor(() => gw.sockets.length === 3, 'a reconnect after a missing ACK', 1000);-- gw.ack = true;-- await waitFor(() => sent(gw, 6).length === 2, 'a second resume');-- assert.match(g.status().last_reconnect.reason, /no heartbeat ACK \(zombied connection\)/);-- // An abnormal close (1006) resumes too.-- await waitFor(() => g.status().state === 'ready', 'ready again');-- gw.drop(gw.last(), 1006);-- await waitFor(() => sent(gw, 6).length === 3, 'a third resume');-- assert.strictEqual(sent(gw, 2).length, 1, 'identified only once');-- await g.stop();-- });---- await check('INVALID_SESSION (not resumable) and close codes 4007/4009 identify afresh; no HELLO reconnects', async () => {-- const gw = scriptedGateway();-- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });-- g.start();-- await waitFor(() => g.status().state === 'ready', 'READY');-- gw.push(gw.last(), { op: 9, d: false });-- await waitFor(() => sent(gw, 2).length === 2, 'a second IDENTIFY');-- assert.strictEqual(gw.last().url, DEFAULT_URL, 'a fresh session starts on the main URL');-- await waitFor(() => g.status().state === 'ready', 'READY again');-- gw.drop(gw.last(), 4009);-- await waitFor(() => sent(gw, 2).length === 3, 'IDENTIFY after 4009');-- await waitFor(() => g.status().state === 'ready', 'READY after 4009');-- // A gateway that never says HELLO.-- gw.onOpen = null;-- gw.drop(gw.last(), 1001);-- await waitFor(() => gw.sockets.length >= 5, 'a reconnect after the HELLO timeout', 1500);-- assert.match(g.status().last_error, /no HELLO/);-- await g.stop();-- });---- await check('fatal close codes stop it with the reason (4004 a wrong token, 4014 the MESSAGE CONTENT intent)', async () => {-- for (const [code, re] of [[4004, /DISCORD_BOT_TOKEN is wrong/], [4014, /MESSAGE CONTENT intent/]]) {-- const gw = scriptedGateway();-- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });-- g.start();-- await waitFor(() => g.status().state === 'ready', 'READY');-- gw.drop(gw.last(), code);-- await sleep(80);-- assert.strictEqual(gw.sockets.length, 1, `no reconnect after ${code}`);-- assert.strictEqual(g.status().state, 'failed');-- assert.match(g.status().last_error, re);-- await g.stop();-- }-- });---- // ── inbound: what becomes a post ────────────────────────-- const hook = await startWebhooks();-- const db = await testDb();-- const general = await forumStore.getSpace(db, 'general');-- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` }, enabled: true });-- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });-- const CHANNEL = '2000000000000000001'; // the fake webhook 1's channel (learned on the first send)-- const inbound = createDiscordInbound({ db, perMinute: 3, maxChars: 200 });-- const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });-- const sam = ids.newId('user');-- const samV = { kind: 'user', subject: sam, staff: false };-- const settle = async () => { await new Promise((r) => setImmediate(r)); await relay.drain(); await relay.drain(); };-- const { thread } = await forum.createThread(samV, 'general', { title: 'Talk to Discord', body: 'Say hi over there' });-- await settle();-- const threadMsg = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id)).external_message_id;-- const failures = async () => await db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();-- const msg = (extra = {}) => ({ id: nextId(), channel_id: CHANNEL, guild_id: '6000000000000000001', type: 19, content: 'hello from Discord', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: threadMsg, channel_id: CHANNEL }, mentions: [], attachments: [], ...extra });-- const postsOf = async (tid) => await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);---- await check('a Discord reply to a relayed message becomes a post: origin discord, nobody on the site, the Discord name, mapped', async () => {-- const m = msg({ content: 'Hi <@4000000000000000003> and <@&4000000000000000009> in <#4000000000000000010> @everyone <:wave:4000000000000000011> <t:1790000000:R>', mentions: [{ id: LEE, username: 'lee', global_name: 'Lee' }], member: { nick: 'Kim (mod)' } });-- const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });-- assert.strictEqual(r.status, 'applied', JSON.stringify(r));-- const p = await forumStore.getPost(db, r.post_id);-- assert.deepStrictEqual([p.thread_id, p.origin, p.author_subject, p.relay_author], [thread.id, 'discord', null, 'Kim (mod)']);-- assert.strictEqual(p.body_markdown, 'Hi @Lee and @role in #channel @\u200beveryone :wave: 2026-09-21 14:13 UTC');-- const row = await db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);-- assert.deepStrictEqual([row.direction, row.local_type, row.local_id, row.thread_id, row.external_channel_id], ['in', 'post', p.id, thread.id, CHANNEL]);-- const shown = (await forum.getThread(samV, 'general', thread.slug, {})).posts.find((x) => x.id === p.id);-- assert.deepStrictEqual([shown.author.display_name, shown.author.is_relay, shown.author.subject, shown.origin], ['Kim (mod)', true, null, 'discord']);-- // The same message again (a gateway replay) is not taken twice.-- assert.deepStrictEqual(await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });-- });---- await check('a message in a Discord thread started from a relayed message becomes a post in that thread', async () => {-- const r = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));-- assert.strictEqual(r.status, 'applied');-- assert.strictEqual((await forumStore.getPost(db, r.post_id)).thread_id, thread.id);-- // A reply to that Discord-origin post (mapped 'in') lands in the same thread too.-- const inId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id)).external_message_id;-- const r2 = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));-- assert.strictEqual((await forumStore.getPost(db, r2.post_id)).relay_author, 'lee');-- });---- await check('everything else is ignored: webhooks (the relay\'s own), bots, the bot itself, system messages, non-replies, other channels, inbound off', async () => {-- const n = (await postsOf(thread.id)).length;-- const cases = [-- [msg({ webhook_id: '1' }), /webhook message/],-- [msg({ author: { id: LEE, username: 'lee', bot: true } }), /bot or system/],-- [msg({ author: { id: BOT, username: 'relaybot' } }), /bot or system/],-- [msg({ type: 7 }), /not a plain message/],-- [msg({ message_reference: undefined, type: 0 }), /not a reply to a relayed message/],-- [msg({ message_reference: { message_id: '9999999999999999999' } }), /not a reply to a relayed message/],-- [msg({ channel_id: '7000000000000000001' }), /not a reply to a relayed message/],-- [msg({ type: 0, content: '', message_reference: { type: 1, message_id: threadMsg, channel_id: CHANNEL } }), /not a reply to a relayed message/], // a forward-- ];-- for (const [m, re] of cases) {-- const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });-- assert.strictEqual(r.status, 'ignored', JSON.stringify(m));-- assert.match(r.reason, re);-- }-- await relay.updateMapping(1, { inbound: false });-- assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /inbound is off/);-- await relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });-- assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /not a mapped channel/);-- await relay.updateMapping(1, { discord_channel_id: CHANNEL });-- assert.strictEqual((await postsOf(thread.id)).length, n);-- assert.strictEqual((await failures()).length, 0, 'ignoring is not a failure');-- });---- await check('limits: a long message is cut, a fourth message a minute is refused, an empty one explains the intent; attachments are noted', async () => {-- const long = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));-- const body = (await forumStore.getPost(db, long.post_id)).body_markdown;-- assert.strictEqual(body.length, 200);-- assert.ok(body.endsWith('…'));-- const talker = { id: '4000000000000000021', username: 'talker' };-- for (let i = 0; i < 3; i++) assert.strictEqual((await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` }))).status, 'applied');-- const r = await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));-- assert.deepStrictEqual([r.status, r.error], ['failed', 'rate limited']);-- const empty = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));-- assert.match(empty.error, /MESSAGE CONTENT intent/);-- const pic = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));-- assert.strictEqual((await forumStore.getPost(db, pic.post_id)).body_markdown, '_(2 attachments on Discord)_');-- const f = await failures();-- assert.deepStrictEqual(f.map((x) => x.error.slice(0, 12)), ['rate limited', 'the message ']);-- assert.deepStrictEqual([f[0].event, f[0].external_author_id, f[0].mapping_id], ['MESSAGE_CREATE', talker.id, 1]);-- assert.ok(!JSON.stringify(f).includes('one too many'), 'failures never keep the text');-- assert.strictEqual(toMarkdown({ content: ' <@123456789012345678> ' }), '@someone');-- });---- await check('closed threads refuse replies from Discord and say why: locked, members-only, deleted', async () => {-- const t = (await forum.createThread(samV, 'general', { title: 'Soon closed', body: 'x' })).thread;-- await settle();-- const anchor = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id)).external_message_id;-- const to = (extra = {}) => msg({ author: { id: '4000000000000000030', username: 'late' }, message_reference: { message_id: anchor }, ...extra });-- await forumStore.setThreadFlags(db, t.id, { locked: true });-- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is locked');-- await forumStore.setThreadFlags(db, t.id, { locked: false });-- await forumStore.setThreadMembersOnly(db, t.id, sam);-- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is not public');-- await forumStore.setThreadMembersOnly(db, t.id, null);-- await forumStore.softDeleteThread(db, t.id);-- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread was deleted');-- assert.strictEqual((await postsOf(t.id)).length, 1, 'only the opening post');-- });---- await check('edits and deletes on Discord follow through the map; a delete of the relay\'s own message leaves the thread', async () => {-- const m = msg({ author: { id: '4000000000000000040', username: 'editor' }, content: 'first words' });-- const { post_id: pid } = await inbound.handle('MESSAGE_CREATE', m);-- const r = await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });-- assert.strictEqual(r.status, 'applied');-- const p = await forumStore.getPost(db, pid);-- assert.deepStrictEqual([p.body_markdown, p.revision], ['second words @lee', 2]);-- assert.deepStrictEqual((await forumStore.listPostVersions(db, pid)).map((v) => v.edited_by), [null, 'discord']);-- assert.match((await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] })).reason, /no text change/);-- assert.match((await inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' })).reason, /relay's own/);-- const other = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));-- const otherId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id)).external_message_id;-- assert.strictEqual((await inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL })).status, 'applied');-- assert.ok((await forumStore.getPost(db, pid)).deleted_at, 'the post is deleted');-- const bulk = await inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });-- assert.deepStrictEqual(bulk.map((x) => x.status), ['applied', 'ignored']);-- assert.ok((await forumStore.getPost(db, other.post_id)).deleted_at);-- // A moderator on Discord removed the relay's announcement: the map knows, the thread stays, no edit goes there again.-- assert.match((await inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL })).note, /thread stays/);-- assert.ok(await forumStore.getThread(db, thread.id), 'the thread is still here');-- assert.ok((await db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg)).external_deleted_at);-- assert.strictEqual(await relay.enqueueEdit(await forumStore.getPost(db, (await db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id)).id)), 0);-- });---- await check('loop prevention: nothing from Discord is ever relayed back out', async () => {-- await settle();-- const before = hook.hits.length;-- const fromDiscord = (await postsOf(thread.id)).filter((p) => p.origin === 'discord');-- assert.ok(fromDiscord.length >= 5);-- for (const p of fromDiscord) assert.strictEqual(await relay.enqueuePost(p, await forumStore.getThread(db, thread.id), general), 0);-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get()).n, 0);-- await settle();-- assert.strictEqual(hook.hits.length, before, 'no webhook call for anything that came from Discord');-- });---- await check('the app: the gateway starts with the relay; status and failures for staff only, without the token; the post shows with a Discord badge', async () => {-- const gw = scriptedGateway();-- process.env.DISCORD_RELAY_INBOUND = 'on';-- process.env.DISCORD_BOT_TOKEN = 'app-bot-token-not-real';-- let t;-- try {-- t = await boot({---- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/5/app` } }, gatewayOptions: { WebSocketImpl: gw.WebSocket, minBackoffMs: 5, jitter: () => 0 }, forumLimits: { threads: { cooldownSec: 0 } } },-- });-- } finally { delete process.env.DISCORD_RELAY_INBOUND; delete process.env.DISCORD_BOT_TOKEN; }-- const net = t.network;-- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });-- const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- await waitFor(async () => (await t.app.locals.relay.status()).inbound.state === 'ready', 'the app\'s gateway READY');-- assert.strictEqual(sent(gw, 2)[0].d.token, 'app-bot-token-not-real');-- const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true } });-- assert.strictEqual(m.json().mapping.inbound, true);-- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Ask Discord', body: 'what do you think?' } });-- await waitFor(async () => await t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');-- const anchor = await t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();-- gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });-- const locked = nextId();-- await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');-- const page = await call(`/s/general/t/${th.json().thread.slug}`);-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');-- await t.db.prepare('UPDATE threads SET locked = 1').run();-- gw.dispatch('MESSAGE_CREATE', { id: locked, channel_id: anchor.external_channel_id, type: 19, content: 'too late', author: { id: KIM, username: 'kim' }, message_reference: { message_id: anchor.external_message_id } });-- assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: samJwt })).status, 403);-- // Handling is async (the gateway queues dispatches), so poll the API for the failure itself — the exact-- // condition asserted below — instead of reading once and hoping the record is already there.-- let inb;-- await waitFor(async () => {-- inb = await call('/api/v1/relay/inbound', { cookie: adminJwt });-- const f = inb.json().failures;-- return f.length === 1 && f[0].error === 'the thread is locked' && f[0].discord.message_id === locked && f[0].mapping.space === 'general';-- }, 'the locked reply listed at /api/v1/relay/inbound');-- assert.strictEqual(inb.status, 200);-- assert.deepStrictEqual(inb.json().failures.map((f) => [f.error, f.discord.message_id, f.mapping.space]), [['the thread is locked', locked, 'general']]);-- assert.strictEqual((await call(`/api/v1/relay/inbound/${inb.json().failures[0].id}/dismiss`, { method: 'POST', cookie: adminJwt })).status, 200);-- assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: adminJwt })).json().failures.length, 0);-- assert.strictEqual((await call('/api/v1/relay/inbound?all=1', { cookie: adminJwt })).json().failures.length, 1);-- const st = await call('/api/v1/relay/status', { cookie: adminJwt });-- assert.deepStrictEqual([st.json().inbound.enabled, st.json().inbound.state, st.json().inbound.handled.applied, st.json().inbound_failures], [true, 'ready', 1, 0]);-- assert.ok(!(st.text + inb.text + (await call('/api/ready')).text).includes('app-bot-token-not-real'), 'the token never shows');-- await t.app.locals.relay.stop();-- assert.strictEqual(gw.last().closedWith, 1000);-- await t.close();-- });---- await hook.close();-- done();--})();ddeleted file mode 100644@@@ -1,509 +0,0 @@--'use strict';--/**-- * Discord relay, outbound (relay/discord.js): off unless DISCORD_RELAY_ENABLED; one delivery per-- * dedupe key; the webhook URL comes from the environment variable a mapping names; creates are sent-- * with ?wait=true and the answer's message id goes into the external message map (unique both ways);-- * replies follow their thread (into the mapping's Discord thread when it names one); edits PATCH and-- * deletes DELETE through the map; retries with backoff on 5xx/429/network errors, then the dead letter-- * ('failed') that staff retry or drop; loop prevention for Discord-origin threads and posts; members,-- * staff and VIP members-only content never relayed (and deleted when gated later); the staff API;-- * the one-time rebuild of an older relay_deliveries table. A fake Discord stands in for the webhooks;-- * the relay's clock is injected so backoff is tested without waiting. The forum queues creates-- * directly here (no Events worker: see relay-events.test.js).-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');--const { startWebhooks } = require('./helpers/fake-discord');--const { testDb } = require('./helpers/db');--const forumStore = require('../server/forum/store');--const { createDiscordRelay } = require('../server/relay/discord');--const { createForumService } = require('../server/forum/service');--const { sqlTime } = require('../server/http/v1');----(async () => {-- const hook = await startWebhooks();-- const db = await testDb();-- const general = await forumStore.getSpace(db, 'general');-- const feedback = await forumStore.getSpace(db, 'feedback');-- const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };-- let clock = Date.parse('2026-09-22T12:00:00Z');-- const now = () => clock;-- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });-- const alex = ids.newId('user');-- await require('../server/identity/projection').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });-- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });-- await relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });-- const newThread = async (space, title, extra = {}) => (await forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra })).thread;-- const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();-- const mapOf = async (type, id) => await db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);-- const settle = () => new Promise((r) => setImmediate(r));-- const EMPTY = { delivered: 0, retry: 0, failed: 0, skipped: 0 };---- await check('off by default: nothing is queued or sent', async () => {-- const off = createDiscordRelay({ db, env, now });-- assert.strictEqual(off.enabled, false);-- const th = await newThread(general, 'Quiet thread');-- assert.strictEqual(await off.enqueueThread(th, general), 0);-- assert.strictEqual(await off.enqueuePost(await forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);-- assert.deepStrictEqual(await off.drain(), EMPTY);-- assert.strictEqual((await deliveries()).length, 0);-- const st = await off.status();-- assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled], [false, false, false]);-- });---- await check('a new thread is posted once with ?wait=true, attribution, a link and no mentions; its message id is mapped', async () => {-- const th = await newThread(general, 'Relay me <please>');-- assert.strictEqual(await relay.enqueueThread(th, general), 1);-- assert.strictEqual(await relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');-- await settle();-- await relay.drain();-- assert.strictEqual(hook.hits.length, 1);-- const msg = hook.hits[0];-- assert.strictEqual(msg.method, 'POST');-- assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');-- assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });-- assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.space\/s\/general\/t\/relay-me-please>$/);-- assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');-- assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.space/s/general/t/relay-me-please');-- assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');-- assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Space · s/general');-- const d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);-- const [row] = await mapOf('thread', th.id);-- assert.deepStrictEqual([row.platform, row.direction, row.mapping_id, row.thread_id, row.external_channel_id, row.external_thread_id, row.external_webhook_id],-- ['discord', 'out', 1, th.id, '2000000000000000001', null, '1']);-- assert.ok(hook.messages.has(row.external_message_id));-- assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get()).discord_channel_id, '2000000000000000001', 'the mapping learned its channel');-- await relay.drain();-- assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');-- // Even a create queued again under another key is not sent twice: the map already has it.-- await db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));-- await relay.drain();-- assert.strictEqual(hook.hits.length, 1);-- assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['delivered', 'already on Discord']);-- });---- await check('the external message map is unique both ways', async () => {-- const th = await newThread(general, 'Unique map');-- const put = async (mapping, type, local, external) => await db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)-- VALUES (?, 'out', ?, ?, ?, '2000000000000000001', ?)`).run(mapping, type, local, th.id, external);-- await put(1, 'thread', th.id, '1900000000000000001');-- await assert.rejects(async () => await put(2, 'post', 999, '1900000000000000001'), /duplicate key/, 'one Discord message is one local object');-- await assert.rejects(async () => await put(1, 'thread', th.id, '1900000000000000002'), /duplicate key/, 'one local object is one message per mapping');-- await put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine-- await db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);-- });---- await check('retries with exponential backoff on 5xx, then delivers', async () => {-- hook.hits.length = 0;-- hook.plan.push({ status: 502, body: { message: 'bad gateway' } });-- const th = await newThread(feedback, 'Flaky Discord');-- await relay.enqueueThread(th, feedback);-- await settle(); await relay.drain();-- let d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);-- assert.match(d.last_error, /Discord answered 502: bad gateway/);-- assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');-- await relay.drain();-- assert.strictEqual(hook.hits.length, 1, 'not due yet');-- clock += 60_000;-- hook.plan.push({ status: 500 });-- await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');-- clock += 120_000;-- await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);-- assert.strictEqual(hook.hits.length, 3);-- });---- await check('429 honours retry_after and holds that webhook for the pass; used-up attempts end as failed; other 4xx fail at once', async () => {-- const th = await newThread(general, 'Rate limited');-- const th2 = await newThread(general, 'Behind it');-- hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });-- await relay.enqueueThread(th, general);-- await relay.enqueueThread(th2, general);-- const before = hook.hits.length;-- await settle(); await relay.drain();-- assert.strictEqual(hook.hits.length, before + 1, 'the next delivery to the same webhook waits for the next pass');-- let d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.strictEqual(d.status, 'pending');-- assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);-- await relay.drain();-- assert.strictEqual((await deliveries()).find((x) => x.thread_id === th2.id).status, 'delivered');-- clock += 300_000;-- hook.plan.push({ status: 503 }, { status: 503 });-- await relay.drain();-- clock += 10 * 60_000;-- await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);---- const gone = await newThread(general, 'Webhook deleted');-- hook.plan.push({ status: 404, body: { message: 'Unknown Webhook', code: 10015 } });-- await relay.enqueueThread(gone, general);-- await settle(); await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === gone.id);-- assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);-- assert.match(d.last_error, /404: Unknown Webhook/);-- });---- await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {-- const showcase = await forumStore.getSpace(db, 'showcase');-- await relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });-- const th = await newThread(showcase, 'Env later');-- await relay.enqueueThread(th, showcase);-- await settle(); await relay.drain();-- let d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);-- env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';-- clock += 60_000;-- await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');-- assert.ok(d.last_error);-- env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;-- clock += 120_000;-- await relay.drain();-- d = (await deliveries()).find((x) => x.thread_id === th.id);-- assert.strictEqual(d.status, 'delivered');-- const everything = JSON.stringify(await db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(await deliveries()) + JSON.stringify(await db.prepare('SELECT * FROM relay_message_map').all());-- assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');-- });---- await check('loop prevention: Discord-origin threads and posts are never relayed; members/staff spaces never leave the site', async () => {-- const before = hook.hits.length;-- const fromDiscord = await newThread(general, 'Came from Discord', { origin: 'discord' });-- assert.strictEqual(await relay.enqueueThread(fromDiscord, general), 0);-- // Even a delivery row that got queued somehow is refused at send time.-- await db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));-- await relay.drain();-- const d = (await deliveries()).find((x) => x.thread_id === fromDiscord.id);-- assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);-- // A reply that came from Discord, in a thread that is on Discord, stays here too.-- const th = await newThread(general, 'Mixed replies');-- await relay.enqueueThread(th, general);-- await settle(); await relay.drain();-- const inbound = await forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });-- assert.strictEqual(await relay.enqueuePost(inbound, th, general), 0);-- await db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));-- await relay.drain();-- assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['failed', 'loop prevention: post came from Discord']);-- await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();-- const ins = await forumStore.getSpace(db, 'insiders');-- await relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });-- assert.strictEqual(await relay.enqueueThread(await newThread(ins, 'Members only'), ins), 0);-- assert.strictEqual(hook.hits.length, before + 1, 'only the public thread went out');-- });---- await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget; drop() keeps it as dropped', async () => {-- await relay.setMappingEnabled(1, false);-- assert.strictEqual(await relay.enqueueThread(await newThread(general, 'Mapping off'), general), 0);-- await relay.setMappingEnabled(1, true);-- const failed = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 404);-- assert.strictEqual(await relay.retry(failed.id), 1);-- await settle(); await relay.drain();-- assert.strictEqual((await deliveries()).find((x) => x.id === failed.id).status, 'delivered');-- assert.strictEqual(await relay.retry(failed.id), 0, 'delivered ones stay delivered');-- const dead = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 503);-- assert.strictEqual(await relay.drop(dead.id), 1);-- assert.strictEqual((await deliveries()).find((x) => x.id === dead.id).status, 'dropped');-- assert.strictEqual(await relay.drop(dead.id), 0);-- assert.strictEqual((await relay.status()).deliveries.dropped, 1);-- });---- // ── replies, edits and deletes through the forum service ─────────────────────────-- const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });-- const sam = ids.newId('user');-- await require('../server/identity/projection').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });-- const samV = { kind: 'user', subject: sam, staff: false };-- const alexV = { kind: 'user', subject: alex, staff: false };-- const modV = { kind: 'user', subject: ids.newId('user'), staff: true };-- const drainAll = async () => { await settle(); for (let i = 0; i < 5; i++) await relay.drain(); };---- await check('replies follow their thread: one message per reply, linked to the post, mapped; a reply waits for its thread\'s message', async () => {-- hook.hits.length = 0;-- const { thread } = await forum.createThread(samV, 'general', { title: 'Replies go too', body: 'Opening words' });-- const { post } = await forum.reply(alexV, 'general', thread.slug, { body: 'First **reply**' });-- await drainAll();-- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);-- const r = hook.hits[1];-- assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');-- assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.space\/s\/general\/t\/replies-go-too#post-\d+>$/);-- assert.strictEqual(r.body.embeds[0].description, 'First reply');-- assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });-- const [row] = await mapOf('post', post.id);-- assert.deepStrictEqual([row.direction, row.thread_id], ['out', thread.id]);-- // The thread's own message failing for now: the reply waits, without using an attempt.-- hook.hits.length = 0;-- hook.plan.push({ status: 500 });-- const t2 = await forum.createThread(samV, 'general', { title: 'Thread first', body: 'x' });-- const p2 = (await forum.reply(alexV, 'general', t2.thread.slug, { body: 'waits' })).post;-- await drainAll();-- const reply = (await deliveries()).find((x) => x.post_id === p2.id);-- assert.deepStrictEqual([reply.status, reply.attempts, reply.last_error], ['pending', 0, "waiting for the thread's own message"]);-- assert.strictEqual(hook.hits.length, 1, 'only the thread was tried');-- clock += 60_000;-- await drainAll();-- assert.deepStrictEqual(hook.hits.map((h) => h.body.content.startsWith('New thread') ? 'thread' : 'reply'), ['thread', 'thread', 'reply'], 'the thread, then its reply');-- assert.strictEqual((await deliveries()).find((x) => x.post_id === p2.id).status, 'delivered');-- // A thread that was never relayed (made before the mapping): its replies are not either.-- const old = await newThread(general, 'Before the mapping');-- assert.strictEqual(await relay.enqueuePost(await forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);-- });---- await check('a mapping that names a Discord thread posts into it (thread_id) and edits and deletes there', async () => {-- hook.hits.length = 0;-- await relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });-- const { thread } = await forum.createThread(samV, 'feedback', { title: 'Into a Discord thread', body: 'hi' });-- const { post } = await forum.reply(samV, 'feedback', thread.slug, { body: 'reply in thread' });-- await drainAll();-- assert.deepStrictEqual(hook.hits.map((h) => h.path), ['/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002', '/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002'], JSON.stringify(hook.hits.map((h) => h.path)));-- const [row] = await mapOf('post', post.id);-- assert.deepStrictEqual([row.external_channel_id, row.external_thread_id], ['3000000000000000002', '3000000000000000002']);-- await forum.editPost(samV, post.id, { body: 'reply in thread, edited' });-- await drainAll();-- await forum.deletePost(samV, post.id);-- await drainAll();-- assert.deepStrictEqual(hook.hits.slice(2).map((h) => `${h.method} ${h.path}`), [-- `PATCH /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,-- `DELETE /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,-- ]);-- assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get()).discord_channel_id, '2000000000000000002', 'the channel learned before stays');-- await relay.updateMapping(2, { discord_thread_id: null });-- });---- await check('edits follow through the map: a reply\'s message and, for the opening post, the thread\'s message are PATCHed', async () => {-- hook.hits.length = 0;-- const { thread, post: opening } = await forum.createThread(samV, 'general', { title: 'Edit me', body: 'Version one' });-- const { post } = await forum.reply(samV, 'general', thread.slug, { body: 'Reply one' });-- await drainAll();-- const threadMsg = (await mapOf('thread', thread.id))[0].external_message_id;-- const postMsg = (await mapOf('post', post.id))[0].external_message_id;-- await forum.editPost(samV, post.id, { body: 'Reply **two**' });-- await forum.editPost(samV, opening.id, { body: 'Version two' });-- await forum.editPost(samV, opening.id, { body: 'Version two' }); // no change: no revision, nothing queued-- await drainAll();-- const edits = hook.hits.filter((h) => h.method === 'PATCH');-- assert.deepStrictEqual(edits.map((h) => h.pathname), [`/api/webhooks/1/general/messages/${postMsg}`, `/api/webhooks/1/general/messages/${threadMsg}`]);-- assert.strictEqual(edits[0].body.embeds[0].description, 'Reply two');-- assert.strictEqual(edits[1].body.embeds[0].description, 'Version two');-- assert.deepStrictEqual(edits[1].body.allowed_mentions, { parse: [] });-- assert.strictEqual(hook.messages.get(threadMsg).body.embeds[0].description, 'Version two');-- const keys = (await deliveries()).filter((d) => d.action === 'edit').map((d) => d.dedupe_key);-- assert.ok(keys.includes(`edit:post:${post.id}:mapping:1:r2`) && keys.includes(`edit:thread:${thread.id}:mapping:1:r2`), keys.join());-- // Someone deleted the reply's message on Discord: the next edit finds nothing and says so.-- hook.messages.get(postMsg).deleted = true;-- await forum.editPost(samV, post.id, { body: 'Reply three' });-- await drainAll();-- const last = (await deliveries()).pop();-- assert.deepStrictEqual([last.action, last.status, last.last_error], ['edit', 'skipped', 'the message is gone on Discord']);-- assert.ok((await mapOf('post', post.id))[0].external_deleted_at, 'the map knows it is gone');-- const queued = (await deliveries()).length;-- await forum.editPost(samV, post.id, { body: 'Reply four' });-- assert.strictEqual((await deliveries()).length, queued, 'nothing more is queued for a message that is gone');-- // An edit right before a delete is skipped: only the delete goes out.-- const { post: p2 } = await forum.reply(samV, 'general', thread.slug, { body: 'short-lived' });-- await drainAll();-- hook.hits.length = 0;-- await forum.editPost(samV, p2.id, { body: 'short-lived, edited' });-- await forum.deletePost(samV, p2.id);-- await drainAll();-- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['DELETE']);-- assert.strictEqual((await deliveries()).find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');-- });---- await check('deletes follow through the map: a reply, and a thread with every reply of it; what was not sent yet is skipped', async () => {-- hook.hits.length = 0;-- const { thread } = await forum.createThread(samV, 'general', { title: 'Delete me', body: 'soon gone' });-- const a = (await forum.reply(samV, 'general', thread.slug, { body: 'a' })).post;-- const b = (await forum.reply(alexV, 'general', thread.slug, { body: 'b' })).post;-- await drainAll();-- const msgs = { thread: (await mapOf('thread', thread.id))[0].external_message_id, a: (await mapOf('post', a.id))[0].external_message_id, b: (await mapOf('post', b.id))[0].external_message_id };-- await forum.deletePost(modV, a.id); // moderated away-- await drainAll();-- assert.deepStrictEqual(hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname), [`/api/webhooks/1/general/messages/${msgs.a}`]);-- const c = (await forum.reply(samV, 'general', thread.slug, { body: 'c, never sent' })).post; // queued, not sent yet-- await forum.deleteThread(samV, 'general', thread.slug);-- await drainAll();-- const deleted = hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname).sort();-- assert.deepStrictEqual(deleted, [msgs.thread, msgs.a, msgs.b].map((m) => `/api/webhooks/1/general/messages/${m}`).sort());-- assert.ok(!hook.hits.some((h) => h.method === 'POST' && /c, never sent/.test(JSON.stringify(h.body))), 'the reply queued before the delete never went out');-- assert.deepStrictEqual([(await deliveries()).find((d) => d.post_id === c.id).status], ['skipped']);-- assert.ok((await mapOf('thread', thread.id)).every((r) => r.external_deleted_at));-- // A delete whose message is already gone on Discord counts as done.-- const t3 = (await forum.createThread(samV, 'general', { title: 'Gone already', body: 'x' })).thread;-- await drainAll();-- hook.messages.get((await mapOf('thread', t3.id))[0].external_message_id).deleted = true;-- await forum.deleteThread(samV, 'general', t3.slug);-- await drainAll();-- const del = (await deliveries()).pop();-- assert.deepStrictEqual([del.action, del.status, del.last_status, del.last_error], ['delete', 'delivered', 404, 'the message was already gone on Discord']);-- });---- await check('members-only (VIP): gated threads and spaces never go out, and gating later deletes what went out', async () => {-- hook.hits.length = 0;-- const { thread } = await forum.createThread(samV, 'general', { title: 'Public for now', body: 'x' });-- await forum.reply(alexV, 'general', thread.slug, { body: 'a public reply' });-- await drainAll();-- const posted = hook.hits.length;-- assert.strictEqual(posted, 2);-- await forum.setThreadMembersOnly(samV, 'general', thread.slug, { members_only: sam });-- await drainAll();-- assert.strictEqual(hook.hits.filter((h) => h.method === 'DELETE').length, 2, 'the thread\'s and the reply\'s messages were deleted');-- // Replies in the gated thread (the creator may still post) never go out.-- const gatedThread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);-- const reply = await forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });-- assert.strictEqual(await relay.enqueuePost(reply, gatedThread, general), 0);-- // A members-only thread from the start is never queued, nor is anything in a VIP space.-- const gated = await newThread(general, 'VIP from the start', { members_only_owner: sam });-- assert.strictEqual(await relay.enqueueThread(gated, general), 0);-- await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);-- const vipSpace = await forumStore.getSpace(db, 'feedback');-- assert.strictEqual(await relay.enqueueThread(await newThread(vipSpace, 'In a VIP space'), vipSpace), 0);-- await db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);-- await drainAll();-- assert.ok(!hook.hits.slice(posted).some((h) => h.method !== 'DELETE'), 'nothing but the two deletes');-- });---- await check('app wiring: threads, replies, edits and deletes through the API are relayed; the admin endpoints are staff-only and URL-free', async () => {-- const t = await boot({---- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/9/app` } }, forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } },-- });-- const net = t.network;-- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });-- const call = (p, { method = 'GET', cookie, token, json, headers = {} } = {}) => {-- const h = { ...headers };-- if (token) h.authorization = `Bearer ${token}`;-- if (json !== undefined) h['content-type'] = 'application/json';-- return t.get(p, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- };-- const r = t.app.locals.relay;-- assert.strictEqual((await r.status()).creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');-- assert.deepStrictEqual([(await r.status()).events_worker.enabled, (await r.status()).inbound.enabled], [false, false]);-- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);-- const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });-- assert.strictEqual(bad.status, 400);-- assert.strictEqual(bad.json().code, 'relay.invalid_ref');-- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', discord_channel_id: 'nope' } })).json().code, 'relay.invalid_discord_id');-- const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } });-- assert.strictEqual(m.status, 201, m.text);-- assert.deepStrictEqual([m.json().mapping.space, m.json().mapping.webhook_configured, m.json().mapping.enabled, m.json().mapping.inbound, m.json().mapping.discord_channel_id], ['general', true, true, false, null]);-- const put = await call(`/api/v1/relay/mappings/${m.json().mapping.id}`, { method: 'PUT', cookie: adminJwt, json: { inbound: true, discord_channel_id: '2000000000000000009' } });-- assert.deepStrictEqual([put.json().mapping.inbound, put.json().mapping.discord_channel_id, put.json().mapping.enabled], [true, '2000000000000000009', true]);-- const before = hook.hits.length;-- const waitHits = async (n) => { for (let i = 0; i < 100 && hook.hits.length < n; i++) await new Promise((res) => setTimeout(res, 10)); };-- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Through the app', body: 'hi' } });-- assert.strictEqual(th.status, 201);-- await waitHits(before + 1);-- assert.strictEqual(hook.hits[hook.hits.length - 1].path, '/api/webhooks/9/app?wait=true');-- const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'a reply' } });-- assert.strictEqual(reply.status, 201, reply.text);-- await waitHits(before + 2);-- assert.match(hook.hits[hook.hits.length - 1].body.content, /replied to \*\*Through the app\*\*/);-- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'a reply, edited' } })).status, 200);-- await waitHits(before + 3);-- assert.strictEqual(hook.hits[hook.hits.length - 1].method, 'PATCH');-- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);-- await waitHits(before + 4);-- assert.strictEqual(hook.hits[hook.hits.length - 1].method, 'DELETE');-- hook.plan.push({ status: 400, body: { message: 'Invalid Form Body' } });-- await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'This one fails', body: 'hi' } });-- await waitHits(before + 5);-- await r.drain();-- assert.strictEqual((await call('/api/v1/relay/deliveries')).status, 401);-- assert.strictEqual((await call('/api/v1/relay/deliveries', { cookie: samJwt })).status, 403);-- assert.strictEqual((await call('/api/v1/relay/status', { cookie: samJwt })).status, 403);-- const failed = await call('/api/v1/relay/deliveries?status=failed', { cookie: adminJwt });-- assert.strictEqual(failed.status, 200, failed.text);-- assert.strictEqual(failed.json().enabled, true);-- assert.strictEqual(failed.json().deliveries.length, 1);-- const dl = failed.json().deliveries[0];-- assert.match(dl.last_error, /400: Invalid Form Body/);-- assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/s/general/t/this-one-fails', 'create', 'direct']);-- const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });-- assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);-- const st = await call('/api/v1/relay/status', { cookie: adminJwt });-- assert.strictEqual(st.status, 200);-- assert.deepStrictEqual([st.json().dead_letters, st.json().deliveries.failed, st.json().creates_from], [1, 1, 'forum']);-- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/drop`, { method: 'POST', cookie: adminJwt })).status, 200);-- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/drop`, { method: 'POST', cookie: adminJwt })).status, 404, 'dropped already');-- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/retry`, { method: 'POST', cookie: adminJwt })).status, 200, 'a dropped one can be retried');-- await waitHits(before + 6);-- await r.drain();-- assert.strictEqual((await call('/api/v1/relay/deliveries?status=failed', { cookie: adminJwt })).json().deliveries.length, 0);-- const ready = await call('/api/ready');-- assert.deepStrictEqual([ready.json().discord_relay.enabled, ready.json().discord_relay.events_worker.enabled, ready.json().discord_relay.deliveries.delivered > 0], [true, false, true]);-- const svcAdmin = await call('/api/v1/relay/deliveries', { token: net.signService({ cap: ['space.forum.manage'] }) });-- assert.strictEqual(svcAdmin.status, 200);-- assert.ok(!(failed.text + svcAdmin.text + st.text + (await call('/api/v1/relay/mappings', { cookie: adminJwt })).text).includes('/api/webhooks/'), 'responses never carry a webhook URL');-- await t.close();-- });---- await check('staff can map only allow-listed webhook variables, never the URL in some other env var', async () => {-- const secretHits = [];-- const internal = { url: 'http://127.0.0.1:9/internal', hits: secretHits };-- const env2 = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/ok`, OV_MEDIA_INTERNAL_URL: internal.url, NETWORK_INTERNAL_URL: internal.url, DISCORD_WEBHOOKS_X: internal.url };-- const fetchSpy = (url, o) => { if (String(url).startsWith(internal.url)) secretHits.push(url); return fetch(url, o); };-- const t = await boot({ appOpts: { relayOptions: { enabled: true, env: env2, fetchImpl: fetchSpy }, forumLimits: { threads: { cooldownSec: 0 } } } });-- const net = t.network;-- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });-- const samJwt = net.sign({ id: 9, subject_id: net.addUser({ network_user_id: 9, username: 'sam' }).subject_id, username: 'sam', role: 'user' });-- const call = (p, { method = 'GET', cookie, token, json } = {}) => t.get(p, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) }, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- const map = (ref, who = { cookie: adminJwt }) => call('/api/v1/relay/mappings', { method: 'POST', json: { space: 'general', webhook_url_ref: ref }, ...who });-- for (const ref of ['OV_MEDIA_INTERNAL_URL', 'NETWORK_INTERNAL_URL', 'DISCORD_WEBHOOKS_X', 'PATH', 'DISCORD_WEBHOOK_']) {-- const r = await map(ref);-- assert.strictEqual(r.status, 400, `${ref}: ${r.text}`);-- assert.strictEqual(r.json().code, 'relay.ref_not_allowed');-- const svc = await map(ref, { token: net.signService({ cap: ['space.forum.manage'] }) });-- assert.strictEqual(svc.status, 400, `${ref} as a service`);-- }-- assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get()).n, 0);-- await assert.rejects(async () => await t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);-- // A mapping that predates the allow-list (straight into the table) is never sent to, and-- // does not reveal whether that variable is set.-- const gen = await t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();-- await t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);-- const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((x) => x.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');-- assert.strictEqual(listed.webhook_configured, false);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);-- await t.app.locals.relay.drain();-- assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');-- const d = await t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();-- assert.strictEqual(d.status, 'failed');-- assert.match(d.last_error, /not an allowed webhook variable/);-- assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');-- await t.close();-- // DISCORD_RELAY_WEBHOOK_VARS narrows it to exact names.-- const strict = createDiscordRelay({ db: t.db, env: env2, webhookVars: ['DISCORD_WEBHOOK_GENERAL'] });-- assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_GENERAL'), true);-- assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_OTHER'), false);-- assert.strictEqual(createDiscordRelay({ db: t.db, env: env2 }).refAllowed('DISCORD_WEBHOOK_OTHER'), true);-- });---- await check('disabled by default in the app: no relay, no Events worker, no gateway, whatever else is set', async () => {-- process.env.DISCORD_BOT_TOKEN = 'not-a-real-token';-- process.env.DISCORD_RELAY_INBOUND = 'on';-- process.env.EVENTS_URL = 'http://127.0.0.1:9';-- try {-- const t = await boot({ });-- const st = await t.app.locals.relay.status();-- assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled, t.app.locals.relayInbound], [false, false, false, null]);-- assert.match(st.events_worker.reason, /relay is off/);-- await t.close();-- } finally {-- delete process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_RELAY_INBOUND; delete process.env.EVENTS_URL;-- }-- });---- await hook.close();-- done();--})();ddeleted file mode 100644@@@ -1,194 +0,0 @@--'use strict';--/**-- * Per-space moderators (space_moderators, migrations/0001_initial.sql): a person listed for a space moderates-- * that space — thread state, deletes, settings, categories, its moderators — and nothing anywhere else; discussion-- * staff still moderate everywhere; creating spaces and the board index stay with staff. Adding is refused for a-- * non-moderator and across a block; account deletion erases the row and a subject merge moves it.-- */--const assert = require('assert');--const { boot, check, done } = require('./helpers/app');----(async () => {-- const t = await boot({-- appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } },-- });-- const net = t.network;-- const mk = (id, username, role = 'user') => {-- const u = net.addUser({ network_user_id: id, username, display_name: username });-- return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };-- };-- const alex = mk(31, 'alex'); // moderates general-- const sam = mk(32, 'sam'); // nobody in particular-- const bob = mk(33, 'bob'); // writes the threads-- const carol = mk(34, 'carol'); // added by alex-- const dave = mk(35, 'dave'); // blocked alex-- const boss = mk(36, 'boss', 'global_mod');-- const call = (path, { method = 'GET', who, json } = {}) => t.get(path, {-- method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: who ? [`ov_token=${who.jwt}`] : [],-- });-- const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${who.jwt}`] });-- const thread = async (space) => {-- const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', who: bob, json: { title: `A thread in ${space}`, body: 'hello there' } });-- assert.strictEqual(r.status, 201, r.text);-- return r.json().thread.slug;-- };-- const mods = async (space) => (await call(`/api/v1/spaces/${space}/moderators`)).json().moderators.map((m) => m.subject);---- await check('(d) adding a moderator: refused for a non-moderator, works for staff and then for the space\'s moderator', async () => {-- let r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: sam });-- assert.strictEqual(r.status, 403, r.text);-- assert.strictEqual(r.json().code, 'capability.denied');-- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: sam })).status, 403, 'nobody adds themselves');-- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT' })).status, 403, 'signed out');-- r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: boss });-- assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(r.json().moderators.map((m) => [m.subject, m.username, m.added_by]), [[alex.subject_id, 'alex', boss.subject_id]]);-- r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex })).status, 200, 'idempotent');-- assert.deepStrictEqual(await mods('general'), [alex.subject_id, carol.subject_id]);-- assert.strictEqual((await call('/api/v1/spaces/general/moderators/not-a-subject', { method: 'PUT', who: alex })).status, 404, 'a name nobody holds');-- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${encodeURIComponent('not a name!')}`, { method: 'PUT', who: alex })).status, 400, 'neither a subject nor a name');-- assert.strictEqual((await call(`/api/v1/spaces/no-such-space/moderators/${carol.subject_id}`, { method: 'PUT', who: boss })).status, 404);-- assert.strictEqual((await call(`/api/v1/spaces/showcase/moderators/${sam.subject_id}`, { method: 'PUT', who: alex })).status, 403, 'not in another space');-- assert.deepStrictEqual(await mods('showcase'), []);-- });---- await check('a person who blocked the one adding them (or was blocked by them) cannot be added', async () => {-- await t.db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, ?)').run(dave.subject_id, alex.subject_id, Date.now());-- const r = await call(`/api/v1/spaces/general/moderators/${dave.subject_id}`, { method: 'PUT', who: alex });-- assert.strictEqual(r.status, 403, r.text);-- assert.strictEqual(r.json().code, 'space.blocked');-- assert.ok(!(await mods('general')).includes(dave.subject_id));-- });---- await check('(a) a moderator of general locks, sets the state of and deletes a thread there; can_moderate shows it', async () => {-- const slug = await thread('general');-- let r = await call(`/api/v1/spaces/general/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true, pinned: true } });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.json().thread.locked, true);-- const page = (await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).json();-- assert.strictEqual(page.viewer.can_moderate, true);-- assert.strictEqual(page.viewer.can_delete, true);-- assert.strictEqual(page.posts[0].can_edit, true);-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: alex, json: { body: 'moderators reply in a locked thread' } })).status, 201);-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: sam, json: { body: 'others do not' } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, true);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: sam })).json().viewer.can_moderate, false);-- r = await call(`/api/v1/spaces/general/threads/${slug}`, { method: 'DELETE', who: alex });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).status, 404);-- });---- await check('(b) the same moderator gets 403 in showcase', async () => {-- const slug = await thread('showcase');-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true } })).status, 403);-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { method: 'DELETE', who: alex })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/showcase/settings', { method: 'PUT', who: alex, json: { name: 'Mine now' } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/showcase/categories/clips', { method: 'PUT', who: alex, json: { name: 'Clips' } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: alex, json: { owner: alex.subject_id } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: alex })).json().viewer.can_moderate, false);-- const page = (await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: alex })).json();-- assert.strictEqual(page.viewer.can_moderate, false);-- assert.strictEqual(page.viewer.can_delete, false);-- assert.strictEqual(page.posts[0].can_edit, false);-- assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');-- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);-- });---- await check('(c) a global moderator still passes in both spaces', async () => {-- for (const space of ['general', 'showcase']) {-- const slug = await thread(space);-- assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}/state`, { method: 'PUT', who: boss, json: { locked: true } })).status, 200, space);-- assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}`, { method: 'DELETE', who: boss })).status, 200, space);-- }-- });---- await check('settings and categories of their own space; never its place on the board index', async () => {-- let r = await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { description: 'Run by its moderators' } });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.json().space.description, 'Run by its moderators');-- assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { group: null } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { parent: 'showcase' } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);-- assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);-- const before = (await call('/api/v1/spaces/general')).json().space.group;-- r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });-- assert.strictEqual(r.status, 303, r.text);-- assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');-- });---- await check('(e) a space moderator cannot create a space or change the board index', async () => {-- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);-- assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);-- assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);-- assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);-- });---- await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {-- let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });-- assert.strictEqual(r.status, 303, r.text);-- assert.ok((await mods('general')).includes(sam.subject_id));-- assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);-- r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });-- assert.strictEqual(r.status, 303, r.text);-- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');-- r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });-- assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(await mods('general'), [alex.subject_id]);-- });---- await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {-- let r = await form('/s/general/moderators', alex, { subject: '@Sam' });-- assert.strictEqual(r.status, 303, r.text);-- assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);-- assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');-- r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });-- assert.strictEqual(r.status, 303, r.text);-- assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');-- assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);-- assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');-- for (const style of ['forum', 'feed']) {-- assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);-- const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });-- assert.strictEqual(page.status, 200, page.text);-- assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);-- assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);-- assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);-- assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);-- assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);-- assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);-- for (const who of [bob, undefined]) {-- const other = await call('/s/general', { who });-- assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);-- }-- }-- r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });-- assert.strictEqual(r.status, 303, r.text);-- assert.deepStrictEqual(await mods('general'), [alex.subject_id]);-- });---- await check('(f) account deletion erases the row; a subject merge moves it', async () => {-- const accountData = require('../server/identity/account-data');-- const merge = require('../server/identity/subject-merge');-- const ids = async (subject) => (await t.db.prepare('SELECT space_id FROM space_moderators WHERE subject_id = ? ORDER BY space_id').all(subject)).map((r) => Number(r.space_id));-- await call(`/api/v1/spaces/showcase/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });-- await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });-- await call(`/api/v1/spaces/showcase/moderators/${bob.subject_id}`, { method: 'PUT', who: boss });-- // carol folds into bob: general moves over, showcase (both had it) stays bob's alone.-- await merge.apply(t.db, { from: carol.subject_id, into: bob.subject_id, merge_id: 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4' }, { log: { log() {} } });-- assert.deepStrictEqual(await ids(carol.subject_id), []);-- assert.deepStrictEqual(await ids(bob.subject_id), [1, 3]);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: bob })).json().viewer.can_moderate, true);-- // alex's account is deleted: the row goes, and the moderators alex added stay without them.-- await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: alex });-- const out = await accountData.erase(t.db, [alex.subject_id]);-- assert.strictEqual(out.erased.space_moderators, 1);-- assert.deepStrictEqual(await ids(alex.subject_id), []);-- assert.strictEqual((await t.db.prepare('SELECT added_by FROM space_moderators WHERE subject_id = ? AND space_id = 1').get(sam.subject_id)).added_by, null);-- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, false);-- });---- await t.close();-- done();--})();ddeleted file mode 100644@@@ -1,73 +0,0 @@--'use strict';--/**-- * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same transaction, after locking-- * the target row (server/votes.js), so however writes interleave the stored score is the sum of the vote rows.-- * Checked on a real database: interleaved writes step by step, a stale reader, then 1000 votes with 32 in flight at a-- * time (under npm run test:pg they run on separate PostgreSQL connections through PgBouncer).-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { testDb } = require('./helpers/db');--const { applyVote } = require('../server/votes');--const forumStore = require('../server/forum/store');--const { check, done } = require('./helpers/app');----let db, thread, subjects;----async function invariant(label) {-- const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);-- const tv = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);-- assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);-- const dup = (await db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM thread_votes WHERE thread_id = ? GROUP BY subject_id HAVING COUNT(*) > 1) d').get(thread.id)).n;-- assert.strictEqual(dup, 0, `${label}: one vote per person`);--}----(async () => {-- db = await testDb({ max: 8 });-- const space = await forumStore.getSpace(db, 'general');-- ({ thread } = await forumStore.createThread(db, { space_id: space.id, title: 'Race thread', body_markdown: 'go' }));-- subjects = Array.from({ length: 10 }, () => ids.newId('user'));---- await check('interleaved votes: add, flip, remove — the score never drifts', async () => {-- const [s1, s2, s3] = subjects;-- const steps = [[s1, 1], [s1, -1], [s2, 1], [s2, 1], [s1, -1], [s3, -1], [s3, 0], [s1, 1], [s2, 0], [s2, -1], [s3, 1], [s3, 1]];-- for (const [s, v] of steps) {-- const out = await applyVote(db, 'thread', thread.id, s, v);-- await invariant('after step');-- assert.strictEqual(out.my_vote, v);-- }-- // Final state is the last write per person: s1 +1, s2 −1, s3 +1.-- const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);-- assert.deepStrictEqual({ ...t }, { score: 1 });-- });---- await check('a stale reader cannot overwrite a newer score: the recount reads the rows under the row lock', async () => {-- const staleScore = (await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id)).score;-- await applyVote(db, 'thread', thread.id, subjects[4], 1);-- const out = await applyVote(db, 'thread', thread.id, subjects[5], 1);-- assert.strictEqual(out.score, staleScore + 2);-- await invariant('stale reader');-- });---- await check('1000 votes, 32 in flight at a time, on one thread: invariants hold, none fails', async () => {-- let seed = 7919;-- const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };-- const ops = Array.from({ length: 1000 }, () => [subjects[rand(subjects.length)], [1, -1, 0][rand(3)]]);-- // 32 in flight at a time: more than the pool's connections, so votes queue and interleave on the row locks.-- const failed = [];-- let next = 0;-- await Promise.all(Array.from({ length: 32 }, async () => {-- while (next < ops.length) {-- const [s, v] = ops[next++];-- try { await applyVote(db, 'thread', thread.id, s, v); } catch (err) { failed.push(err); }-- }-- }));-- assert.strictEqual(failed.length, 0, failed.length ? failed[0].message : '');-- await invariant('after the storm');-- const rows = (await db.prepare('SELECT COUNT(*) AS n FROM thread_votes WHERE thread_id = ?').get(thread.id)).n;-- assert.ok(rows <= subjects.length);-- });---- await db.close();-- done();--})();
@@ -26,72 +26,15 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4940/auth/callback
@@ -26,72 +26,15 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4940/auth/callback
# Cookies — production default is Secure; set false for plain-http local dev
# Cookies — production default is Secure; set false for plain-http local dev
#COOKIE_SECURE=false
#COOKIE_SECURE=false
-# OpenVibe.Media — images on posts are med_ objects uploaded through Media's Object API
-# (the OAuth client needs media.object.upload for audience openvibe.media).
-OV_MEDIA_URL=https://openvibe.media
-OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
-
-# OpenVibe.VIP: members-only spaces and threads. Space asks POST /api/v1/policies/evaluate with a
-# service token (the OAuth client needs vip.resource.policy.evaluate for audience openvibe.vip).
-# Without it, or with VIP down, only the owner and moderators get in (fails closed).
-OV_VIP_URL=https://openvibe.vip
-OV_VIP_INTERNAL_URL=http://127.0.0.1:4620
-# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.
-#VIP_CACHE_TTL_MS=30000
-#VIP_CACHE_DENY_TTL_MS=10000
-#VIP_CACHE_UNAVAILABLE_TTL_MS=2000
-
-# OpenVibe.Chat: a space's chat room. Space asks Chat with the signed-in person's own token whether they
-# manage the room (no service grant needed); the space page links OV_CHAT_URL/r/<room>.
-OV_CHAT_URL=https://openvibe.chat
-OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
-
-# OpenVibe.Community — the hub: Space's pages link the network Pulse feed it serves (D12).
+# OpenVibe.Community — the hub, and where the forum lives (owner decision, 2026-10-08): every
+# forum URL on this site redirects to the same path and query there.
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
-# IndexNow (openvibe-shared/indexnow): the key file is served at /<key>.txt and a public thread
-# appearing, changing or going away pings the engines. Unset: off.
-INDEXNOW_KEY=
+# OpenVibe.Live — the legal pages the shared footer links
+OV_LIVE_URL=https://openvibe.live
-# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space: the runtime role through
-# PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters). The forum
-# lives there: spaces, threads, posts, votes, attachments, the relay's bookkeeping. Without DATABASE_URL,
-# development uses an embedded PGlite database in data/pglite.
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh space. Space holds no
+# user content yet; the database carries the shared schema (migrations/) and feeds /api/ready.
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
DATABASE_URL=
DATABASE_URL=
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
-VALKEY_URL=
-VALKEY_PREFIX=ov:space:
-
-# Per-actor limits (server/actor-limits.js): reads one caller (a person, a service or app acting as
-# itself, else an address) may make to one API per minute and per hour; past them 429 rate_limited
-# with Retry-After. Writes have their own numbers (README). The per-address /api/ limit stays.
-#SPACE_LIMITS_MINUTE=120
-#SPACE_LIMITS_HOUR=3000
-
-# Browser origins allowed to call the embeddable forum APIs with a Bearer Network JWT
-#API_CORS_ORIGINS=https://openvibe.community,https://openvibe.live,https://openvibe.network,https://openvibe.tools
-
-# ── Discord relay (server/relay; docs/discord-relay.md) ──────
-# Outbound: new threads and replies in mapped public spaces → the mapping's Discord webhook.
-# Mappings (POST /api/v1/relay/mappings, staff) name an ENVIRONMENT VARIABLE that holds the webhook
-# URL — the URL itself never goes into the database.
-#DISCORD_RELAY_ENABLED=true
-#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>
-# Variables a mapping may name: DISCORD_WEBHOOK_* unless this lists the exact names.
-#DISCORD_RELAY_WEBHOOK_VARS=DISCORD_WEBHOOK_FEEDBACK,DISCORD_WEBHOOK_GENERAL
-#DISCORD_RELAY_POLL_MS=30000
-#DISCORD_RELAY_BACKOFF_MS=30000
-#DISCORD_RELAY_MAX_ATTEMPTS=6
-# Inbound: replies on Discord become posts (a bot in the server with the MESSAGE CONTENT intent).
-#DISCORD_RELAY_INBOUND=on
-#DISCORD_BOT_TOKEN=
-#DISCORD_GATEWAY_URL=wss://gateway.discord.gg/?v=10&encoding=json
-
-# OpenVibe.Events — the relay's worker polls creates from the outbox through Events, and Events
-# delivers Network's block/account/merge events to /internal/events (server/events-consumer.js).
-EVENTS_URL=
-# Signature secret Events signs deliveries with (SPACE_EVENTS_SECRET; comma-separated for rotation,
-# each 32+ characters). Unset: the internal route answers 503 and nothing is applied.
-SPACE_EVENTS_SECRET=
-# Set 0 to stop the relay's worker from reading creates off Events (the forum queues them itself).
-#DISCORD_RELAY_EVENTS=off
@@ -10,7 +10,7 @@ jobs:
@@ -10,7 +10,7 @@ jobs:
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
with:
with:
- syntax-dirs: server public/js test
+ syntax-dirs: server test
contracts-service: space
contracts-service: space
test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+# Changelog
+
+## Unreleased
+
+- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.
+- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.
+- Space's home page (`/`) is now a short, honest page: Space will host code and dynamic pages, and "spaces" — apps people build on their OpenVibe account, the network's services and the SDK — with a clear link to the forum at https://openvibe.community/s. It claims no feature Space does not have.
+- Kept: sign-in (the Network OAuth2 client `space`), the discovery files (robots, llms.txt, llms-full.txt, a sitemap holding `/` alone), health/ready/release.json. Removed the account export/deletion hooks and the rest of the network-event consumer: they only ever touched the forum's tables, and the forum's account data is Community's. The forum tables stay in the database; this release writes no drop migration.
+- The suite is now `home`, `redirects` and `boot` (the real process on a fresh database, ending in a clean SIGTERM).
@@ -1,304 +1,169 @@
@@ -1,304 +1,169 @@
# OpenVibe.Space
# OpenVibe.Space
-**https://openvibe.space — the forums of OpenVibe.**
+**https://openvibe.space — code and dynamic pages for the OpenVibe network.**
## Purpose
## Purpose
-The forum service of the OpenVibe network: public, members-only and staff **spaces**, their
-**threads**, **posts**, votes, ratings, categories, per-space **moderators** and the **Discord
-relay**. The forum engine moved here from OpenVibe.Community (plan T10 step 3, decisions D11/D12/D29):
-Community stays the hub (pastes, comment threads, Pulse, submissions) and Space owns the forums, in
-its own database.
-
-It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that server-renders
-every page — crawlers and no-JS readers get the whole thing, every action is a link or a form post —
-and adds a little progressive JavaScript for comfort.
-
-## Owns
-
-- spaces (groups, categories, child boards, per-space settings), threads, posts, post versions,
- thread votes, post ratings and image attachments (Media objects it names)
-- the board index, the space pages, the thread pages and their reads and writes: `GET /s`,
- `GET /api/v1/spaces…`, `GET|POST|PUT|DELETE /api/v1/posts…`
-- per-space moderators (`space_moderators`) — a data row, not a capability: a person the space lists
- moderates that space as discussion staff do everywhere, while the board index and creating spaces
- stay with staff
-- a space's chat-room link (OpenVibe.Chat owns the room), the Discord relay's mappings, deliveries,
- message map, cursors and inbound failures, the block projection, the account-data records, the
- `space.*` events and one PostgreSQL database (`ov_space`, ADR-035; schema in [migrations/](migrations/);
- embedded PGlite in development), with Valkey for the per-actor limit counters
+Space is being rebuilt to host **code** and **dynamic pages**, and **spaces**: apps people build
+that use their OpenVibe account, the network's services and the SDK.
+
+Nothing is published here yet. The site is honest about that: the home page says what is coming and
+nothing else, and no feature is faked.
+
+## The forum moved to OpenVibe.Community
+
+The forum lived here from 3 October 2026 (it moved off Community then). On **2026-10-08 the owner
+moved it back**: OpenVibe.Community owns the forum (spaces, threads, posts, votes, categories,
+per-space moderators, the Discord relay) again, and Space becomes code hosting and spaces.
+
+So Space keeps no forum code and **every forum URL answers a permanent redirect to the same path and
+query on https://openvibe.community**:
+
+| Path | Methods | Status |
+| --- | --- | --- |
+| `/s`, `/s/*` (pages, feeds) | GET, HEAD | `301` |
+| `/s`, `/s/*` | anything else | `308` (the method and body are preserved) |
+| `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*`, `/api/v1/relay*` | all | `308` |
+
+A redirected bookmark, crawler or API client therefore lands on Community's copy of the same page.
+Nothing that is not a forum path redirects.
+
+## What is here today
+
+- **`GET /`** — the home page (server-rendered through the shared OpenVibe Frame), with a clear link to
+ the forum at https://openvibe.community/s.
+- **Sign-in** — the Network OAuth2 client `space`: `/auth/login|callback|fedcm|logout|me|refresh`.
+ No signed-in surface needs it yet; it is kept because the shared navbar signs people in everywhere.
+- **Discovery** — `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (the home page alone).
+- **Health and release** — `/api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback only).
## Does not own
## Does not own
-- identity and SSO (OpenVibe.Network), image bytes (OpenVibe.Media), memberships (OpenVibe.VIP),
- chat rooms (OpenVibe.Chat) and the Discord API itself
-- **pastes, typed comment threads, Pulse and submissions**: they are OpenVibe.Community's, and they
- stay there (plan T10 D3). A thread's replies are its own posts — the forum does not use the comment
- service — and Space records no Pulse item and keeps no paste table.
+- the forum, pastes, typed comment threads, Pulse and submissions — OpenVibe.Community's
+- identity, accounts and tokens — OpenVibe.Network's
+- images, memberships and chat rooms — OpenVibe.Media's, OpenVibe.VIP's and OpenVibe.Chat's
## Depends on
## Depends on
-- OpenVibe.Network (SSO, JWKS, service tokens, `identity.subject.resolve` for author names)
-- OpenVibe.Media (attachment uploads through the Object API), OpenVibe.VIP (members-only gates),
- OpenVibe.Chat (a space's room), OpenVibe.Events (the outbox relay and the block/account/VIP
- subscriptions), OpenVibe.Community (the hub: the Pulse feed Space's pages link)
-- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.35.0, `openvibe-shared` v2.15.0, pinned by release tarball
+- OpenVibe.Network (SSO, JWKS, the `space` OAuth client)
+- OpenVibe.Community (where every forum URL redirects)
+- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.35.0, `openvibe-shared` v2.15.0, pinned by release tarball
-## How it fits the network
+## What Space applies from the network: nothing
-| Concern | Where it lives | How Space reaches it |
-| --- | --- | --- |
-| Spaces, threads, posts, votes, ratings | **Space's PostgreSQL** (`ov_space`, `server/forum/*`) | own database |
-| Image bytes | **OpenVibe.Media** (Object API v2, tenant `space`) | service token (`media.object.upload`) |
-| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `space` |
-| 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) |
-| Pulse (the network's public activity) | **OpenVibe.Community** | a link (`OV_COMMUNITY_URL/pulse`); Space stores none of it |
-| Blocks, token cutoffs, account export/deletion, subject merge | **OpenVibe.Network** through **OpenVibe.Events** | `POST /internal/events`, signed with `SPACE_EVENTS_SECRET` |
-
-Space resolves the visitor itself: a Network JWT names the account by its `subject_id` (`usr_…`),
-which is exactly what Space stores, so there is no second identity to map and no proxy.
-
-## Endpoints
-
-Pages (server-rendered) and API:
-
-| Page | API |
-| --- | --- |
-| `GET /s` — the board index (every space, grouped, with topics, posts and the last post) | `GET /api/v1/spaces`, `GET /api/v1/space-groups` |
-| `GET /s/:space` — a space's threads (`?sort=hot\|new\|top\|active&page=&category=&status=`) | `GET /api/v1/spaces/:space/threads` |
-| `GET /s/:space/t/:slug` — a thread with its posts (`?page=`, `?quote=<post id>`) | `GET /api/v1/spaces/:space/threads/:slug` |
-| `GET /s/:space/new`, `POST /s/:space/new` — start a thread | `POST /api/v1/spaces/:space/threads` |
-| `POST /s/:space/t/:slug/reply` — reply (multipart images allowed) | `POST /api/v1/spaces/:space/threads/:slug/posts` |
-| `POST /s/:space/t/:slug/vote` — 1, −1 or 0 | `POST /api/v1/spaces/:space/threads/:slug/votes` |
-| `POST …/state`, `…/delete`, `…/members-only`, `…/status`, `…/category`, `…/crosspost` — moderation, no JS | `PUT /api/v1/spaces/:space/threads/:slug/…`, `PUT\|DELETE /api/v1/posts/:id` |
-| `POST /s/:space/chat-room`, `…/chat-room/detach` | `PUT\|DELETE /api/v1/spaces/:space/chat-room` |
-| `POST /s/:space/moderators`, `…/moderators/remove` | `GET\|PUT\|DELETE /api/v1/spaces/:space/moderators[/:subject]` |
-| `GET /s/feed.xml`, `/s/:space/feed.xml` — RSS | `GET /robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` |
-| `GET /auth/login\|callback\|logout\|me\|refresh` | `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` (loopback) |
-| `GET /`, `/updates` — 301/302 to `/s` and the community's log | `POST /internal/events` (signed Events deliveries) |
-
-## The forum
-
-A space is one of three visibilities — `public` (anyone reads, people post), `members` (signed-in
-people read and post) and `staff` (moderators only; it looks missing to everyone else) — and one of
-two styles: `feed` (ranked by votes, hot/new/top, like a subreddit) or `forum` (the newest reply on
-top, author panels, quotes; like a classic board). A thread is a `discussion`, a `request` (Feedback:
-open → planned → in progress → done, or declined) or a `roadmap` item (the Roadmap space, synced from
-`docs/roadmap/public.json` at every boot), each with its own statuses and its own thread.
-
-Writing needs a person: the browser's Network JWT, or a service naming one in `X-OV-Subject` (or
-`X-OV-Origin: ai` for AI output, stored with origin `ai` and no author). Per-person limits
-(server/limits.js) hold: threads 3 a minute, replies 6 a minute, votes and ratings 60, uploads 12,
-plus a daily thread cap; per-actor limits (server/actor-limits.js) cap requests per route family.
-
-Images on posts go to OpenVibe.Media as `med_` objects (`kind: attachment`, `visibility: unlisted`),
-owned by the person who uploaded them; the post names at most four `media_id`s. Post bodies are
-Markdown through `server/render/markdown.js` — a small, safe renderer: every piece of text is escaped
-first and only a fixed tag set leaves it.
-
-### Per-space moderators
-
-A space lists its own moderators (`space_moderators`, people only, added by `@username` or `usr_…`
-resolved through the Network). In that space they act as discussion staff do everywhere: thread
-state and status, deletes and edits, settings, categories, members-only gating and the chat room.
-They can add and remove each other, and step down. Creating spaces, the board index (groups, a
-space's place on it), roadmap items and staff spaces stay with discussion staff
-(`identity/capabilities.js`).
-
-### Members-only spaces and threads (OpenVibe.VIP)
-
-A space (its creator, or a moderator for any creator) or a single thread (its author, for their own
-members, or a moderator) can be opened to one creator's VIP members: `members_only_owner` is the
-creator's `usr_…` subject. Reading, posting, voting and editing then need an active entitlement,
-asked of VIP with a service token and cached (a "yes" at most `VIP_CACHE_TTL_MS`); the owner and
-moderators always pass, and every doubt (signed out, VIP down, no grant) fails closed with
-`403 vip.members_only` and a join link. A gated thread's title is listed with its `members_only`
-flag, never a body; gated things never reach the sitemap, feeds or the Discord relay.
-
-## Discord relay
-
-`server/relay/discord.js` mirrors new threads and replies in mapped public spaces to a Discord
-webhook, with attribution, a link and no mentions, and follows edits and deletes through the
-message map. Webhook URLs live in environment variables named by a mapping (`webhook_url_ref`) —
-never in the database. `server/relay/events-worker.js` can queue the creates from
-`space.thread.created` / `space.post.created` off OpenVibe.Events instead (the default when
-`EVENTS_URL` and the client secret are set); `server/relay/inbound.js` + `discord-gateway.js` bring
-replies written on Discord back as posts, with the Discord name and `origin 'discord'`, honouring
-locks, members-only gates and per-author limits. The whole relay is off unless
-`DISCORD_RELAY_ENABLED` (inbound also needs `DISCORD_RELAY_INBOUND=on` and `DISCORD_BOT_TOKEN`);
-see [docs/discord-relay.md](docs/discord-relay.md) (the moved doc from Community).
-
-## What Space applies from the network
-
-`POST /internal/events` (server/events-consumer.js) is Space's Events subscription endpoint
-(consumer `space`, signature v2 under `SPACE_EVENTS_SECRET`, loopback only). Exactly once, through
-the SDK inbox:
-
-- `network.user.token_valid_after` — the person's token cutoff moves (sign out everywhere, password
- change, ban) and their older tokens are refused here at once
-- `network.block.changed` — the block projection (`network_blocks`): nobody replies in a thread whose
- author blocked them, or reacts to a post whose author blocked them
-- `network.subject.merged` — the folded-in subject's rows become the survivor's
-- `network.account.export_requested` / `network.account.deleted` — Space's part of an account export,
- and what the person wrote goes (or becomes an authorless tombstone when others replied under it,
- so their replies keep their place); `space_moderators` and `network_blocks` rows go, `created_by`
- and `added_by` are cleared, and Space confirms the counts
-- `vip.membership.changed` — the cached members-only answers for that creator are dropped at once
-
-## Capabilities
-
-Space's capabilities are `space.*` (openvibe-contracts `manifests/services/space.json`); v0.110.0
-registers them active and lists them on the Space manifest:
-
-| Capability | What it covers |
-| --- | --- |
-| `space.forum.read` | list the spaces the caller can open, one space's metadata, `GET /s` |
-| `space.forum.manage` | moderation and staff administration of spaces (create, rename, settings, categories, moderators, visibility, members-only) |
-| `space.thread.read` | a space's threads and one thread with its posts, the feeds |
-| `space.post.write` | create threads and replies, vote, edit or delete one's own posts |
-
-`space.thread.write` (a thread lifecycle grant on its own) and `space.pulse.read` (the Pulse activity
-beside the forums) stay planned in Contracts and Space serves neither: `space.post.write` carries
-thread writes and Community serves the Pulse feed.
-
-Service tokens are held to these (`server/http/v1.js` `serviceCap`/`serviceAnyCap`); browsers and
-anonymous callers pass the middlewares and are judged by Space's own rules instead. Staff powers come
-from the contracts staff map (`staff.moderation.discussions`), so a person is staff on every OpenVibe
-site at once.
-
-## Per-actor limits
-
-`server/actor-limits.js` counts requests per caller (a person, a first-party service relaying a
-visitor, a service acting as itself, else the address) per route family, once `req.viewer` is
-resolved: reads get `SPACE_LIMITS_MINUTE`/`SPACE_LIMITS_HOUR` (120/3000), writes their own numbers
-where they are mounted. Past a limit the route answers `429 rate_limited` with `Retry-After` before
-it does any work, counted in `space_rate_limited_total{limit,window}`. The per-address `/api/` limit
-stays in front, and `/internal/events`, `/api/health`, `/api/ready`, `/release.json` and `/metrics`
-are never limited.
-
-## SEO
-
-Every page carries the full head through `openvibe-shared/shell`: description, canonical, robots,
-Open Graph, Twitter card, an ai-summary and JSON-LD (`WebSite`, `BreadcrumbList`, and
-`DiscussionForumPosting` for a thread, with its replies; never for a gated page). `/robots.txt`,
-`/llms.txt`, `/llms-full.txt`, `/sitemap.xml` (rebuilt at most hourly, members-only spaces and
-threads never in it), `/s/feed.xml` and `/s/:space/feed.xml` come from `server/discovery.js` and
-`openvibe-shared/seo`; with `INDEXNOW_KEY` set, a public, indexable page appearing, changing or going
-away pings the engines.
+Space holds **no user content**, so it consumes no network events and has nothing to export or erase:
+
+- **Account export and deletion are gone.** Space's former export/deletion hooks (`network.account.export_requested`,
+ `network.account.deleted`) only ever touched the **forum tables** — threads, posts, attachments, votes,
+ reactions, the moderator list. Those tables' rows are the forum's, and the forum is
+ OpenVibe.Community's, so **the forum's account data is Community's** and Space's hooks were removed.
+- **Platform blocks, subject merges and VIP membership changes are gone** for the same reason: they only
+ updated forum projections.
+- The **token-cutoff projection** (`network.user.token_valid_after`) went with them: without signed-in
+ content there is nothing here for a cutoff to protect.
+
+The forum tables stay in Space's database (migrations `0001_initial.sql` onward still create them).
+This release writes **no drop migration**; the rows are Community's, and dropping them is a later
+decision.
## Configuration
## Configuration
-Copy [.env.example](.env.example) to `.env` (or `/etc/openvibe/space.env` in production, 0600).
-Everything has a working default except the OAuth client secret and the database. The keys that
-change behaviour:
-
-- `PORT` (4940), `HOST` (127.0.0.1), `BASE_URL`, `TRUST_PROXY` (2: Cloudflare → nginx → Node)
-- `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID` (`space`),
- `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`
-- `DATABASE_URL` (PgBouncer) and `DATABASE_DIRECT_URL` (the owner, migrations); without them,
- development uses an embedded PGlite database in `data/pglite` (`SPACE_PGLITE_DIR` overrides the path)
-- `VALKEY_URL` / `VALKEY_PREFIX` (`ov:space:`) — shared per-actor counters
-- `OV_MEDIA_URL` / `OV_MEDIA_INTERNAL_URL`, `OV_VIP_URL` / `OV_VIP_INTERNAL_URL`,
- `OV_CHAT_URL` / `OV_CHAT_INTERNAL_URL`, `OV_COMMUNITY_URL`
-- `EVENTS_URL`, `SPACE_EVENTS_SECRET`, `INDEXNOW_KEY`
-- `DISCORD_RELAY_*`, `DISCORD_BOT_TOKEN`, `DISCORD_GATEWAY_URL`
+Copy `.env.example` to `.env` (production: `/etc/openvibe/space.env`, mode 0600).
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `PORT` / `HOST` | `4940` / `127.0.0.1` | Listen address (nginx in front) |
+| `BASE_URL` | `https://openvibe.space` in production | Canonical origin |
+| `TRUST_PROXY` | `2` | X-Forwarded-For hops (Cloudflare → nginx → Node) |
+| `OV_NETWORK_URL` | `https://openvibe.network` | Issuer / authorize URL / JWKS |
+| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Token grants, JWKS (tried first) |
+| `OV_OAUTH_CLIENT_ID` | `space` | Registered on the Network |
+| `OV_OAUTH_CLIENT_SECRET` | — | **Required** for sign-in |
+| `OV_OAUTH_REDIRECT_URI` | `https://openvibe.space/auth/callback` | Must match the registration |
+| `OV_COMMUNITY_URL` | `https://openvibe.community` | Where every forum URL redirects |
+| `OV_LIVE_URL` | `https://openvibe.live` | Legal pages the shared footer links |
+| `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 space`) |
## Run
## Run
-```sh
-npm ci
-cp .env.example .env # fill in OV_OAUTH_CLIENT_SECRET; without DATABASE_URL it uses PGlite
-npm run dev # http://localhost:4940
-npm test # every test in test/ (PGlite)
-npm run test:pg # the same, on PostgreSQL + PgBouncer + Valkey (openvibe-sdk scripts/test-services.sh)
+```
+npm install
+npm start # http://127.0.0.1:4940
+npm test # boots the Network mock in-process; no network needed
```
```
## Acceptance
## Acceptance
-- `npm test` green: the forum rules and API, SSR, the two styles (feed/forum), per-space moderators,
- members-only gating, chat rooms, attachments, the safe Markdown renderer, votes under race,
- categories and the roadmap, the Discord relay (outbound, inbound through a fake gateway, and the
- Events worker), and `test/boot.test.js` — the real process on a fresh database: `/api/ready` 2xx,
- `GET /s` renders, SIGTERM stops it cleanly.
-- `openvibe-contracts-check` for `space` in CI, and no `community.` id anywhere under `server/`.
+`npm test` runs every `test/*.test.js`:
+
+- `home` — the home page says what Space will host and links the forum on Community; discovery, health,
+ readiness and sign-in answer; an unknown path is an honest 404;
+- `redirects` — `/s`, `/s/*` (301 for GET/HEAD, 308 otherwise) and the forum APIs (308) redirect to the
+ same path and query on openvibe.community, and nothing else redirects;
+- `boot` — the real process on a fresh PGlite database: migrations run, `/api/ready` is 2xx, the home
+ page renders, the forum URLs redirect, and SIGTERM stops it cleanly.
## Security
## Security
-- [SECURITY.md](SECURITY.md) — how to report a vulnerability.
-- Identity never comes from a request body or query: a service token is judged on the token, a bad
- one is refused rather than downgraded to anonymous, and a first-party service acting for someone
- must name a subject the token allows.
-- Post bodies are rendered by the safe Markdown renderer; every other user value is escaped.
-- The relay never stores a webhook URL (only the name of the environment variable holding it), and
- `/internal/events` refuses any request that came through a proxy.
-- Per-address and per-actor limits on every API surface, plus the per-person write limits.
+Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
+
+- **Auth.** Browsers use Network JWTs (the `ov_token` cookie); the session layer verifies them offline
+ against the Network's JWKS. The OAuth `state` cookie is required, and post-auth targets are restricted
+ to this site or `https://openvibe.network/…`.
+- **Egress.** Space calls only its configured Network host.
+- **Secrets.** `OV_OAUTH_CLIENT_SECRET` lives in `/etc/openvibe/space.env` (0600), by name only.
## Deploy
## Deploy
-`deploy/systemd/openvibe-space.service` (unit `openvibe-space`, `127.0.0.1:4940`,
-`WorkingDirectory=/opt/openvibe.space`, `EnvironmentFile=/etc/openvibe/space.env`) and
-`deploy/nginx/openvibe.space.conf` (TLS, www → apex, the auth and API rate limits, `/metrics`
-refused from the edge). The database is OpenVibe.Host's `add-service.sh space`
-(`ov_space` on the data role). Deployed with the host's `ovhost deploy space`.
+```
+/opt/openvibe.space # git checkout, `npm ci --omit=dev`
+/etc/openvibe/space.env # secrets (0600)
+deploy/systemd/openvibe-space.service # → /etc/systemd/system/, User=ubuntu, port 4940
+deploy/nginx/openvibe.space.conf # → /etc/nginx/sites-available/, TLS from
+ # /etc/letsencrypt/live/openvibe.space/
+```
+
+The database is `ov_space` on the host's data role. Rollback: ovhost puts the previous sha back when
+`/api/ready` does not answer 2xx after the restart.
## Layout
## Layout
```
```
server/
server/
- index.js, app.js process entry and the Express app
- config.js, db.js configuration and the PostgreSQL handle (migrations at boot)
- events.js Space → OpenVibe.Events (the outbox)
- events-consumer.js OpenVibe.Events → Space (blocks, accounts, cutoffs, VIP)
- observability.js readiness for GET /api/ready
- auth/routes.js the OAuth2 client session (Network)
- identity/ subjects, capabilities, authors, blocks, projection, account data, merge
- forum/ store (SQL), service (rules), api (JSON), routes (pages/forms), reactions, roadmap
- relay/ discord (outbound), events-worker, discord-gateway + inbound, api
- render/ layout, pages, forum + board (the two styles), markdown, highlight, jsonld
- http/v1.js problems, capability guards, cursors, CORS
- limits.js, actor-limits.js, votes.js, chat-rooms.js, media/, vip/, discovery.js
-migrations/0001_initial.sql the whole schema (forum, relay, blocks, accounts, outbox, inbox)
-public/ space.css, space.js, the app icons and og-default.png
-test/ the suites (test/run.js runs them) and their mocks
-deploy/ systemd unit and nginx vhost
-docs/roadmap/public.json the Roadmap space's source (synced at boot)
+ index.js process entry (listen, graceful stop)
+ app.js Express app factory: redirects, home page, sign-in, discovery, health
+ config.js env → config
+ db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot
+ discovery.js robots disallow set, llms sections, the sitemap (the home page alone)
+ observability.js truthful /api/ready (the database is required; the Network key degrades)
+ auth/routes.js OAuth2 client (login/callback/fedcm/logout/me/refresh)
+ render/layout.js page shell (openvibe-shared/shell) and the redirect/site constants
+ render/pages.js the home page and the error pages
+public/ css/space.css, assets/ (logo, og-default), manifest.webmanifest
+migrations/ the shared schema, including the forum tables Space no longer serves
+deploy/ systemd unit, nginx vhost
+test/ run.js + home/redirects/boot tests
```
```
## What is next
## What is next
-- **The data cutover** (plan T10 step 4, not this repository's job): move Community's forum tables
- into `ov_space`, 301 `/s/*` on Community to Space, drop Community's forum tables and the
- deprecated `community.*` forum capabilities, and remove the Sites placeholder for openvibe.space.
-- **Contracts**: v0.110.0 registers Space live and its capabilities active, and publishes the
- `space.thread.created` / `space.post.created` / `space.moderation.action` event payloads, so the
- outbox only waits on `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET`; `space.thread.write` and
- `space.pulse.read` stay planned (`space.post.write` carries thread writes, Community serves Pulse).
-- **Search**: Space pushes no Search documents yet (there is no `space.index_document.*` contract),
- so `/search` on every site does not see forum threads; there is no `/search` page here.
-- **Pulse**: forum activity no longer publishes to the network Pulse feed. Restoring it belongs to
- Community's side (its consumer subscribing to `space.thread.*`) or to a `community.pulse.write`
- grant Space could use.
-- **Paste cards on posts**: Community's forum let a post attach pastes; pastes are Community's, so
- Space posts do not carry them. If the feature is wanted, it needs a cross-service read of
- Community's paste metadata (a decision, not code that exists).
+Code hosting (repositories, releases) and dynamic pages, then "spaces": apps people build on their
+OpenVibe account, the network's services and the SDK.
## Related services
## Related services
-- **OpenVibe.Community** (https://openvibe.community) — the hub: pastes, comment threads, Pulse, submissions
-- **OpenVibe.Network** (https://openvibe.network) — identity, SSO and the shared chrome
-- **OpenVibe.Media** (https://openvibe.media) — objects and image bytes
-- **OpenVibe.VIP** (https://openvibe.vip) — memberships and the members-only gate
-- **OpenVibe.Chat** (https://openvibe.chat) — a space's chat room
-- **OpenVibe.Events** (https://openvibe.events) — the events fabric
+- Identity/SSO: https://openvibe.network (OpenVibers/OpenVibe.Network)
+- The forum and the hub: https://openvibe.community (OpenVibers/OpenVibe.Community)
+- Streaming: https://openvibe.live (OpenVibers/OpenVibe.Live)
+- Tools: https://openvibe.tools (OpenVibers/OpenVibe.Tools)
+- Media: https://openvibe.media (OpenVibers/OpenVibe.Media)
## License
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.112.0
+- openvibe-contracts: v0.118.0
- openvibe-sdk: v0.35.0
- openvibe-sdk: v0.35.0
- openvibe-shared: v2.15.0
- openvibe-shared: v2.15.0
<!-- versions:end -->
<!-- versions:end -->
@@ -2,42 +2,30 @@
@@ -2,42 +2,30 @@
"repository": "OpenVibers/OpenVibe.Space",
"repository": "OpenVibers/OpenVibe.Space",
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
- "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19), T10 step 3 — the forum moved off OpenVibe.Community",
- "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "plan": "OpenVibe Updated Plan Based On Codebase (T0–T19) — the forum moved back to OpenVibe.Community (owner decision, 2026-10-08); Space becomes code hosting and spaces",
+ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": 4940,
"port": 4940,
"domain": "openvibe.space",
"domain": "openvibe.space",
- "contracts": "openvibe-contracts v0.112.0",
+ "contracts": "openvibe-contracts v0.118.0",
"sdk": "openvibe-sdk v0.35.0",
"sdk": "openvibe-sdk v0.35.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
- "capabilities": [
- "space.forum.read",
- "space.forum.manage",
- "space.thread.read",
- "space.post.write"
- ],
- "capabilitiesRegistered": true,
+ "capabilities": [],
+ "capabilitiesRegistered": false,
"deployed": false,
"deployed": false,
- "deployment": "Not deployed yet (T10 step 3 built the service; step 4 is the cutover). The Host inventory entry, the deploy recipe, the Contracts flip (space.json status/exposure live, space.* activated) and the Sites placeholder removal for openvibe.space are the deploy job's. Unit deploy/systemd/openvibe-space.service on 127.0.0.1:4940, vhost deploy/nginx/openvibe.space.conf, database ov_space via OpenVibe.Host roles/data add-service.sh space. Production's forum tables in ov_community are empty except the migration seeds, so the data cutover moves only the schema's rows.",
+ "deployment": "Not deployed: Space publishes nothing yet. Unit deploy/systemd/openvibe-space.service on 127.0.0.1:4940, vhost deploy/nginx/openvibe.space.conf, database ov_space via OpenVibe.Host roles/data add-service.sh space. Every forum URL (/s and /s/*, /api/v1/spaces|posts|space-groups|relay) answers a permanent redirect to the same path and query on https://openvibe.community. The forum tables stay in ov_space; this release writes no drop migration.",
"features": [
"features": [
- "spaces: public/members/staff visibility, feed and forum styles, groups, child boards, per-space settings",
- "threads and posts: Markdown (safe renderer), edits with versions, soft deletes, pins and locks",
- "thread votes, Facepunch-style post ratings, categories, statuses, crossposts and quotes",
- "per-space moderators (space_moderators), addable by @username through the Network",
- "image attachments as OpenVibe.Media objects (med_), at most four per post",
- "members-only spaces and threads gated by OpenVibe.VIP (fail closed)",
- "a space's chat room on OpenVibe.Chat, attached with the person's own token",
- "the Discord relay: outbound webhooks, the Events worker, and inbound through the gateway",
- "blocks, token cutoffs, account export/deletion and subject merge from Network events",
- "service capabilities space.forum.read/manage, space.thread.read, space.post.write",
- "server-rendered pages with full SEO (robots, llms.txt, sitemap, RSS, IndexNow)",
- "per-actor limits (openvibe-sdk/limits) and per-person write limits",
+ "the home page: what Space will host (code, dynamic pages, \"spaces\" apps) and where the forum went",
+ "permanent forum redirects: /s and /s/* (301 GET/HEAD, 308 otherwise) and the forum APIs (308) to openvibe.community",
+ "sign-in: the Network OAuth2 client `space` (login, callback, FedCM, logout, me, refresh)",
+ "discovery: robots.txt, llms.txt, llms-full.txt, sitemap.xml (the home page alone)",
+ "health and release: /api/health, /api/ready, /release.json, /metrics (loopback)",
"the process boots on a fresh database and stops cleanly (test/boot.test.js)"
"the process boots on a fresh database and stops cleanly (test/boot.test.js)"
],
],
"knownGaps": [
"knownGaps": [
- "space.thread.write and space.pulse.read stay 'planned' in openvibe-contracts and Space serves neither; space.post.write carries thread and reply writes and community serves the network Pulse feed",
- "no Search documents (no space.index_document.* contract) and no /search page",
- "the network Pulse feed is Community's: Space publishes no Pulse item and renders no pulse page",
- "paste cards on forum posts were dropped with the move (pastes are Community's)"
+ "code hosting and dynamic pages are not built yet, and no \"spaces\" apps run here; the home page says so",
+ "openvibe-contracts still registers space.forum.read/manage, space.thread.read and space.post.write for Space; the Contracts update that retires or reassigns them is a separate change",
+ "the forum tables remain in ov_space (migrations 0001+ still create them) with their rows; no drop migration in this release",
+ "Space consumes no network events and has no account export/deletion: it holds no user content, and the forum's account data is Community's"
],
],
"updated": "2026-10-08"
"updated": "2026-10-08"
}
}
@@ -4,11 +4,12 @@
@@ -4,11 +4,12 @@
## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf
## This file is what is deployed to /etc/nginx/sites-available/openvibe.space.conf
## — keep them in sync, and reload nginx after changing it.
## — keep them in sync, and reload nginx after changing it.
##
##
-## The Node app (port 4940, systemd unit openvibe-space) renders every page:
-## the board index and the spaces at /s/*, the JSON API at /api/v1/spaces|posts|space-groups|relay,
-## and the internal Events delivery route at /internal/events (loopback only — it refuses forwarded
-## requests itself). nginx terminates TLS, redirects www → apex, rate-limits the auth and API paths,
-## and compresses text.
+## The Node app (port 4940, systemd unit openvibe-space) renders the site: the home page, sign-in
+## (/auth/*), the discovery files (/robots.txt, /llms.txt, /sitemap.xml) and health/readiness.
+## The forum is OpenVibe.Community's again (owner decision, 2026-10-08): Node answers every forum URL
+## — /s and /s/*, /api/v1/spaces|posts|space-groups|relay — with a permanent redirect to the same path
+## there, so nginx just proxies. nginx terminates TLS, redirects www → apex, rate-limits the auth and
+## API paths, and compresses text.
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=ovspace_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovspace_api:10m rate=10r/s;
@@ -52,7 +53,6 @@ server {
@@ -52,7 +53,6 @@ server {
access_log /var/log/nginx/openvibe.space.access.log;
access_log /var/log/nginx/openvibe.space.access.log;
error_log /var/log/nginx/openvibe.space.error.log;
error_log /var/log/nginx/openvibe.space.error.log;
- # Post bodies are Markdown and nothing else; images go to OpenVibe.Media, never through here.
client_max_body_size 1m;
client_max_body_size 1m;
add_header X-Content-Type-Options nosniff always;
add_header X-Content-Type-Options nosniff always;
@@ -96,23 +96,9 @@ server {
@@ -96,23 +96,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- # ── Image uploads (multipart, streamed on to OpenVibe.Media) ──
- location ~ ^/api/v1/spaces/[^/]+/attachments$ {
- limit_req zone=ovspace_api burst=5 nodelay;
- client_max_body_size 9m;
- proxy_request_buffering off;
- proxy_read_timeout 120s;
- proxy_send_timeout 120s;
- proxy_pass http://127.0.0.1:4940;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $remote_addr;
- proxy_set_header CF-Connecting-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
# ── API (moderate rate limit) ───────────────────────────
# ── API (moderate rate limit) ───────────────────────────
+ # Space serves only /api/health and /api/ready here; the forum APIs are permanent redirects to
+ # OpenVibe.Community, and a redirected request should reach it with its method and body intact.
location /api/ {
location /api/ {
limit_req zone=ovspace_api burst=30 nodelay;
limit_req zone=ovspace_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4940;
proxy_pass http://127.0.0.1:4940;
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
[Unit]
[Unit]
-Description=OpenVibe.Space — the forums of the OpenVibe network
-# Network (identity) should be up first, but Space degrades gracefully if it is not: public pages and
-# reads still work, sign-in and signed-in writes answer 503 (server/observability.js).
-After=network.target postgresql.service pgbouncer.service valkey-server.service
-Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+Description=OpenVibe.Space — code and dynamic pages for the OpenVibe network
+# Network (identity) should be up first, but Space degrades gracefully if it is not: the home page and
+# the discovery files still work, sign-in is unavailable (server/observability.js).
+After=network.target postgresql.service pgbouncer.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service
[Service]
[Service]
Type=simple
Type=simple
deleted file mode 100644
deleted file mode 100644
@@ -1,164 +0,0 @@
@@ -1,164 +0,0 @@
-# Discord relay
-
-Space threads on Discord, and replies from Discord back as posts (roadmap WS-J tasks 5 and 6).
-Everything is **off by default** and stays inert until the owner provides webhooks, a mapping and,
-for the inbound half, a bot token.
-
-## What it does
-
-**Out** (`DISCORD_RELAY_ENABLED=true` plus a mapping):
-
-- A new thread in a mapped **public** space is announced through the mapping's Discord webhook:
- "New thread in s/<space> by <name>", the title, an excerpt of the opening post and the link.
-- Each reply to a thread that went to Discord follows it into the same channel (or into the
- Discord thread the mapping names), with who replied, an excerpt and the link to the post.
-- Editing the opening post edits the thread's Discord message; editing a reply edits its message.
-- Deleting a reply or a thread (by its author or a moderator), or making a thread or space
- members-only (VIP), deletes the Discord messages the relay had sent for it. Anything still
- waiting to be sent is skipped.
-- Mentions are disabled on every message (`allowed_mentions.parse = []`); names and titles are
- escaped, so nothing relayed can ping anyone.
-
-**In** (`DISCORD_RELAY_INBOUND=on`, `DISCORD_BOT_TOKEN`, and `inbound: true` on the mapping):
-
-- A reply on Discord to a relayed message (Discord's Reply), or any message in a Discord thread
- started from a relayed message, becomes a post in that Space thread. Other messages in
- the channel are ignored.
-- The post has origin `discord`, belongs to nobody on the site (no author subject) and shows the
- Discord display name with a "Discord" badge. It is never relayed back out.
-- Edits on Discord edit the post (a new revision, `edited_by` `discord`); deletes on Discord
- delete it. Deleting the relay's own announcement on Discord only marks it gone; the thread stays.
-- The thread must still be public, open (not locked) and not members-only; otherwise the
- message is refused and the reason is recorded for staff.
-- Limits: `DISCORD_RELAY_INBOUND_PER_MINUTE` (6) messages a minute per Discord author per
- mapping, ten times that per mapping; `DISCORD_RELAY_INBOUND_MAX_CHARS` (4000) per message,
- longer text is cut. User, role and channel mentions, custom emoji and timestamps become plain
- text, `@everyone`/`@here` are defused, attachments are noted ("2 attachments on Discord") but
- never fetched. The rest is Markdown, which the forum's safe renderer shows (no raw HTML).
-
-## How it works
-
-| Piece | File | Notes |
-| --- | --- | --- |
-| Queue and sender | `server/relay/discord.js` | `relay_deliveries`: one row per dedupe key, actions `create`, `edit`, `delete`. Creates are sent with `?wait=true`, so Discord answers with the message and its id goes into the map. |
-| External message map (task 5) | `relay_message_map` | Every Discord message the relay knows, both ways (`out`: a thread or reply it sent; `in`: a reply that came from Discord). Unique both ways: a Discord message is one local object, and a local object is one message per mapping. Edits and deletes on either side find their counterpart here. |
-| Events worker (task 6) | `server/relay/events-worker.js` | Reads Space's own `space.thread.*` and `space.post.*` from OpenVibe.Events (`GET /api/v1/events`, capability `events.event.read`) and queues the creates. Its cursor (`relay_cursors`) moves in the same transaction as the deliveries a page queued; a replay queues nothing twice (dedupe keys), and nothing already in the map is sent twice. The first start begins at Events' head: older threads are never announced. |
-| Gateway | `server/relay/discord-gateway.js` | Discord gateway v10 over Node 22's built-in WebSocket: IDENTIFY/RESUME, heartbeats (a missing ACK means a zombied connection: resume), RECONNECT, INVALID_SESSION, backoff. Intents `GUILD_MESSAGES` and `MESSAGE_CONTENT`. |
-| Inbound | `server/relay/inbound.js` | `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `MESSAGE_DELETE_BULK` for mapped channels only. |
-| Staff API | `server/relay/api.js` | `/api/v1/relay/*`, below. |
-
-When the Events worker is on, it is the one path for creates (the forum's own calls queue none).
-Without `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET`, or with `DISCORD_RELAY_EVENTS=off`, the forum
-queues creates itself, as the first relay did. Edits and deletes are always queued by the forum:
-Contracts has no `space.thread`/`space.post` update or delete events yet.
-
-**Loop prevention**, both halves: a thread or post with origin `discord` is never queued or sent
-out (checked when queueing and again when sending); inbound ignores webhook messages (the
-relay's own among them), bots, the bot itself and system messages, and never takes a message
-that is already in the map.
-
-**Retries and the dead letter**: network errors, timeouts, 5xx, 429 (its `retry_after`
-honoured; the rest of that webhook's queue waits for the next pass) and an unset webhook variable
-are retried with exponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour)
-up to `DISCORD_RELAY_MAX_ATTEMPTS`. Then the delivery is `failed`: the dead letter, kept with its
-error, that staff retry or drop. Other 4xx fail at once. A delete that finds the message already
-gone on Discord counts as delivered; an edit of a message that is gone is skipped.
-
-**Secrets never live in the database**: a mapping names the environment variable holding the
-webhook URL (`webhook_url_ref`), and only allow-listed names (`DISCORD_WEBHOOK_*`, or exactly
-`DISCORD_RELAY_WEBHOOK_VARS`) can be mapped. No API response carries a webhook URL or the bot token.
-
-## Staff API
-
-Staff only: an admin/global_mod browser session, or a service holding `space.forum.manage`.
-
-| Route | What |
-| --- | --- |
-| `GET /api/v1/relay/status` | The queue by status (`failed` = dead letters), the Events worker (cursor, latest seq, lag, last error, last gap), the gateway (state, reconnects, last error) and open inbound failures |
-| `GET /api/v1/relay/deliveries?status=&action=&limit=` | Deliveries; `status` pending, delivered, failed, dropped or skipped; `action` create, edit or delete |
-| `POST /api/v1/relay/deliveries/:id/retry` | Queue it again now, with a fresh attempt budget |
-| `POST /api/v1/relay/deliveries/:id/drop` | Give up on a pending or failed one (kept as `dropped`) |
-| `GET /api/v1/relay/inbound?all=1` | Messages from Discord that did not become (or change) a post, and why: ids only, never the text |
-| `POST /api/v1/relay/inbound/:id/dismiss` | Mark one seen |
-| `GET /api/v1/relay/mappings` | Mappings: space, webhook variable (and whether it is set), Discord channel and thread ids, inbound |
-| `POST /api/v1/relay/mappings` | `{ space, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? }` |
-| `PUT /api/v1/relay/mappings/:id` | `{ enabled?, inbound?, discord_channel_id?, discord_thread_id? }` (`null` clears an id) |
-
-`GET /api/ready` shows the same summary under `discord_relay` while the relay is on.
-
-## Owner steps
-
-1. **Webhook per channel** (outbound). In Discord: the channel's settings → Integrations →
- Webhooks → New Webhook, name it (for example "OpenVibe.Space"), Copy Webhook URL. You need
- Manage Webhooks on that channel to do this; the bot does not.
-2. **The bot** (inbound only). At <https://discord.com/developers/applications>: New Application →
- Bot → Reset Token (this is `DISCORD_BOT_TOKEN`). Under Privileged Gateway Intents turn on
- **Message Content Intent** (Presence and Server Members stay off). Invite it: OAuth2 → URL
- Generator → scope `bot`, permissions **View Channels** and **Read Message History** (permission
- integer `66560`). It sends nothing itself (the webhook does), so leave Send Messages and Manage
- Webhooks off. It must be able to see each mapped channel, and threads in it (public threads only).
-3. **Environment** in `/etc/openvibe/space.env` (0600):
-
- ```sh
- DISCORD_RELAY_ENABLED=true
- DISCORD_WEBHOOK_GENERAL=https://discord.com/api/webhooks/<id>/<token> # one per channel, any DISCORD_WEBHOOK_* name
- # inbound
- DISCORD_RELAY_INBOUND=on
- DISCORD_BOT_TOKEN=<the bot token>
- ```
-
- `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are already set for the outbox, so the Events worker
- starts by itself. It needs the Network grant `space` → `events.event.read` on audience
- `openvibe.events` (in the Network's principal seed; check it is provisioned: a missing grant
- shows as a 403 in the worker's `last_error`).
- Then `sudo systemctl restart openvibe-space`. The boot log says
- `[Relay] Discord relay on: creates queued by the Events worker; events worker on; inbound on`.
-4. **Map a space**, signed in as an admin on openvibe.space, from the browser console:
-
- ```js
- await (await fetch('/api/v1/relay/mappings', { method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true }) })).json();
- ```
-
- `discord_channel_id` is learned from Discord's answer to the first message; set it yourself
- (Discord: Settings → Advanced → Developer Mode, then right-click the channel → Copy Channel ID)
- if you want replies accepted before anything was sent. To post into one Discord thread instead
- of the channel, add `discord_thread_id`. Only public spaces are ever relayed.
-5. **One production round trip**: start a thread in the mapped space → it appears on Discord;
- reply to it on Discord (Reply, or in a thread on the message) → the reply appears in the
- thread with a Discord badge; edit and delete it on Discord → the post follows; reply and edit
- on the site → Discord follows; delete the thread → its messages leave Discord. Watch
- `GET /api/v1/relay/status` (no dead letters, lag 0, gateway `ready`).
-
-**Off again**: unset `DISCORD_RELAY_ENABLED` and restart (nothing is sent or read; the tables stay),
-or per mapping `PUT /api/v1/relay/mappings/:id { "enabled": false }` (deletes still go out) or
-`{ "inbound": false }`. Unset `DISCORD_RELAY_INBOUND` to close the gateway only.
-
-## Configuration
-
-| Variable | Default | What |
-| --- | --- | --- |
-| `DISCORD_RELAY_ENABLED` | off | The relay at all |
-| `DISCORD_WEBHOOK_*` | — | Webhook URLs, named by mappings |
-| `DISCORD_RELAY_WEBHOOK_VARS` | — | Exact variable names mappings may use (instead of `DISCORD_WEBHOOK_*`) |
-| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Sender cadence, first retry delay, attempts before the dead letter |
-| `DISCORD_RELAY_EVENTS` | on | `off` leaves creates to the forum instead of the Events worker |
-| `DISCORD_RELAY_EVENTS_POLL_MS` | `5000` | How often the worker reads Events |
-| `EVENTS_URL`, `OV_OAUTH_CLIENT_SECRET` | — | Events' address and Space's client secret (shared with the outbox) |
-| `DISCORD_RELAY_INBOUND` | off | The gateway |
-| `DISCORD_BOT_TOKEN` | — | The bot's token (inbound only) |
-| `DISCORD_GATEWAY_URL` | `wss://gateway.discord.gg/?v=10&encoding=json` | The gateway |
-| `DISCORD_RELAY_INBOUND_PER_MINUTE` | `6` | Messages a minute per Discord author per mapping (10× per mapping) |
-| `DISCORD_RELAY_INBOUND_MAX_CHARS` | `4000` | Longest message taken in; longer is cut |
-
-## Known limits
-
-- Messages sent on Discord while Space is restarting (or the gateway is down longer than a
- resume allows) are not replayed: a new session starts from now.
-- Private Discord threads, attachments (either way), reactions and Discord-side thread titles are
- not relayed.
-- Threads created before the Events worker's first start are never announced, and replies go
- out only for threads that went out through that mapping.
-- Edits made by the Roadmap sync (`docs/roadmap/public.json`) are not propagated to Discord.
-- A retention gap in Events (the worker fell further behind than Events keeps) skips the creates
- in it; the gap is logged and shown in the status.
deleted file mode 100644
deleted file mode 100644
@@ -1,41 +0,0 @@
@@ -1,41 +0,0 @@
-{
- "about": "OpenVibe's public roadmap: the plain-language summary of what is being built next, synced into the Roadmap space on openvibe.space at every boot (server/forum/roadmap.js). Edit this file to change the roadmap; each item keeps its thread (by key), so replies and votes stay with it.",
- "items": [
- { "key": "openvibe-chat", "category": "launches", "status": "done", "title": "OpenVibe.Chat, on its own site",
- "summary": "Global chat, direct messages, rooms and your chat settings at [openvibe.chat](https://openvibe.chat), working with or without JavaScript and even while Live is down. Opened on 24 September 2026; rooms followed on 25 September." },
- { "key": "openre-stream", "category": "launches", "status": "in_progress", "title": "OpenRe.Stream: stream once, restream everywhere",
- "summary": "Send your stream to one ingest and let OpenRe.Stream forward it to every platform, with keys, destinations and output health in one place. The service is running; moving Live's stream slots onto it needs the new ingest address and a short switch-over window for each broadcaster." },
- { "key": "openvibe-tips-vip", "category": "launches", "status": "planned", "title": "OpenVibe.Tips and OpenVibe.VIP",
- "summary": "Tips, goals and paid messages for creators, and creator memberships with perks recognised on every site. Both are built; they open once all money moves through the network's billing ledger, so every tip and membership is recorded exactly once." },
- { "key": "openvibe-news-reviews", "category": "launches", "status": "planned", "title": "OpenVibe.News and OpenVibe.Reviews",
- "summary": "Source-backed stories with citations and several perspectives, and reviews gathered with their sources. They open when their source lists have been reviewed: nothing is published without a source." },
- { "key": "openvibe-deals-coupons", "category": "launches", "status": "planned", "title": "OpenVibe.Deals and OpenVibe.Coupons",
- "summary": "Community deals with the source, price and freshness always shown, and coupon codes checked by the people who use them." },
- { "key": "openvibe-trade", "category": "launches", "status": "planned", "title": "OpenVibe.Trade: watchlists and alerts",
- "summary": "Watchlists, sourced market context and alerts. Information only: no custody and no orders." },
- { "key": "openvibe-host", "category": "launches", "status": "planned", "title": "OpenVibe.Host",
- "summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },
- { "key": "chat-calls", "category": "features", "status": "planned", "title": "Voice and video calls in OpenVibe.Chat",
- "summary": "Calls move from Live into OpenVibe.Chat, so rooms and direct messages can turn into a call on any site." },
- { "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts",
- "summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },
- { "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",
- "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },
- { "key": "forum-boards", "category": "features", "status": "done", "title": "Forum boards, ratings and pastes in posts",
- "summary": "Spaces can be classic forum boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },
- { "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
- "summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },
- { "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",
- "summary": "Set a style, a length and a perspective once on your account, and OpenVibe AI follows them whenever it writes something for you. Turn off \"Keep what I ask AI\" and it keeps neither your request nor a reusable copy of the answer." },
- { "key": "favourite-tools", "category": "features", "status": "done", "title": "Favourite tools on every device",
- "summary": "Star a tool on openvibe.tools or on your account page, and it leads your tools everywhere you sign in." },
- { "key": "sign-out-everywhere", "category": "platform", "status": "done", "title": "Sign out everywhere means everywhere",
- "summary": "Signing out everywhere, changing your password or a ban now reaches every OpenVibe site within seconds: older sign-ins stop working and open chat and game connections close." },
- { "key": "channel-renames", "category": "platform", "status": "done", "title": "Rename your channel without breaking links",
- "summary": "When you change your username, links to your old channel address forward to the new one, and nobody else can take your old name for six months." },
- { "key": "media-reliability", "category": "platform", "status": "in_progress", "title": "Every VOD and clip checked and recoverable",
- "summary": "Recordings and clips are stored in more than one place and checked regularly. A video now plays only once a stored copy has been checked, holds keep evidence from being deleted or moved (clips follow their recording), and a monthly report lists stray files without touching them. Still to come: splitting very long recordings into parts, and repairing the few copies known to be damaged." },
- { "key": "discord-relay", "category": "platform", "status": "planned", "title": "Community threads on Discord, and back",
- "summary": "New threads are already announced on Discord. Next: replies both ways, edits and deletions kept in step, with no loops." }
- ]
-}
@@ -14,10 +14,7 @@
@@ -14,10 +14,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "highlight.js": "^11.11.1",
- "iovalkey": "^0.4.0",
- "multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -40,7 +37,9 @@
@@ -40,7 +37,9 @@
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
@@ -88,12 +87,6 @@
@@ -88,12 +87,6 @@
}
}
}
}
},
},
- "node_modules/append-field": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
- "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
- "license": "MIT"
- },
"node_modules/array-flatten": {
"node_modules/array-flatten": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -130,17 +123,6 @@
@@ -130,17 +123,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
"node_modules/bytes": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -184,6 +166,8 @@
@@ -184,6 +166,8 @@
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
@@ -251,6 +235,8 @@
@@ -251,6 +235,8 @@
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=0.10"
"node": ">=0.10"
}
}
@@ -582,15 +568,6 @@
@@ -582,15 +568,6 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
- "node_modules/highlight.js": {
- "version": "11.12.0",
- "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
- "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=12.0.0"
- }
- },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -634,6 +611,8 @@
@@ -634,6 +611,8 @@
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
"integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
"integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"@iovalkey/commands": "^0.1.0",
"@iovalkey/commands": "^0.1.0",
"cluster-key-slot": "^1.1.0",
"cluster-key-slot": "^1.1.0",
@@ -654,6 +633,8 @@
@@ -654,6 +633,8 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"ms": "^2.1.3"
"ms": "^2.1.3"
},
},
@@ -670,7 +651,9 @@
@@ -670,7 +651,9 @@
"version": "2.1.3",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
@@ -740,7 +723,9 @@
@@ -740,7 +723,9 @@
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
@@ -752,7 +737,9 @@
@@ -752,7 +737,9 @@
"version": "3.1.0",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
@@ -865,24 +852,6 @@
@@ -865,24 +852,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT"
},
},
- "node_modules/multer": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
- "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
- "license": "MIT",
- "dependencies": {
- "append-field": "^1.0.0",
- "busboy": "^1.6.0",
- "type-is": "^1.6.18"
- },
- "engines": {
- "node": ">= 10.16.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -917,9 +886,9 @@
@@ -917,9 +886,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.112.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",
+ "version": "0.118.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
+ "integrity": "sha512-vd5OzSIGhGB9ezy/SDHolji+jCJIxp1SmaKkE01KbiManwOnMdM6FOX3LIEVldXCBTjWH77iMkqB5sCxeY+qiQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1178,6 +1147,8 @@
@@ -1178,6 +1147,8 @@
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=4"
"node": ">=4"
}
}
@@ -1187,6 +1158,8 @@
@@ -1187,6 +1158,8 @@
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"redis-errors": "^1.0.0"
"redis-errors": "^1.0.0"
},
},
@@ -1377,7 +1350,9 @@
@@ -1377,7 +1350,9 @@
"version": "2.1.0",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
@@ -1388,14 +1363,6 @@
@@ -1388,14 +1363,6 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-space",
"name": "openvibe-space",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Space — the forums of the OpenVibe network: public and members-only spaces, threads, posts, votes, categories, per-space moderators, a space's chat room on OpenVibe.Chat and the Discord relay. The forum engine moved here from OpenVibe.Community (plan T10 step 3); identity stays on OpenVibe.Network, images on OpenVibe.Media, memberships on OpenVibe.VIP.",
+ "description": "OpenVibe.Space — code and dynamic pages for the OpenVibe network, and \"spaces\": apps people build that use their OpenVibe account, the network's services and the SDK. The forum moved back to OpenVibe.Community (owner decision, 2026-10-08); every forum URL here redirects there.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -13,10 +13,9 @@
@@ -13,10 +13,9 @@
"keywords": [
"keywords": [
"openvibe",
"openvibe",
"space",
"space",
- "forum",
- "forums",
- "boards",
- "community"
+ "code",
+ "pages",
+ "apps"
],
],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
@@ -27,10 +26,7 @@
@@ -27,10 +26,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "highlight.js": "^11.11.1",
- "iovalkey": "^0.4.0",
- "multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1,262 +1,51 @@
@@ -1,262 +1,51 @@
-/* OpenVibe.Space — small, theme-following stylesheet.
- Colors come from the shared theme tokens set by theme-loader.js; the fallbacks are the
- network's default (blue "vibe") palette so the page looks right before it loads. */
-:root {
- --bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;
- --text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;
- --accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;
- --border: #1f2d47; --border-light: #2c3d5c;
- --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;
- --shadow: 0 2px 12px rgba(0,0,0,0.45); --shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
- --radius: 12px; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
-}
-* { box-sizing: border-box; }
-html { color-scheme: var(--color-scheme, dark); scroll-behavior: smooth; }
-body { margin: 0; background: var(--bg-primary); color: var(--text-primary); font: 15px/1.55 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
-a { color: var(--accent-light); text-decoration: none; }
-a:hover { text-decoration: underline; }
-h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 .4em; }
-h1 { font-size: clamp(1.7rem, 3.5vw, 2.4rem); }
-h2 { font-size: 1.35rem; }
-h3 { font-size: 1.05rem; }
-img { max-width: 100%; height: auto; }
-.page { max-width: 1120px; margin: 0 auto; padding: 24px 16px 48px; }
-.muted { color: var(--text-secondary); }
-.small { font-size: .85rem; }
-.empty { color: var(--text-muted); padding: 32px 0; text-align: center; }
-.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .75rem; font-weight: 700; color: var(--accent-light); margin: 0 0 8px; }
-.lede { font-size: 1.1rem; color: var(--text-secondary); max-width: 46rem; }
-
-/* Buttons */
-.btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-card); color: var(--text-primary); font: inherit; font-weight: 600; cursor: pointer; transition: border-color .15s, background .15s, transform .1s; }
-.btn:hover { border-color: var(--accent); text-decoration: none; background: var(--bg-hover); }
-.btn:active { transform: translateY(1px); }
-/* The derived pair reads at 4.5:1 in every theme (Shared 1.13.0); white on the accent did not. */
-.btn-primary { background: var(--accent-strong, #1d4ed8); border-color: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); }
-.btn-primary:hover { background: var(--accent-dark, var(--accent)); border-color: var(--accent-dark, var(--accent)); }
-.btn-ghost { background: transparent; }
-.btn-danger { border-color: var(--danger); color: var(--danger); }
-.btn-danger:hover { background: var(--danger); color: #fff; }
-.btn-sm { padding: 6px 11px; font-size: .85rem; border-radius: 7px; }
-.btn[disabled] { opacity: .6; cursor: wait; }
-
-/* Sections */
-.section { padding: 28px 0 8px; }
-.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
-.section-head h2 { margin: 0; }
-.more { font-size: .9rem; font-weight: 600; white-space: nowrap; }
-
-.author { display: inline-flex; align-items: center; gap: 6px; color: inherit; min-width: 0; }
-.author span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: none; }
-.avatar-letter { display: inline-grid; place-items: center; background: var(--accent); color: var(--on-accent); font-size: .7rem; font-weight: 700; }
-.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; border: 1px solid var(--border-light); color: var(--text-secondary); }
-a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-light); }
-.stat { display: inline-flex; align-items: center; gap: 4px; }
-
-.page-head { padding: 16px 0 8px; }
-.page-head h1 { margin-bottom: 4px; }
-.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 22px 0 0; }
-.pager a { font-weight: 600; }
-.pager-info { color: var(--text-muted); font-size: .9rem; }
-[data-results].loading { opacity: .5; pointer-events: none; }
-
-.crumbs { font-size: .85rem; color: var(--text-muted); margin: 4px 0 14px; }
-/* Underlined: a crumb link sits in muted text and must not be told apart by colour alone (axe link-in-text-block). */
-.crumbs a { text-decoration: underline; text-underline-offset: 2px; }
-.sep { color: var(--text-muted); }
-.actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
-
-/* Syntax colors follow the theme instead of shipping a fixed palette. */
-.hljs { color: var(--text-primary); }
-.hljs-comment, .hljs-quote, .hljs-meta { color: var(--text-muted); font-style: italic; }
-.hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-section, .hljs-name { color: var(--accent-light); font-weight: 600; }
-.hljs-string, .hljs-regexp, .hljs-addition, .hljs-template-tag { color: var(--success); }
-.hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--warning); }
-.hljs-title, .hljs-title.function_, .hljs-title.class_, .hljs-type, .hljs-built_in, .hljs-selector-id, .hljs-selector-class { color: var(--info); }
-.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-property, .hljs-params { color: var(--text-secondary); }
-.hljs-deletion { color: var(--danger); }
-.hljs-emphasis { font-style: italic; }
-.hljs-strong { font-weight: 700; }
+/* OpenVibe.Space — the home page and error pages. The shared Frame (openvibe-shared/shell) brings the
+ theme variables, navbar and footer; this is only what this site renders itself. */
-/* Forms */
-.post-form { display: flex; flex-direction: column; gap: 14px; max-width: 900px; }
-.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }
-.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }
-.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }
-.field textarea { font-family: var(--mono); font-size: 13px; line-height: 1.5; min-height: 160px; resize: vertical; }
-.field :focus { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 0; border-color: var(--accent); }
-.field-row { display: flex; flex-wrap: wrap; gap: 14px; }
-.check { display: inline-flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: .9rem; }
-.tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--border); }
-.tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-secondary); padding: 8px 12px; font: inherit; font-weight: 600; cursor: pointer; }
-.tab.active { color: var(--accent-light); border-bottom-color: var(--accent); }
-.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
-.form-status { min-height: 1.2em; margin: 0; color: var(--text-secondary); }
-.form-status.error { color: var(--danger); }
-.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
-.alert-error { border-color: var(--danger); color: var(--danger); }
-
-/* Spaces, threads, posts */
-.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
-.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
-.space-item h2 { margin: 0 0 4px; font-size: 1.15rem; }
-.space-item p { margin: 4px 0 0; }
-.space-link:hover { text-decoration: none; }
-.sort-tabs { margin: 8px 0 14px; }
-.sort-tabs .tab { text-decoration: none; }
-.thread-row { display: flex; gap: 14px; align-items: flex-start; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; }
-.thread-score { min-width: 2.6em; text-align: center; font-weight: 700; color: var(--text-secondary); padding-top: 2px; }
-.thread-main { min-width: 0; flex: 1; }
-.thread-title { font-weight: 600; color: var(--text-primary); word-break: break-word; }
-.thread-title i { color: var(--accent-light); font-size: .85em; }
-.thread-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin: 4px 0 0; font-size: .82rem; color: var(--text-secondary); }
-.badge-ai { border-color: var(--info); color: var(--info); }
-.badge-relay { border-color: var(--accent-light); color: var(--accent-light); }
-.badge-vis { font-size: .7rem; vertical-align: middle; }
-/* Categories, requests and the roadmap (forum pages) */
-.badge-status, .badge-category { font-size: .7rem; vertical-align: middle; }
-.badge-category { color: var(--text-secondary); }
-.badge-category:hover { border-color: var(--accent); text-decoration: none; }
-.status-planned, .status-open { border-color: var(--info); color: var(--info); }
-.status-in_progress { border-color: var(--warning); color: var(--warning); }
-.status-done { border-color: var(--success); color: var(--success); }
-.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }
-.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
-.post-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-top: 10px; }
-.post-images img { display: block; width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; border: 1px solid var(--border-light); background: var(--bg-secondary, transparent); }
-.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
-.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: .85rem; font-weight: 600; }
-.chip:hover { border-color: var(--accent); text-decoration: none; }
-.chip.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
-.chip.active .muted { color: inherit; opacity: .8; }
-/* The members-only (OpenVibe.VIP) teaser page and the badge that marks a gated space or thread. */
-.members-only { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 22px; margin: 18px 0; }
-.badge-vip { border-color: var(--warning); color: var(--warning); }
-.inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
-.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
-.thread-head { display: flex; gap: 16px; align-items: flex-start; }
-.thread-head h1 { word-break: break-word; }
-.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }
-.vote-score { font-weight: 700; }
-.vote-btn { background: none; border: 1px solid var(--border-light); border-radius: 7px; color: var(--text-secondary); width: 2.2em; height: 2em; cursor: pointer; font: inherit; }
-.vote-btn:hover { border-color: var(--accent); color: var(--accent-light); }
-.vote-btn.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
-.mod-actions form { margin: 0; }
-.posts { display: flex; flex-direction: column; gap: 12px; margin: 14px 0; }
-.post { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
-.post-opening { border-color: var(--border-light); }
-.post-deleted { background: transparent; border-style: dashed; padding: 8px 18px; }
-.post-deleted p { margin: 0; }
-.post-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: .88rem; color: var(--text-secondary); }
-.post-num { margin-left: auto; }
-.md { overflow-wrap: anywhere; }
-.md > :first-child { margin-top: .6em; }
-.md > :last-child { margin-bottom: 0; }
-.md blockquote { margin: .6em 0; padding: 4px 14px; border-left: 3px solid var(--border-light); color: var(--text-secondary); }
-.md code { font: .9em var(--mono); background: var(--bg-input); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; }
-.md pre.md-code { background: var(--bg-input); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; overflow: auto; }
-.md pre.md-code code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.5; white-space: pre; }
-.md hr { border: 0; border-top: 1px solid var(--border); }
-.reply-form { margin-top: 18px; }
+.page {
+ max-width: 60rem;
+ margin: 0 auto;
+ padding: 4rem 1.25rem 6rem;
+}
-/* Errors + footer host */
-.error { text-align: center; padding: 60px 0; }
-.error .btn { margin: 4px; }
-.ov-footer { margin-top: 24px; }
+.hero h1 {
+ font-size: clamp(1.75rem, 4vw, 2.75rem);
+ line-height: 1.15;
+ margin: 0.25rem 0 1rem;
+}
-@media (max-width: 640px) {
- .page { padding: 16px 16px 40px; }
- .thread-row { flex-wrap: wrap; }
- .thread-head { gap: 10px; }
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.75rem;
+ font-weight: 600;
+ opacity: 0.7;
+ margin: 0;
}
}
-/* ── Forum style: board index, topic tables, author panels (render/board.js) ── */
-.board-group { margin: 22px 0; }
-.board-group-h { font-size: 1rem; text-transform: uppercase; letter-spacing: .06em; color: var(--accent-light); margin: 0 0 6px; }
-.board-group-p { margin: 0 0 8px; }
-.board-table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
-.board-table th.num { width: 6.5em; }
-.board-table th:last-child { width: 16em; }
-.topic-table th.topic-icon { width: 2.6em; }
-.board-table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary); padding: 8px 12px; border-bottom: 1px solid var(--border-light); background: var(--bg-hover); }
-.board-table td { padding: 10px 12px; border-top: 1px solid var(--border-light); vertical-align: top; }
-.board-table tbody tr:first-child td { border-top: 0; }
-.board-table .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; width: 5.5em; }
-.board-name { display: flex; gap: 12px; align-items: flex-start; }
-.board-name > div { min-width: 0; }
-.board-name p { margin: 2px 0 0; }
-.board-icon { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: rgba(var(--accent-rgb), .12); color: var(--accent-light); }
-.board-title, .topic-title { font-weight: 700; }
-.board-last { width: 15em; font-size: .85rem; }
-.board-last > a, .board-last > .small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 15em; }
-.board-last .small a { display: inline; }
-.badge-style { font-size: .65rem; vertical-align: middle; }
-.board-children { margin-top: 4px; }
-.topic-table .topic-icon { width: 2.2em; text-align: center; color: var(--text-secondary); }
-.topic-main { min-width: 0; }
-.topic-table tr.sticky td { background: rgba(var(--accent-rgb), .06); }
-.topic-by { display: block; margin-top: 3px; }
-.topic-pages a { margin-left: 3px; }
-.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }
-.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }
-.space-settings, .crosspost-form { margin: 18px 0; }
-.chat-room { margin: 12px 0 16px; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--bg-card); }
-.chat-room h2 { margin: 0 0 6px; }
-.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
-.mod-list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 6px; }
-.mod-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 10px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--bg-card); }
-.mod-row .inline-form { margin-left: auto; }
-.postbits { display: grid; gap: 12px; margin: 12px 0; }
-.postbit { display: grid; grid-template-columns: 170px 1fr; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; scroll-margin-top: 80px; }
-.postbit-author { padding: 14px; background: var(--bg-hover); border-right: 1px solid var(--border-light); border-radius: 12px 0 0 12px; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; }
-.postbit-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }
-.postbit-letter { display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: 1.5rem; }
-.postbit-name { font-weight: 700; word-break: break-word; }
-.postbit-stats { display: grid; gap: 2px; margin: 0; font-size: .8rem; color: var(--text-secondary); }
-.postbit-stats div { display: flex; gap: 6px; justify-content: center; }
-.postbit-stats dt { font-weight: 600; }
-.postbit-stats dd { margin: 0; }
-.postbit-ratings { margin: 0; font-size: .8rem; display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
-.postbit-main { padding: 12px 16px; min-width: 0; }
-.postbit-head { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }
-.postbit.post-deleted { grid-template-columns: 1fr; }
-@media (max-width: 720px) {
- .postbit { grid-template-columns: 1fr; }
- .postbit-author { flex-direction: row; flex-wrap: wrap; text-align: left; border-right: 0; border-bottom: 1px solid var(--border-light); border-radius: 12px 12px 0 0; padding: 10px 12px; }
- .postbit-avatar { width: 40px; height: 40px; }
- .postbit-stats { grid-auto-flow: column; gap: 10px; }
- .board-table thead { display: none; }
- .board-table, .board-table tbody, .board-table tr, .board-table td { display: block; width: 100%; }
- .board-table tr { border-top: 1px solid var(--border-light); padding: 8px 0; }
- .board-table tbody tr:first-child { border-top: 0; }
- .board-table td { border-top: 0; padding: 2px 12px; }
- .board-table td.num { display: inline-block; width: auto; text-align: left; }
- .board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }
- .board-table td.topic-icon { display: none; }
- .board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }
+.lede {
+ font-size: 1.125rem;
+ line-height: 1.6;
+ max-width: 44rem;
}
}
-/* ── Ratings (Facepunch style) ── */
-.reactions { position: relative; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 10px 0 0; }
-.rx { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light); background: transparent; color: var(--text-primary, inherit); font: inherit; font-size: .82rem; cursor: default; }
-button.rx { cursor: pointer; }
-button.rx:hover { border-color: var(--accent); }
-.rx.mine { border-color: var(--accent); background: rgba(var(--accent-rgb), .15); }
-.rx-negative { border-style: dashed; }
-.rx-pick { position: static; }
-.rx-pick summary { list-style: none; cursor: pointer; font-size: .82rem; color: var(--text-secondary); padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; }
-.rx-pick summary::-webkit-details-marker { display: none; }
-.rx-pick summary:hover, .rx-pick[open] summary { border-color: var(--border-light); }
-.rx-menu { position: absolute; left: 0; top: 100%; z-index: 20; margin-top: 6px; padding: 8px; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.35); display: grid; gap: 6px; width: max-content; max-width: min(440px, 100%); }
-.rx-row { display: flex; flex-wrap: wrap; gap: 4px; }
-.rx-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 8px; border: 1px solid var(--border-light); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; }
-.rx-btn:hover, .rx-btn:focus-visible { border-color: var(--accent); background: var(--bg-hover); }
-.rx-btn.rx-negative { color: var(--text-secondary); }
+.forum-note {
+ margin-top: 1.5rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid currentColor;
+ border-radius: 0.5rem;
+ font-weight: 600;
+}
-/* Crossposts */
-.crosspost { margin: 8px 0; }
-.crosspost-note { margin: 4px 0; }
+.muted {
+ opacity: 0.7;
+ font-size: 0.9375rem;
+}
-/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */
-.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }
+.btn {
+ display: inline-block;
+ margin-right: 0.5rem;
+ padding: 0.5rem 0.875rem;
+ border: 1px solid currentColor;
+ border-radius: 0.375rem;
+ text-decoration: none;
+}
deleted file mode 100644
deleted file mode 100644
@@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
-/* OpenVibe.Space — progressive enhancement. Every page works without this file: the board index,
- spaces, threads and posts are server-rendered, and every action (reply, vote, moderate, settings,
- attach a chat room) is a plain link or form post. What this file adds is only comfort. */
-(function () {
- 'use strict';
- var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
-
- // A silent sign-in that found no session leaves ?sso=none behind — tidy the address bar.
- if (/[?&]sso=none\b/.test(location.search) && history.replaceState) {
- var u = new URL(location.href); u.searchParams.delete('sso');
- history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
- }
-
- // A dead avatar URL (an old Live avatar, Media offline) falls back to the letter badge.
- document.addEventListener('error', function (e) {
- var img = e.target;
- if (!img || img.tagName !== 'IMG' || !img.classList.contains('avatar')) return;
- var name = (img.parentNode && img.parentNode.textContent || '?').trim();
- var span = document.createElement('span'); span.className = 'avatar avatar-letter'; span.setAttribute('aria-hidden', 'true'); span.textContent = (name[0] || '?').toUpperCase();
- img.replaceWith(span);
- }, true);
-
- // A post's permalink (#post-12) is a copy target: a click on the number copies the full URL
- // (the anchor still navigates if the clipboard is unavailable).
- document.addEventListener('click', function (e) {
- var a = e.target && e.target.closest ? e.target.closest('a[href^="#post-"]') : null;
- if (!a || !navigator.clipboard) return;
- navigator.clipboard.writeText(location.href.split('#')[0] + a.getAttribute('href')).then(function () {
- var old = a.textContent; a.textContent = 'Link copied';
- setTimeout(function () { a.textContent = old; }, 1200);
- }).catch(function () { /* the anchor still jumps to the post */ });
- });
-})();
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
"name": "OpenVibe.Space",
"name": "OpenVibe.Space",
"short_name": "Space",
"short_name": "Space",
- "description": "The forums of the OpenVibe network: spaces, threads, replies and votes",
- "start_url": "/s",
+ "description": "Code and dynamic pages for the OpenVibe network. The forum is on OpenVibe.Community.",
+ "start_url": "/",
"scope": "/",
"scope": "/",
"display": "standalone",
"display": "standalone",
"background_color": "#0a0f1c",
"background_color": "#0a0f1c",
deleted file mode 100644
deleted file mode 100644
@@ -1,80 +0,0 @@
@@ -1,80 +0,0 @@
-'use strict';
-
-/**
- * Per-actor rate limits at the API routes (roadmap WS-R task 4; openvibe-sdk/limits).
- *
- * The per-address /api/ limit (app.js) and the per-person content limits (limits.js: cooldowns, writes
- * a minute) stay. These count requests by who makes them, once req.viewer is resolved:
- *
- * a person user:usr_… (their own token, or named by a service or app in X-OV-Subject)
- * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For),
- * a signed-out visitor as the per-address limit counts them
- * a service or app acting as its principal (svc:live, app:app_…): moderation and AI output
- * itself
- * a signed-out browser ip:<address>
- *
- * A first-party service reading for itself (no person, no visitor) is not counted on reads: its pages
- * speak for all its visitors, and the per-address /api/ limit already bounds it. Past a limit the route
- * answers 429 problem+json `rate_limited` with Retry-After before it does any work; the refusal is
- * logged once and counted in space_rate_limited_total{limit,window}. Reads get SPACE_LIMITS_MINUTE
- * / SPACE_LIMITS_HOUR (120 and 3000); writes set their own numbers where they are mounted. Counters
- * live in this process: a restart forgets them.
- *
- * Never limited: /api/health, /api/ready, /release.json, /metrics, and the signed Events deliveries
- * (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;
- * they also carry token cutoffs and account deletions). Pages keep their per-address form limits.
- */
-const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
-const config = require('./config');
-
-const FIRST_PARTY = /^svc:/;
-const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
-
-/** A first-party service that forwards the address of the visitor it acts for. */
-function relaysVisitor(req) {
- const v = req.viewer;
- return !!(v && v.kind === 'service' && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));
-}
-
-function actor(req) {
- const v = req.viewer;
- if (!v || v.kind === 'anonymous') return defaultActor(req);
- if (v.subject) return `user:${v.subject}`;
- if (v.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;
- return defaultActor(req);
-}
-
-/** A first-party service reading for itself: no person, no visitor. */
-function serviceItself(req) {
- const v = req.viewer;
- return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));
-}
-
-/**
- * limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of
- * a router (its writes set their own limits per route). opts.limits and opts.now are for tests.
- */
-function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console, valkey = null } = {}) {
- const refused = registry
- ? registry.counter({ name: 'space_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
- : null;
- const limiter = createActorLimiter({
- limits: limits || { minute: config.limits.minute, hour: config.limits.hour },
- actor,
- now,
- // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
- ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
- onLimited(e) {
- // The actor is a subject id, a principal or an address, never a token.
- log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
- if (refused) refused.inc({ limit: e.name, window: e.window });
- },
- });
- limiter.reads = (name) => {
- const limit = limiter(name);
- return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(req) ? limit(req, res, next) : next());
- };
- return limiter;
-}
-
-module.exports = { createActorLimits, actor, serviceItself };
@@ -1,23 +1,23 @@
@@ -1,23 +1,23 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Space — the forums of OpenVibe.
+ * OpenVibe.Space — code and dynamic pages for the OpenVibe network.
*
*
* Express app factory (server/index.js listens; tests build their own instance).
* Express app factory (server/index.js listens; tests build their own instance).
*
*
- * Pages (server-rendered) API / machine
- * GET /s the board index /api/v1/spaces/* spaces, threads, posts, votes,
- * GET /s/:space one space's topics /api/v1/posts/* categories, moderators (forum/api.js)
- * GET /s/:space/t/:slug one thread /api/v1/space-groups/* the board index's groups
- * GET /s/:space/new start one /api/v1/relay/* Discord relay admin (relay/api.js)
- * GET /s/feed.xml, /s/:space/feed.xml GET /api/health, /api/ready, /release.json, /metrics (loopback)
- * POST /s/…/reply | /vote | /state | … GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml
- * GET /auth/login|callback|logout|me|refresh POST /internal/events (signed Events deliveries)
+ * Space is being rebuilt and publishes one page of its own:
*
*
- * The forum lives in Space's own database (spaces, threads, posts, votes, attachments). People are
- * OpenVibe.Network subjects; images are OpenVibe.Media objects; members-only spaces and threads are
- * OpenVibe.VIP; a space's chat room is OpenVibe.Chat. Pastes, typed comment threads, submissions and
- * the network Pulse feed stay on OpenVibe.Community, the hub.
+ * GET / the home page: what Space will host, and where the forum went
+ * GET /auth/login|callback|fedcm|logout|me|refresh sign-in (OpenVibe.Network OAuth2 client)
+ * GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml crawler endpoints (the sitemap is / alone)
+ * GET /api/health, /api/ready, /release.json, /metrics (loopback)
+ *
+ * The forum is OpenVibe.Community's again (owner decision, 2026-10-08): every forum URL here answers
+ * a permanent redirect to the same path and query there — /s and /s/* (301 for GET and HEAD, 308
+ * otherwise), and /api/v1/spaces*, /api/v1/posts/*, /api/v1/space-groups* and /api/v1/relay* (308).
+ *
+ * Space holds no user content, so it consumes no network events and has no account export or
+ * deletion: the forum's account data is Community's. See README.md.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -30,34 +30,38 @@ const discovery = require('./discovery');
@@ -30,34 +30,38 @@ const discovery = require('./discovery');
const { assetVersion, SITE_NAME } = require('./render/layout');
const { assetVersion, SITE_NAME } = require('./render/layout');
const { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { getDb } = require('./db');
const { getDb } = require('./db');
-const { createNetworkIdentity } = require('./identity/network');
-const { createViewerResolver } = require('./identity/viewer');
-const { createForumService } = require('./forum/service');
-const { createVipGate } = require('./vip');
-const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');
-const { createForumRoutes } = require('./forum/routes');
-const { createDiscordRelay } = require('./relay/discord');
-const { createRelayEventsWorker } = require('./relay/events-worker');
-const { createDiscordGateway } = require('./relay/discord-gateway');
-const { createDiscordInbound } = require('./relay/inbound');
-const { createRelayApi } = require('./relay/api');
-const { createActorLimits } = require('./actor-limits');
-const { createIndexNow } = require('openvibe-shared/indexnow');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-// Pages are rendered for the person reading them (and a form post redirects back to them), so no
+// Pages are rendered for the person reading them (and the shared navbar reads their session), so no
// shared or browser cache keeps one.
// shared or browser cache keeps one.
const PAGE_CACHE = cache.htmlHeaders({ private: true });
const PAGE_CACHE = cache.htmlHeaders({ private: true });
+/** The forum paths that now live on OpenVibe.Community: /s and /s/*, and the forum APIs. */
+const FORUM_PAGE = /^\/s(\/|$)/;
+const FORUM_API = /^\/api\/v1\/(spaces|posts|space-groups|relay)(\/|$)/;
+
+/**
+ * Permanent redirect to the same path and query on Community. A GET or HEAD of a page keeps its
+ * method with a 301; anything else (a form post, a vote) must not be replayed as a GET, and the
+ * APIs are called with bodies, so they always answer 308.
+ */
+function forumRedirect(req, res, next) {
+ const isPage = FORUM_PAGE.test(req.path);
+ if (!isPage && !FORUM_API.test(req.path)) return next();
+ const method = String(req.method || 'GET').toUpperCase();
+ const status = isPage && (method === 'GET' || method === 'HEAD') ? 301 : 308;
+ res.redirect(status, `${config.communityUrl}${req.originalUrl}`);
+}
+
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
// What this server runs (ADR-016); the shared navbar's release-watch polls it.
// What this server runs (ADR-016); the shared navbar's release-watch polls it.
- const release = require('openvibe-shared/release').createRelease({ service: 'space', root: require('path').join(__dirname, '..') });
+ const release = require('openvibe-shared/release').createRelease({ service: 'space', root: path.join(__dirname, '..') });
require('./render/layout').setRelease(release.release);
require('./render/layout').setRelease(release.release);
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// direct loopback callers only (Track O). Request metrics only: no content counts.
// direct loopback callers only (Track O). Request metrics only: no content counts.
@@ -74,9 +78,9 @@ async function createApp(opts = {}) {
@@ -74,9 +78,9 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://cdnjs.cloudflare.com', 'https://static.cloudflareinsights.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://cdnjs.cloudflare.com', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://openvibe.network'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://openvibe.network'],
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],
- // Attachments serve from openvibe.media (which may 302 to object storage); avatars from Network/Live.
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
- connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.events', 'https://openvibe.media', 'https://openvibe.chat', 'https://openvibe.vip', 'https://cloudflareinsights.com'],
+ // The shared navbar's notification bell hears OpenVibe.Events (notificationsRealtime).
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com'],
// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.
// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
frameAncestors: ["'self'"],
@@ -92,6 +96,9 @@ async function createApp(opts = {}) {
@@ -92,6 +96,9 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+ // ── The forum is Community's: every forum URL redirects there ───
+ app.use(forumRedirect);
+
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
const auth = opts.auth || createAuthClient(config);
const auth = opts.auth || createAuthClient(config);
app.locals.auth = auth;
app.locals.auth = auth;
@@ -100,83 +107,20 @@ async function createApp(opts = {}) {
@@ -100,83 +107,20 @@ async function createApp(opts = {}) {
app.use('/auth', createAuthRoutes(config, auth));
app.use('/auth', createAuthRoutes(config, auth));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'space', service: 'space', host: 'openvibe.space', name: 'OpenVibe.Space', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
- // ── Space's database, identity, and what lives in it ─────
+ // ── Space's database (for readiness and the shared schema) ───
const db = opts.db || getDb();
const db = opts.db || getDb();
- const network = opts.network || createNetworkIdentity({ config, db });
- // Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password
- // changes and bans refuse older tokens here at once (WS-B task 4).
- // Cutoffs are read into memory before the app serves: isRevoked() is synchronous on every signed-in request.
- const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });
- await revocations.load();
- const viewers = createViewerResolver({ auth, config, network, revocations });
- const relay = opts.relay || createDiscordRelay({
- db, config, enabled: config.discordRelay.enabled,
- pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
- webhookVars: config.discordRelay.webhookVars,
- ...(opts.relayOptions || {}),
- });
- // Its Events worker (creates from space.thread.* / space.post.*) and the inbound gateway;
- // both off unless configured (relay/events-worker.js, relay/discord-gateway.js, relay/inbound.js).
- const relayInbound = relay.enabled ? createDiscordInbound({ db, perMinute: config.discordRelay.inboundPerMinute, maxChars: config.discordRelay.inboundMaxChars, ...(opts.inboundOptions || {}) }) : null;
- if (!opts.relay) {
- relay.attach({
- worker: createRelayEventsWorker({
- db, relay, enabled: config.discordRelay.events, eventsUrl: config.discordRelay.eventsUrl, clientSecret: config.oauth.clientSecret,
- tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, pollMs: config.discordRelay.eventsPollMs, fetchImpl: opts.fetchImpl,
- ...(opts.relayWorkerOptions || {}),
- }),
- gateway: relayInbound && config.discordRelay.inbound ? createDiscordGateway({
- token: config.discordRelay.botToken, url: config.discordRelay.gatewayUrl,
- onDispatch: async (type, data, ctx) => await relayInbound.handle(type, data, ctx),
- ...(opts.gatewayOptions || {}),
- }) : null,
- inbound: relayInbound,
- });
- }
- // OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).
- const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
- // Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).
- 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 });
- // 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 forum service
- // pings 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, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
- discovery.useForum(forum);
- Object.assign(app.locals, { db, network, relay, relayInbound, vip, forum, indexnow });
- if (opts.startRelay !== false) relay.start();
-
- // Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its
- // viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).
- // Valkey (ADR-035): the per-actor limit counters, shared across processes; opts.valkey for tests (null: none).
- const valkey = opts.valkey !== undefined ? opts.valkey
- : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null);
- app.locals.valkey = valkey;
- const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });
-
- // ── /api/v1: the forum, its groups and the relay admin ───
- app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
- app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
- app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
- app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
- app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
- // OpenVibe.Events → Space (server/events-consumer.js): token cutoffs, platform blocks, account
- // export/deletion/merge and VIP cache convergence.
- const eventsConsumer = require('./events-consumer').createEventsConsumer({ db, vipCache: vip && vip.cache, revocations, accountSend: config.oauth && config.oauth.clientSecret ? require('./identity/account-data').createSender({ config }) : null, secrets: String(process.env.SPACE_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
- app.locals.eventsConsumer = eventsConsumer;
- // Never per-actor limited: Events pushes at its own pace (a 429 only makes it retry and fall behind),
- // and these deliveries carry token cutoffs and account deletions.
- app.use('/internal/events', eventsConsumer.router);
+ app.locals.db = db;
+
+ // /updates: Space ships with the community's log until it has one of its own.
+ app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-space', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-space', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// sendBeacon, no auth) into /metrics as release_client_updates_total.
// sendBeacon, no auth) into /metrics as release_client_updates_total.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- // Readiness reports what is actually served: 503 only without the database; the Network key
- // and Media failures degrade (server/observability.js).
- const readiness = require('./observability').createSpaceReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });
+ // Readiness reports what is actually served: 503 only without the database; a Network key that has
+ // not loaded degrades (server/observability.js).
+ const readiness = require('./observability').createSpaceReadiness({ db, auth, config, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Static assets (content-hashed ?v= → immutable) ───────
// ── Static assets (content-hashed ?v= → immutable) ───────
@@ -192,11 +136,11 @@ async function createApp(opts = {}) {
@@ -192,11 +136,11 @@ async function createApp(opts = {}) {
}));
}));
// ── Machine endpoints ────────────────────────────────────
// ── Machine endpoints ────────────────────────────────────
- // Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
+ // Written by openvibe-shared/seo from server/discovery.js; the sitemap is / alone.
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })));
.send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })));
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
- .send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, sections: discovery.llmsSections() })));
+ .send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.SUMMARY, sections: discovery.llmsSections() })));
app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
app.get('/sitemap.xml', async (_req, res) => {
app.get('/sitemap.xml', async (_req, res) => {
@@ -204,20 +148,10 @@ async function createApp(opts = {}) {
@@ -204,20 +148,10 @@ async function createApp(opts = {}) {
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
});
});
- // GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).
- if (indexnow.enabled) app.use(indexnow.keyFile);
// ── Pages ────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────
- // Pages are for browsers: the viewer (subject, staff) is resolved here too.
- // Every page response below (redirects and error pages too) that sets no policy of its own.
- app.use(cache.applyHtml({ private: true }));
-
- // The board index lives at /s (GET /s renders it); / sends visitors there.
- app.get('/', (_req, res) => res.redirect(301, '/s'));
- // /updates: Space ships with the community's log until it has one of its own.
- app.get('/updates', (_req, res) => res.redirect(302, `${config.communityUrl}/updates`));
-
- app.use(createForumRoutes({ forum, viewers, config }));
+ // The home page: what Space will host, and where the forum went (no fake features).
+ app.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(require('./render/pages').homePage()));
// ── 404 / errors ─────────────────────────────────────────
// ── 404 / errors ─────────────────────────────────────────
app.use((req, res) => {
app.use((req, res) => {
deleted file mode 100644
deleted file mode 100644
@@ -1,89 +0,0 @@
@@ -1,89 +0,0 @@
-'use strict';
-/**
- * OpenVibe.Chat rooms for spaces (roadmap WS-I task 4): a space's owner or staff attach a chat room to
- * the space; the space page links it.
- *
- * Each side checks its own end. Space decides who may change the space (forum/service.js); Chat
- * decides who may attach the room: the person must manage it (its owner, or chat staff). Space asks
- * Chat on the person's behalf with the person's OWN Network token (the ov_token they are signed in with),
- * so no service grant or new capability is involved:
- *
- * POST <chat>/api/chat/rooms/:room/attachments { service: 'space', resource: <space slug>, title }
- * 201 attached, 200 already attached (idempotent) → { room: { id, slug, name, kind, visibility } }
- * DELETE <chat>/api/chat/rooms/:room/attachments/space/:space (the room's managers or who attached it)
- *
- * Chat's answers become Space's: 404 (no such room, or a private room the person is not in: Chat
- * says nothing more), 403 (not the room's owner), 401 (the token expired). Chat unreachable: 503.
- */
-const { fail } = require('./http/v1');
-
-const ROOM_SLUG = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
-
-/** A room slug from "night-owls", "/r/night-owls" or "https://openvibe.chat/r/night-owls"; null otherwise. */
-function parseRoomRef(input) {
- const s = String(input == null ? '' : input).trim();
- if (!s || s.length > 300) return null;
- const m = s.match(/^(?:https?:\/\/[^/\s]+)?\/r\/([^/?#\s]+)\/?(?:[?#].*)?$/i);
- const slug = (m ? m[1] : s).toLowerCase();
- return ROOM_SLUG.test(slug) ? slug : null;
-}
-
-function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {
- const chat = (config && config.chat) || {};
- const base = chat.internalUrl || '';
- const timeoutMs = chat.timeoutMs || 4000;
- const publicUrl = chat.url || 'https://openvibe.chat';
-
- async function call(method, path, token, body) {
- if (!base) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
- let res;
- try {
- res = await fetchImpl(`${base}${path}`, {
- method,
- headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
- body: body ? JSON.stringify(body) : undefined,
- signal: AbortSignal.timeout(timeoutMs),
- });
- } catch (err) {
- fail(503, 'chat_room.unavailable', 'OpenVibe.Chat did not answer. Try again in a minute');
- }
- let data = null;
- try { data = await res.json(); } catch { data = null; }
- return { status: res.status, data };
- }
-
- /** Ask Chat to attach `room` to `space` as the person holding `token`. → { room, created } */
- async function attach({ token, room, space, title }) {
- if (!token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
- const r = await call('POST', `/api/chat/rooms/${encodeURIComponent(room)}/attachments`, token, { service: 'space', resource: space, title: title || undefined });
- if ((r.status === 200 || r.status === 201) && r.data && r.data.room && r.data.room.slug) {
- const x = r.data.room;
- return {
- created: r.status === 201,
- room: { id: Number(x.id) || null, slug: String(x.slug), name: String(x.name || x.slug).slice(0, 80), kind: String(x.kind || 'community'), visibility: x.visibility === 'private' ? 'private' : 'public' },
- };
- }
- if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');
- if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');
- if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');
- if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');
- fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');
- }
-
- /** Tell Chat the space let go of the room; best effort. → 'detached' | 'not_allowed' | 'unavailable' */
- async function detach({ token, room, space }) {
- if (!token || !base) return 'unavailable';
- try {
- const r = await call('DELETE', `/api/chat/rooms/${encodeURIComponent(room)}/attachments/space/${encodeURIComponent(space)}`, token);
- if (r.status === 200) return 'detached';
- if (r.status === 403 || r.status === 404) return 'not_allowed';
- return 'unavailable';
- } catch { return 'unavailable'; }
- }
-
- const roomUrl = (slug) => `${publicUrl}/r/${encodeURIComponent(slug)}`;
-
- return { attach, detach, roomUrl, parseRoomRef };
-}
-
-module.exports = { createChatRooms, parseRoomRef, ROOM_SLUG };
@@ -18,13 +18,6 @@ module.exports = {
@@ -18,13 +18,6 @@ module.exports = {
// Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.
// Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.
trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
- // Per-actor limits at the API routes (server/actor-limits.js): the reads one caller may make to
- // one API per minute and per hour. Writes set their own numbers per route.
- limits: {
- minute: parseInt(process.env.SPACE_LIMITS_MINUTE, 10) || 120,
- hour: parseInt(process.env.SPACE_LIMITS_HOUR, 10) || 3000,
- },
-
// Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
// Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
@@ -43,80 +36,14 @@ module.exports = {
@@ -43,80 +36,14 @@ module.exports = {
secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
},
},
- // OpenVibe.Community — the hub (D12). Space's pages link the network Pulse feed Community serves;
- // Space stores none of it and serves no Pulse page of its own.
+ // OpenVibe.Community — the hub. The forum is Community's again (owner decision, 2026-10-08) and
+ // every forum URL here redirects to the same path and query on Community.
communityUrl: (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, ''),
communityUrl: (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, ''),
- // OpenVibe.Live — author profile links (the Network hosts the profile page; Live's layout is the
- // familiar one) and avatars.
+ // OpenVibe.Live — the legal pages the shared footer links (the Network hosts the profile page).
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
- // OpenVibe.Media — public host that serves the images posts attach (med_ objects).
- mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
- // Media's internal address: attachment uploads go to its Object API with a service token
- // (media.object.upload for audience openvibe.media).
- mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),
- // PostgreSQL (ADR-035) for Space's own data: the forum (spaces, threads, posts, votes,
- // categories, attachments), the Discord relay's bookkeeping and the account-data records.
- // DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,
- // development uses an embedded PGlite database in data/pglite.
+ // PostgreSQL (ADR-035). Space holds no user content yet; the database is kept for the release
+ // ledger and the shared schema migrations. Without DATABASE_URL, development uses an embedded
+ // PGlite database in data/pglite.
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
- // Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.
- valkey: { url: process.env.VALKEY_URL || '', prefix: process.env.VALKEY_PREFIX || 'ov:space:' },
-
- // Browser origins allowed to call the embeddable forum APIs with a Bearer Network JWT.
- // No cookies cross origins.
- apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.community,https://openvibe.live,https://openvibe.network,https://openvibe.tools')
- .split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
-
- // OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from
- // VIP's POST /api/v1/policies/evaluate (capability vip.resource.policy.evaluate, client
- // credentials of client `space`), cached per viewer: a "yes" at most ttlMs (the convergence
- // bound after VIP stops granting), a "no" denyTtlMs, a failure unavailableTtlMs. Without a
- // client secret or with VIP down, nobody but the owner and moderators gets in.
- vip: {
- internalUrl: (process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/$/, ''),
- publicUrl: (process.env.OV_VIP_URL || 'https://openvibe.vip').replace(/\/$/, ''),
- timeoutMs: parseInt(process.env.VIP_TIMEOUT_MS, 10) || 2000,
- ttlMs: parseInt(process.env.VIP_CACHE_TTL_MS, 10) || 30_000,
- denyTtlMs: parseInt(process.env.VIP_CACHE_DENY_TTL_MS, 10) || 10_000,
- unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
- },
-
- // IndexNow (openvibe-shared/indexnow): when INDEXNOW_KEY is set, the key file is served at
- // /<key>.txt and a public, indexable page appearing, changing or going away pings the engines.
- // Unset: off — nothing is mounted and nothing is sent.
- indexnow: { key: process.env.INDEXNOW_KEY || '' },
-
- // OpenVibe.Chat — a space can attach a chat room (server/chat-rooms.js). Space asks Chat, with the
- // signed-in person's own Network token, whether they manage the room; the space page links it.
- chat: {
- url: (process.env.OV_CHAT_URL || 'https://openvibe.chat').replace(/\/$/, ''),
- internalUrl: (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/$/, ''),
- timeoutMs: parseInt(process.env.CHAT_TIMEOUT_MS, 10) || 4000,
- },
-
- // Discord relay (server/relay; docs/discord-relay.md). Off by default, and inert without an owner's
- // webhook variables, mappings and (for inbound) bot token.
- // out threads and replies in mapped public spaces → the mapping's Discord webhook; edits and deletes follow
- // in replies on Discord → posts (the gateway; DISCORD_RELAY_INBOUND=on and DISCORD_BOT_TOKEN)
- // Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.
- discordRelay: {
- enabled: /^(1|true|yes|on)$/i.test(process.env.DISCORD_RELAY_ENABLED || ''),
- pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,
- backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,
- maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,
- // The only variables a mapping may name (comma-separated exact names); unset = DISCORD_WEBHOOK_*.
- webhookVars: (process.env.DISCORD_RELAY_WEBHOOK_VARS || '').split(',').map((s) => s.trim()).filter(Boolean),
- // The Events worker queues creates from space.thread.* / space.post.* (needs EVENTS_URL and
- // OV_OAUTH_CLIENT_SECRET, capability events.event.read); 'off' leaves them to the forum.
- events: !/^(0|false|no|off)$/i.test(process.env.DISCORD_RELAY_EVENTS || ''),
- eventsUrl: (process.env.EVENTS_URL || '').replace(/\/+$/, '') || null,
- eventsPollMs: parseInt(process.env.DISCORD_RELAY_EVENTS_POLL_MS, 10) || 5000,
- // Inbound through the Discord gateway (a bot in the server with the MESSAGE CONTENT intent).
- inbound: /^(1|true|yes|on)$/i.test(process.env.DISCORD_RELAY_INBOUND || ''),
- botToken: process.env.DISCORD_BOT_TOKEN || '',
- gatewayUrl: process.env.DISCORD_GATEWAY_URL || 'wss://gateway.discord.gg/?v=10&encoding=json',
- inboundPerMinute: parseInt(process.env.DISCORD_RELAY_INBOUND_PER_MINUTE, 10) || 6,
- inboundMaxChars: parseInt(process.env.DISCORD_RELAY_INBOUND_MAX_CHARS, 10) || 4000,
- },
};
};
@@ -3,13 +3,11 @@
@@ -3,13 +3,11 @@
/**
/**
* Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.
* Space's own database: PostgreSQL (ADR-035). The schema is migrations/NNNN_*.sql, applied at boot.
*
*
- * Space owns the forum: space groups, spaces, categories, threads, posts and their versions, thread
- * votes, attachments (Media objects), post reactions, a space's chat room, per-space moderators, the
- * Discord relay's bookkeeping and the account-data and block projections. 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.
- *
- * Pastes, typed comment threads, Pulse and submissions are OpenVibe.Community's; they are not here.
+ * Space holds no user content while it is rebuilt. The migrations still create the forum tables
+ * (spaces, threads, posts, votes, attachments, the relay's bookkeeping, the event inbox and the
+ * account-data records) that Space served when the forum lived here; they stay in the database —
+ * this release writes no drop migration — and their rows are OpenVibe.Community's now, where the
+ * forum is. The database itself stays for the shared schema and the release ledger.
*
*
* Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()
* Timestamps are text ('YYYY-MM-DD HH:MM:SS', UTC): the schema defines ov_now(), datetime() and julianday()
* with those semantics, and ov_hot() for the forum's hot rank.
* with those semantics, and ov_hot() for the forum's hot rank.
@@ -1,86 +1,47 @@
@@ -1,86 +1,47 @@
'use strict';
'use strict';
/**
/**
- * What Space tells crawlers, feed readers and language models: the robots.txt disallow set,
- * the /llms.txt and /llms-full.txt sections, the sitemap rows (cached ~1h) and the feed items.
- * Only data lives here; openvibe-shared/seo writes every format (server/app.js, server/forum/routes.js).
+ * What Space tells crawlers and language models: the robots.txt disallow set, the /llms.txt and
+ * /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes
+ * every format (server/app.js).
+ *
+ * Space publishes one page of its own — the home page — while it is rebuilt to host code and
+ * dynamic pages and "spaces", apps people build on their OpenVibe account, the network's services
+ * and the SDK. The forum is OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum
+ * shaped appears here: no /s entries, no feeds.
*/
*/
const config = require('./config');
const config = require('./config');
const { abs } = require('./render/layout');
const { abs } = require('./render/layout');
-const { isoDate } = require('./render/jsonld');
-const SITEMAP_TTL_MS = 60 * 60 * 1000;
-const ROBOTS_DISALLOW = ['/api/', '/auth/', '/s/*/new', '/*?sso='];
-const SUMMARY = 'The forums of the OpenVibe network: public and members-only spaces, their threads and replies, votes and categories. The network Pulse feed lives on OpenVibe.Community.';
-const CONTENT_LABELS = 'Threads and replies written by services for the OpenVibe AI carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
-
-// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,
-// the feeds and /llms-full.txt.
-let _forum = null;
-function useForum(forum) { _forum = forum || null; resetCaches(); }
+const ROBOTS_DISALLOW = ['/api/', '/auth/', '/*?sso='];
+const SUMMARY = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';
// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
function llmsSections() {
function llmsSections() {
const u = config.baseUrl;
const u = config.baseUrl;
return [
return [
- { title: 'Browse', links: [
- { title: 'Spaces', url: `${u}/s`, note: 'the board index: every public space, grouped' },
- { title: 'Pulse', url: `${config.communityUrl}/pulse`, note: 'network activity, on OpenVibe.Community' },
+ { title: 'This site', links: [
+ { title: 'Home', url: `${u}/`, note: 'what Space will host (nothing is published here yet)' },
+ ] },
+ { title: 'The network', links: [
+ { title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },
+ { title: 'OpenVibe.Network', url: config.networkUrl, note: 'identity, accounts and sign-in' },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
- { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },
{ title: 'Full text for language models', url: `${u}/llms-full.txt` },
{ title: 'Full text for language models', url: `${u}/llms-full.txt` },
] },
] },
];
];
}
}
-/** The latest public threads in full (nothing gated). */
async function llmsFullSections() {
async function llmsFullSections() {
- const { markdownToText } = require('./render/markdown');
- const sections = [{ title: 'Content labels', pages: [{ title: 'Who wrote what', url: '/s', text: CONTENT_LABELS }] }];
- if (_forum) {
- const threads = await _forum.recentPublic({ limit: 50 });
- sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
- title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),
- })) });
- }
- return sections;
+ return [{ title: 'OpenVibe.Space', pages: [{ title: 'What Space will host', url: '/', text: SUMMARY }] }];
}
}
-// ── sitemap.xml rows (cached ~1h) ────────────────────────────
-let _sitemap = null, _sitemapAt = 0;
-async function buildSitemapRows() {
- const rows = [];
- const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });
- add('/s', null, 'hourly', '1.0');
- if (_forum) {
- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
- }
- return rows;
-}
-/** The sitemap rows, rebuilt at most hourly; a failed rebuild keeps serving the last good rows. */
+// ── sitemap.xml rows ─────────────────────────────────────────
+/** Space's only page is the home page. */
async function sitemapRows() {
async function sitemapRows() {
- if (!_sitemap || Date.now() - _sitemapAt > SITEMAP_TTL_MS) {
- try { _sitemap = await buildSitemapRows(); _sitemapAt = Date.now(); }
- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) throw e; }
- }
- return _sitemap;
+ return [{ loc: abs('/'), lastmod: null, changefreq: 'weekly', priority: '1.0' }];
}
}
-// ── Feed items (openvibe-shared/seo feedXml, 2.5.0 item shape) ──
-/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */
-function threadFeedItems(threads) {
- const { markdownToText } = require('./render/markdown');
- return threads.map((t) => {
- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
- const description = markdownToText(t.opening, 300);
- return { title: t.title, link: url, guid: url, description, content: description,
- author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };
- });
-}
-
-function resetCaches() { _sitemap = null; _sitemapAt = 0; }
-
-module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, threadFeedItems, useForum, resetCaches };
+module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };
deleted file mode 100644
deleted file mode 100644
@@ -1,152 +0,0 @@
@@ -1,152 +0,0 @@
-'use strict';
-/**
- * OpenVibe.Events → Space: POST /internal/events, the endpoint of Space's Events subscriptions
- * (consumer `space`, created at boot by startSubscriptions()). The forum keeps its tables honest with
- * what the rest of the network says about people:
- *
- * network.user.token_valid_after revocation (WS-B task 4): the person's token cutoff moves
- * (openvibe-sdk createPgRevocationStore); the viewer resolver refuses
- * their older tokens.
- * network.block.changed platform blocks (WS-E task 5): the network_blocks projection
- * (../identity/blocks.js; the newest revision per pair wins) that
- * replies honour.
- * network.subject.merged two accounts became one (ADR-029): the folded-in subject's rows
- * become the survivor's (../identity/subject-merge.js).
- * network.account.export_requested Space's part of an account export (../identity/account-data.js).
- * network.account.deleted what the subject wrote goes (or becomes a tombstone) and Space
- * confirms with counts.
- * vip.membership.changed VIP convergence: the member's cached members-only answers for that
- * creator are dropped at once (the VIP gate's cache handleEvent)
- * instead of waiting out its TTL.
- *
- * Pulse item creation is NOT here: the network Pulse feed is OpenVibe.Community's (plan T10 D3, D12),
- * and Space stores none of it.
- *
- * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the write.
- * Signature v2 only (parseDelivery requireV2) under SPACE_EVENTS_SECRET (comma-separated for rotation,
- * 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header came
- * through nginx and is refused.
- */
-const express = require('express');
-const { http, serviceAuth } = require('openvibe-contracts');
-const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
-const blocks = require('./identity/blocks');
-
-const CONSUMER = 'space';
-const TOPICS = Object.freeze(['vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);
-const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-function createEventsConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
- const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
- // Receipts (space_event_inbox) are in migrations/0001_initial.sql.
- const inbox = createPgInbox(db, { table: 'space_event_inbox', now });
- const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
- const router = express.Router();
- router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
- const ctx = http.requestContext(req.headers);
- const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'space.internal_only', 'internal route');
- if (!keys.length) return problem(503, 'space.events_disabled', 'SPACE_EVENTS_SECRET is not set');
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
- let delivery = null;
- for (const s of keys) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true, now: now() }); if (delivery) break; }
- if (!delivery) { stats.refused++; return problem(401, 'space.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window'); }
- const event = delivery.event;
- if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'space.bad_delivery', 'body must be { event: <envelope>, seq }'); }
- stats.received++; stats.last_at = new Date(now()).toISOString();
- if (event.event_type === 'network.user.token_valid_after') {
- if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await revocations.apply(event) }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- }
- if (event.event_type === 'network.block.changed') {
- const p = blocks.payloadOf(event);
- if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
- try {
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await blocks.apply(db, p, now()) }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- } catch (err) {
- stats.failed++;
- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(500, 'space.event_failed', 'processing failed; it will be retried');
- }
- }
- if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
- // Account export and deletion (ADR-033, ./identity/account-data.js): once per export or deletion by its own
- // record, answered after Network took the part or the confirmation, so a failure is redelivered without
- // erasing twice.
- if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }
- return require('./identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {
- stats.applied++;
- res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });
- }, (err) => {
- stats.failed++;
- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- problem(500, 'space.event_failed', 'processing failed; it will be retried');
- });
- }
- if (event.event_type === 'network.subject.merged') {
- // Two accounts became one (ADR-029): the folded-in subject's rows become the survivor's (./identity/subject-merge.js).
- const merge = require('./identity/subject-merge');
- const p = merge.payloadOf(event);
- if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
- try {
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await merge.apply(db, p, { log }) }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- } catch (err) {
- stats.failed++;
- log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(500, 'space.event_failed', 'processing failed; it will be retried');
- }
- }
- if (event.event_type === 'vip.membership.changed') {
- if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
- const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- }
- stats.ignored++;
- return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:type' });
- });
- return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };
-}
-
-/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
-function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVENTS_URL, fetchImpl = globalThis.fetch, log = console }) {
- if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.SPACE_EVENTS_SUBSCRIBE === '0') return null;
- const base = String(eventsUrl).replace(/\/+$/, '');
- const endpoint = process.env.SPACE_EVENTS_ENDPOINT || `http://127.0.0.1:${port}/internal/events`;
- const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });
- const call = async (method, path, body) => {
- const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
- const json = await res.json().catch(() => ({}));
- return { status: res.status, ok: res.ok, body: json };
- };
- const attempt = async () => {
- const listed = await call('GET', '/api/v1/subscriptions');
- if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
- const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
- for (const topic of TOPICS) {
- if (mine.some((s) => s.topic_pattern === topic)) continue;
- const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
- if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
- if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
- }
- };
- const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
- let i = 0;
- let timer = null;
- let stopped = false;
- const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
- if (stopped) return;
- if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
- }); };
- timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
- // Graceful stop: no further attempts (they run again at the next start).
- return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
-}
-
-module.exports = { createEventsConsumer, startSubscriptions, TOPICS };
deleted file mode 100644
deleted file mode 100644
@@ -1,125 +0,0 @@
@@ -1,125 +0,0 @@
-'use strict';
-/**
- * Space → OpenVibe.Events (contracts space.*@1).
- *
- * space.thread.created, space.post.created server/forum/store.js
- * space.moderation.action server/forum/service.js (staff actions on others' content)
- *
- * Each event is written to Space's own outbox inside the SAME transaction as the change (the SDK
- * outbox refuses anything else), and a relay publishes it with Space's service token (audience
- * openvibe.events, capability events.event.publish). Events down: rows wait and are retried; a write
- * never waits on Events. Payloads carry ids, the author subject, visibility and the public URL, never
- * a body or a title. Envelope visibility is public only for public items.
- *
- * Off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set (EVENTS_PUBLISH=off disables it).
- *
- * The event types are the forum's own namespace (plan T10 D3: the forum's contracts are Space's).
- * openvibe-contracts v0.110.0 publishes the space.thread.created / space.post.created /
- * space.moderation.action payloads (space.json `eventsProduced`), so Events accepts them; the producer
- * and the Discord relay's Events worker stay off without EVENTS_URL and OV_OAUTH_CLIENT_SECRET.
- */
-const { createClient } = require('openvibe-sdk/core');
-const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
-const config = require('./config');
-
-let outbox = null;
-let outboxDb = null; // the handle enqueue() writes through (it joins the caller's ambient transaction)
-let pruneTimer = null;
-const stats = { queued: 0, lastError: null };
-const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
-
-function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oauth && config.oauth.clientSecret, fetchImpl, intervalMs } = {}) {
- if (outbox) return outbox;
- if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;
- const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'space', clientSecret, fetch: fetchImpl });
- const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
- outboxDb = db;
- // The PostgreSQL outbox (its table is in migrations/0001_initial.sql); several processes relay it safely (leases).
- outbox = createPgOutbox(db, {
- events: createEventsClient(client, { source: 'space' }),
- intervalMs: intervalMs || 2000,
- onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },
- });
- outbox.start();
- pruneTimer = setInterval(() => { outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);
- if (pruneTimer.unref) pruneTimer.unref();
- outbox.pending().then((n) => console.log(`[Events] space → ${eventsUrl} (${n} pending)`), () => {});
- return outbox;
-}
-
-const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? { type: 'user', id: sub } : (sub && /^gst_/.test(sub) ? { type: 'guest', id: sub } : null));
-
-/**
- * Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,
- * so the change rolls back with it. A no-op (null) while publishing is off.
- */
-async function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {
- if (!outbox) return null;
- const env = await outbox.enqueue(outboxDb, {
- event_type: eventType,
- actor: actor || { type: 'service', id: 'space' },
- subject,
- visibility: isPublic ? 'public' : 'internal',
- priority: 'low',
- payload,
- });
- stats.queued++;
- outboxDb.afterCommit(() => outbox && outbox.kick());
- return env;
-}
-
-// ── Builders (read the row as it is after the change) ─────────────────────────────
-const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
-
-// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).
-const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');
-async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
- const visibility = forumVisibility(thread, spaceVisibility);
- return await record('space.thread.created', { type: 'thread', id: String(thread.id) }, {
- thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
- }, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
-}
-async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
- const visibility = forumVisibility(thread, spaceVisibility);
- return await record('space.post.created', { type: 'post', id: String(post.id) }, {
- post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
- }, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
-}
-
-/**
- * A staff action on someone else's content: space.moderation.action, for the network's moderation
- * audit log (ADR-022). Call inside the same transaction as the change. Never the content.
- */
-async function moderationAction(v, action, target, { reason = null, details = {} } = {}) {
- const actorSubject = v && v.subject ? v.subject : null;
- const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };
- return await record('space.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
- { action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
- { actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });
-}
-
-/** Synchronous: whether the relay is on, and what this process queued. */
-function status() {
- if (!outbox) return { enabled: false };
- return { enabled: true, queued_since_boot: stats.queued, last_error: stats.lastError };
-}
-/** status() with the outbox's backlog (rows not sent yet, rows Events refused). */
-async function backlog() {
- if (!outbox) return { enabled: false };
- return { ...status(), pending: await outbox.pending(), rejected: await outbox.rejected() };
-}
-/**
- * Graceful stop (server/index.js): no further sends; resolves when the send in progress has finished.
- * Rows written after this (a request that was still finishing) stay in the outbox for the next start.
- */
-function stop() {
- if (pruneTimer) clearInterval(pruneTimer);
- pruneTimer = null;
- return outbox ? outbox.stop() : Promise.resolve();
-}
-function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }
-
-module.exports = { init, record, moderationAction, threadCreated, postCreated, status, backlog, stop, _reset };
deleted file mode 100644
deleted file mode 100644
@@ -1,145 +0,0 @@
@@ -1,145 +0,0 @@
-'use strict';
-
-/**
- * /api/v1/spaces and /api/v1/posts — the forum's JSON API (the pages in render/forum.js are the
- * no-JS equivalent; service.js holds the rules for both).
- *
- * GET /spaces spaces the caller can open
- * GET /spaces/:space one space
- * GET /spaces/:space/threads?sort=hot|new|top&page= threads (server pagination)
- * POST /spaces/:space/threads { title, body } new thread (body is Markdown)
- * GET /spaces/:space/threads/:slug?page= thread + posts
- * DELETE /spaces/:space/threads/:slug author or moderator
- * POST /spaces/:space/threads/:slug/posts { body } reply
- * POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
- * PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators
- * PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
- * PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
- * POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
- * POST /spaces/:space/attachments (multipart `file`) an image for a new thread or reply → { attachment: { media_id, url, … } };
- * then POST …/threads or …/posts with { attachments: [media_id] } (at most 4)
- * GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)
- * PUT /spaces/:space/categories/:category { name, description?, position? } moderators
- * DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
- * PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
- * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,
- * roadmap items planned|in_progress|done|paused (?status= filters)
- * POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
- * PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
- * POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)
- * PUT /spaces/:space/chat-room { room: slug | https://openvibe.chat/r/<slug> } the space's owner or staff, signed in
- * themselves (Chat checks they manage the room) → { chat_room, created }
- * DELETE /spaces/:space/chat-room the space's owner or staff (idempotent) → { detached, chat }
- * GET /spaces/:space/moderators the space's own moderators → { space, moderators: [{ subject, username, display_name, added_by, added_at }] }
- * PUT /spaces/:space/moderators/:subject add a person (usr_… or @username, resolved through the Network; 404 moderator.unknown_user)
- * — the space's moderators or staff (idempotent)
- * DELETE /spaces/:space/moderators/:subject remove one — the space's moderators or staff (idempotent)
- * POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
- * PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
- *
- * Services write with space.post.write (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
- * moderate with space.forum.manage. "moderators" on a /spaces/:space route means discussion staff or
- * one of that space's own moderators (people, signed in themselves). Errors are problem+json. A members-only space or thread
- * refuses readers and writers without the creator's VIP membership with 403 vip.members_only
- * ({ reason, gate, members_only: { owner, owner_username, join_url } }).
- */
-const express = require('express');
-const multer = require('multer');
-const contracts = require('openvibe-contracts');
-const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
-
-const POST = 'space.post.write';
-const MOD = 'space.forum.manage';
-
-function createSpacesApi({ forum, viewers, limits }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
- const write = serviceCap(POST);
- const writeOrMod = serviceAnyCap([POST, MOD]);
- const p = (req) => req.params;
- // A space's moderators: a service needs space.forum.manage; a browser must be discussion
- // staff or one of the space's own moderators (the service methods decide again, with 404 for a missing space).
- const spaceMod = (req, res, next) => {
- const v = req.viewer;
- if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);
- return forum.canModerate(v, p(req).space)
- .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))
- .catch(next);
- };
- // Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
- // (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;
- // these cap requests, refused ones included, and every write that has no content limit.
- router.use(limits.reads('space.forum.read'));
- const configure = limits('space.space.configure', { minute: 30, hour: 300 }); // moderators' settings
- const editThread = limits('space.thread.edit', { minute: 30, hour: 300 });
-
- router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));
- router.post('/', serviceCap(MOD), limits('space.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
- router.put('/:space/settings', configure, spaceMod, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
- router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
- router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
- router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
- // An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
- const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');
- const withFile = (req, res, next) => one(req, res, (err) => (err
- ? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })
- : next()));
- // An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
- router.post('/:space/attachments', write, limits('space.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
- router.put('/:space/categories/:category', configure, spaceMod, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
- router.delete('/:space/categories/:category', configure, spaceMod, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
- router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/status', configure, spaceMod, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.post('/:space/threads/:slug/crosspost', write, limits('space.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
- router.post('/:space/threads', write, limits('space.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
- router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
- router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
- router.post('/:space/threads/:slug/posts', write, limits('space.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
- router.post('/:space/threads/:slug/votes', write, limits('space.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/members-only', configure, spaceMod, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
- // A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
- router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
- router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));
- // The space's own moderators (people): listed to its readers, added and removed by its moderators or staff.
- router.get('/:space/moderators', run(async (req) => await forum.moderators(req.viewer, p(req).space)));
- router.put('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.addModerator(req.viewer, p(req).space, p(req).subject)));
- router.delete('/:space/moderators/:subject', configure, spaceMod, run(async (req) => await forum.removeModerator(req.viewer, p(req).space, p(req).subject)));
-
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-function createPostsApi({ forum, viewers, limits }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
- const writeOrMod = serviceAnyCap([POST, MOD]);
- // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
- router.use(limits.reads('space.forum.read'));
-
- router.put('/:id', writeOrMod, limits('space.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));
- router.post('/:id/reactions', serviceCap(POST), limits('space.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));
- router.delete('/:id', writeOrMod, limits('space.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));
- router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));
-
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-/** /api/v1/space-groups — the board index's groups: GET (everyone), PUT /:group { name, description?, position? } (moderators). */
-function createGroupsApi({ forum, viewers, limits }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
- // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
- router.use(limits.reads('space.forum.read'));
- router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));
- router.put('/:group', serviceCap(MOD), limits('space.space.configure', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.putGroup(req.viewer, req.params.group, req.body || {})));
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-module.exports = { createSpacesApi, createPostsApi, createGroupsApi };
deleted file mode 100644
deleted file mode 100644
@@ -1,76 +0,0 @@
@@ -1,76 +0,0 @@
-'use strict';
-
-/**
- * Ratings on forum posts, after Facepunch: instead of a plain like, a row of specific community judgments.
- * One rating per person per post (rating again with the same one takes it back; another replaces it);
- * nobody rates their own post. A space can switch ratings off (spaces.reactions).
- */
-const REACTIONS = Object.freeze([
- { key: 'agree', emoji: '✅', label: 'Agree', group: 'positive' },
- { key: 'winner', emoji: '🏆', label: 'Winner', group: 'positive' },
- { key: 'funny', emoji: '😂', label: 'Funny', group: 'positive' },
- { key: 'informative', emoji: '💡', label: 'Informative', group: 'positive' },
- { key: 'friendly', emoji: '😊', label: 'Friendly', group: 'positive' },
- { key: 'sympathy', emoji: '❤️', label: 'Sympathy', group: 'positive' },
- { key: 'dumb', emoji: '📦', label: 'Dumb', group: 'negative' },
- { key: 'disgusting', emoji: '🤢', label: 'Disgusting', group: 'negative' },
- { key: 'bad_reading', emoji: '📖', label: 'Bad reading', group: 'utility' },
- { key: 'late', emoji: '🕒', label: 'Late', group: 'utility' },
-]);
-const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));
-const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));
-
-/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */
-async function setReaction(db, postId, subject, reaction) {
- const cur = await db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);
- if (!reaction || (cur && cur.reaction === reaction)) {
- await db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);
- return null;
- }
- await db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)
- ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`).run(postId, subject, reaction);
- return reaction;
-}
-
-const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');
-
-/**
- * Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]
- * in the fixed order, only ratings someone gave.
- */
-async function reactionsFor(db, postIds, viewerSubject = null) {
- const out = new Map();
- if (!postIds.length) return out;
- const rows = await db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);
- for (const r of rows) {
- const def = BY_KEY.get(r.reaction);
- if (!def) continue;
- if (!out.has(r.post_id)) out.set(r.post_id, new Map());
- const m = out.get(r.post_id);
- if (!m.has(r.reaction)) m.set(r.reaction, { ...def, count: 0, raters: [], mine: false });
- const e = m.get(r.reaction);
- e.count++;
- if (e.raters.length < 8) e.raters.push(r.subject_id);
- if (viewerSubject && r.subject_id === viewerSubject) e.mine = true;
- }
- return new Map([...out].map(([id, m]) => [id, [...m.values()].sort((a, b) => ORDER.get(a.key) - ORDER.get(b.key))]));
-}
-
-/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */
-async function receivedBy(db, subjects) {
- const out = new Map();
- const list = [...new Set(subjects.filter(Boolean))];
- if (!list.length) return out;
- const rows = await db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id
- WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);
- for (const r of rows) {
- const def = BY_KEY.get(r.reaction);
- if (!def) continue;
- if (!out.has(r.s)) out.set(r.s, []);
- out.get(r.s).push({ key: def.key, emoji: def.emoji, label: def.label, count: r.n });
- }
- for (const [s, l] of out) out.set(s, l.sort((a, b) => b.count - a.count || ORDER.get(a.key) - ORDER.get(b.key)).slice(0, 3));
- return out;
-}
-
-module.exports = { REACTIONS, BY_KEY, setReaction, reactionsFor, receivedBy };
deleted file mode 100644
deleted file mode 100644
@@ -1,58 +0,0 @@
@@ -1,58 +0,0 @@
-'use strict';
-
-/**
- * The Roadmap space (WS-J task 8): OpenVibe's public roadmap summary (docs/roadmap/public.json) as one
- * thread per item, written by the site itself (origin 'system', shown as OpenVibe). Every boot syncs the
- * file: a new item becomes a thread; a changed title, status, category or summary updates the item's
- * thread (the summary as an edit of its opening post, so the history stays); an item that left the file
- * keeps its thread. Items are matched by key (threads.external_key), so replies and votes stay with them.
- */
-const fs = require('fs');
-const path = require('path');
-const store = require('./store');
-const { STATUSES } = require('./service');
-
-const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');
-const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;
-
-/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */
-async function syncRoadmap(db, items, { log = console } = {}) {
- const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
- const space = await store.getSpace(db, 'roadmap');
- if (!space) return out;
- for (const item of Array.isArray(items) ? items : []) {
- const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
- const summary = String((item && item.summary) || '').trim();
- if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !summary || !STATUSES.roadmap.includes(item.status)) {
- out.skipped++;
- log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);
- continue;
- }
- const category = item.category ? await store.getCategory(db, space.id, item.category) : null;
- const categoryId = category ? category.id : null;
- const existing = await store.getThreadByKey(db, space.id, item.key);
- if (!existing) {
- await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });
- out.created++;
- continue;
- }
- let changed = false;
- if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {
- await db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);
- changed = true;
- }
- const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);
- if (opening && opening.body_markdown !== summary) { await store.editPost(db, opening.id, summary, 'system'); changed = true; }
- if (changed) out.updated++; else out.unchanged++;
- }
- return out;
-}
-
-/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */
-async function syncFromFile(db, { file = FILE, log = console } = {}) {
- let doc;
- try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }
- return await syncRoadmap(db, doc.items, { log });
-}
-
-module.exports = { syncRoadmap, syncFromFile, FILE };
deleted file mode 100644
deleted file mode 100644
@@ -1,319 +0,0 @@
@@ -1,319 +0,0 @@
-'use strict';
-
-/**
- * Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
- *
- * GET /s spaces
- * GET /s/feed.xml RSS: latest threads in public spaces
- * GET /s/:space threads (?sort=hot|new|top&page=)
- * GET /s/:space/feed.xml RSS: latest threads of one public space
- * GET /s/:space/new POST /s/:space/new start a thread
- * GET /s/:space/t/:slug a thread (?page=)
- * POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
- * POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)
- *
- * A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
- * creator's join link (403), never its posts.
- *
- * Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the
- * visitor); an Origin header from somewhere else is refused as well.
- */
-const express = require('express');
-const multer = require('multer');
-const seo = require('openvibe-shared/seo');
-const cache = require('openvibe-shared/cache-policy');
-const discovery = require('../discovery');
-const { SITE_NAME } = require('../render/layout');
-const pages = require('../render/pages');
-const forumPages = require('../render/forum');
-const boardPages = require('../render/board');
-const { ApiError } = require('../http/v1');
-
-function createForumRoutes({ forum, viewers, config }) {
- const router = express.Router();
- const withViewer = viewers.middleware({ services: false });
- const form = express.urlencoded({ extended: false, limit: '256kb' });
- // New threads and replies may carry up to 4 images (multipart `attachments`); a urlencoded form passes through.
- const imageParser = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 20, fieldSize: 64 * 1024 } }).array('attachments', 4);
- const withImages = (req, res, next) => imageParser(req, res, (err) => {
- if (err) req.uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') ? 'At most 4 images per post' : 'The images could not be read';
- next();
- });
- /** Store the form's images in Media first. → their media ids (the post then names them) */
- async function uploadImages(req) {
- if (req.uploadError) throw new ApiError(400, 'attachments.invalid', req.uploadError);
- const ids = [];
- for (const f of req.files || []) {
- if (!f || !f.size) continue;
- ids.push((await forum.uploadAttachment(req.viewer, req.params.space, f)).attachment.media_id);
- }
- return ids;
- }
- const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', cache.htmlHeaders({ private: true })).send(body);
- const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
- const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
-
- /** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
- function failPage(req, res, err, next) {
- if (!(err instanceof ApiError)) return next(err);
- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|react|crosspost|settings|status|category)$/, ''));
- if (err.code === 'vip.members_only' && err.extra) {
- return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);
- }
- const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };
- return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);
- }
-
- function sameOrigin(req, res, next) {
- const origin = req.get('origin');
- if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
- next();
- }
-
- // The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
- router.get('/s', withViewer, wrap(async (req, res, next) => {
- try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Moderators: a new space, either style.
- router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {
- try {
- if (!req.user) return login(res, '/s/new-space');
- if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');
- html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
- } catch (err) { failPage(req, res, err, next); }
- }));
- router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- const b = req.body || {};
- const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
- try {
- const out = await forum.createSpace(req.viewer, values);
- discovery.resetCaches();
- res.redirect(303, `/s/${out.space.slug}`);
- } catch (err) {
- if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
- html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);
- }
- }));
-
- // RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).
- const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
- title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
- link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
- description: space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Space.',
- language: 'en',
- selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
- items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
- }, { format: 'rss' }));
-
- router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
-
- router.get('/s/:space/feed.xml', wrap(async (req, res) => {
- const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
- if (!space) return res.status(404).type('text/plain').send('Not found');
- await threadFeed(res, space);
- }));
-
- router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
- try {
- const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
- if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- const chatRoom = forumPages.chatRoomBox(out.space, { canManage: out.viewer.can_manage_chat_room, error: typeof req.query.chat_error === 'string' ? req.query.chat_error.slice(0, 200) : null });
- const me = req.viewer && req.viewer.subject;
- const modError = typeof req.query.mod_error === 'string' ? req.query.mod_error.slice(0, 200) : null;
- if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user, chatRoom, me, modError }));
- html(res, forumPages.spacePage({ ...out, user: req.user, chatRoom, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) + boardPages.moderatorsBox(out.space, out.moderators || [], { me, error: modError }) : '' }));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
- try {
- // A members-only space: the teaser, not the form, for someone who may not post there.
- const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
- if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
- const { space } = await forum.space(req.viewer, req.params.space);
- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled() }));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
- const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
- if ((req.body || {}).members_only === '1') values.members_only = true;
- if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
- try {
- // Images go to Media only when the rest can be saved (no strays from an empty form).
- if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
- const out = await forum.createThread(req.viewer, req.params.space, values);
- discovery.resetCaches();
- res.redirect(303, out.thread.url);
- } catch (err) {
- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
- try {
- const { space } = await forum.space(req.viewer, req.params.space);
- const { categories } = await forum.categories(req.viewer, req.params.space);
- html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);
- } catch (e) { failPage(req, res, e, next); }
- }
- }));
-
- async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
- const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
- if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- if (status === 200 && await forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;
- // Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.
- if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {
- try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }
- }
- const view = { ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() };
- html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);
- }
-
- router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
- try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
- const draft = String((req.body || {}).body || '').slice(0, 40_000);
- try {
- const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
- const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined });
- res.redirect(303, out.url);
- } catch (err) {
- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
- try {
- // The reply form lives on the thread's last page; show it there with the error and the draft.
- const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});
- req.query.page = String(probe.pages);
- await renderThread(req, res, { status: err.status, error: err.message, draft });
- } catch (e) { failPage(req, res, e, next); }
- }
- }));
-
- const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
-
- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
- res.redirect(303, back(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Members-only on/off (no JS): on gates the thread to its author's VIP members.
- router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
- discovery.resetCaches();
- res.redirect(303, back(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- const b = req.body || {};
- const flags = {};
- if (b.pinned !== undefined) flags.pinned = b.pinned === '1';
- if (b.locked !== undefined) flags.locked = b.locked === '1';
- try {
- await forum.moderateThread(req.viewer, req.params.space, req.params.slug, flags);
- res.redirect(303, back(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Rate a post (no JS): the rating's button in the post's form; back to the post.
- router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- const b = req.body || {};
- try {
- await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });
- const page = Math.max(parseInt(b.page, 10) || 1, 1);
- res.redirect(303, `${back(req)}${page > 1 ? `?page=${page}` : ''}#post-${encodeURIComponent(String(b.post || ''))}`);
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Crosspost to another space (no JS).
- router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
- discovery.resetCaches();
- res.redirect(303, out.thread.url);
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Moderators: a space's settings (no JS).
- router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- const b = req.body || {};
- try {
- // The group (the board index) is staff's; a space's own moderators change the rest.
- await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });
- discovery.resetCaches();
- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's
- // moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.
- const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
- const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'space.blocked' || err.code === 'identity.unavailable');
- router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
- res.redirect(303, modsBack(req));
- } catch (err) {
- if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));
- failPage(req, res, err, next);
- }
- }));
- router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
- res.redirect(303, modsBack(req));
- } catch (err) {
- if (modRefusal(err)) return res.redirect(303, modsBack(req, err.message));
- failPage(req, res, err, next);
- }
- }));
-
- // The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
- const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
- router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });
- res.redirect(303, chatBack(req));
- } catch (err) {
- if (err instanceof ApiError && String(err.code).startsWith('chat_room.')) return res.redirect(303, chatBack(req, err.message));
- failPage(req, res, err, next);
- }
- }));
- router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.detachChatRoom(req.viewer, req.params.space);
- res.redirect(303, chatBack(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- // Status (moderators) and category (the author or moderators), no JS.
- router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });
- res.redirect(303, back(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
- try {
- await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });
- res.redirect(303, back(req));
- } catch (err) { failPage(req, res, err, next); }
- }));
-
- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
- try {
- await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
- discovery.resetCaches();
- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
- } catch (err) { failPage(req, res, err, next); }
- });
-
- return router;
-}
-
-module.exports = { createForumRoutes };
deleted file mode 100644
deleted file mode 100644
@@ -1,994 +0,0 @@
@@ -1,994 +0,0 @@
-'use strict';
-
-/**
- * Forum rules — spaces, threads and posts on top of store.js. Used by the JSON API (api.js) and
- * the server-rendered pages (render/forum.js) alike, so both enforce exactly the same things.
- *
- * Spaces: public — anyone reads; people post
- * members — signed-in people read and post (every Network account is a member for now)
- * staff — discussion moderators only; looks missing to everyone else
- * Writers: a person (browser JWT subject, or a service with space.post.write naming them in
- * X-OV-Subject), or AI output from a service (X-OV-Origin: ai — stored with origin 'ai'
- * and no author, labelled as AI). Anonymous visitors read; they do not post.
- * Locked threads take no replies or votes except from moderators. Nobody replies in a thread whose
- * author blocked them on the network (identity/blocks.js). Authors edit and delete their
- * own posts; moderators (identity/capabilities.js discussionModerator) any, and pin/lock.
- * A space also has its own moderators (space_moderators, people only): in that space they act as
- * discussion staff do — thread state and status, deletes and edits, settings, categories, members-only,
- * the chat room — and add or remove its moderators. Creating spaces, the board index (groups, a space's
- * place on it), roadmap items and staff spaces stay with discussion staff.
- * Edits keep every revision in post_versions.
- *
- * Side effects of a new thread/post: a space.thread.created / space.post.created event in the outbox
- * (../events.js) and a Discord relay delivery per mapping (relay/discord.js; off unless
- * DISCORD_RELAY_ENABLED; while its Events worker runs, it queues creates off those events instead).
- * Edits, deletes and gating queue the matching Discord edit or delete for whatever the relay had sent.
- * The network Pulse feed is OpenVibe.Community's (plan T10 D3): Space records no Pulse item.
- *
- * Members-only (OpenVibe.VIP): a space or a single thread can be for one creator's VIP members
- * (members_only_owner = the creator's usr_ subject). Reading its threads and posts, starting a
- * thread, replying, voting and editing need an active entitlement, asked of VIP
- * (POST /policies/evaluate with that owner, through vip/index.js's cache); a gated thread in a gated
- * space needs both. The owner and discussion moderators always pass; every doubt (signed out, VIP
- * down, no grant) is a 403 vip.members_only with the reason and a join link. Listings keep a gated
- * thread's title with its members_only flag, never a body; gated things never reach the Discord
- * relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author
- * gates it to their own members.
- *
- * A chat room (OpenVibe.Chat, WS-I task 4): a space's owner (its creator, or the creator whose members'
- * space it is) or discussion staff attach one room to the space; the space page links it. Chat checks
- * that the same person manages the room, with their own token (chat-rooms.js), so services cannot attach
- * (moderator services can detach). Space keeps the link: space_chat_rooms.
- */
-const store = require('./store');
-const events = require('../events');
-const { applyVote, myVotes, parseVote } = require('../votes');
-const { fail, isoTime } = require('../http/v1');
-const { createAuthors } = require('../identity/authors');
-const { createPersonLimiter } = require('../limits');
-const { discussionModerator } = require('../identity/capabilities');
-const { renderMarkdown, markdownToText } = require('../render/markdown');
-const { isUserSubject } = require('../vip');
-const { stripImageMetadata } = require('../media/strip-metadata');
-const reactions = require('./reactions');
-const blocks = require('../identity/blocks');
-const STYLES = ['feed', 'forum'];
-const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
-
-const THREADS_PER_PAGE = 25;
-/**
- * What a thread is (spaces.thread_kind decides it for new threads) and the statuses it can have:
- * discussion no status
- * request a feature request, bug or question (Feedback): open → planned → in_progress → done, or declined; staff set it
- * roadmap a roadmap item (the Roadmap space; staff and the roadmap sync start them): planned, in_progress, done, paused
- */
-const STATUSES = Object.freeze({
- request: ['open', 'planned', 'in_progress', 'done', 'declined'],
- roadmap: ['planned', 'in_progress', 'done', 'paused'],
-});
-const FIRST_STATUS = { request: 'open', roadmap: 'planned' };
-const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
-// Attachments (WS-J task 2): images only, stored in OpenVibe.Media as med_ objects (media/objects.js).
-const ATTACH_MAX = 4;
-const ATTACH_BYTES = 8 * 1024 * 1024;
-const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
-/** The image type from its first bytes (never the name or the declared type), or null. */
-function sniffImage(b) {
- if (!Buffer.isBuffer(b) || b.length < 12) return null;
- if (b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image/png';
- if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
- if (b.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
- if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
- return null;
-}
-const POSTS_PER_PAGE = 50;
-const TITLE_MIN = 3, TITLE_MAX = 200;
-const BODY_MAX = 40_000;
-const THREADS_PER_DAY = 20;
-
-function createForumService({ db, network = null, 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 || {}) });
- const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
- const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });
- const reactLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'ratings', ...(limits.reactions || {}) });
- // Views: one per viewer (person, or address) per thread per 30 minutes; memory only.
- const viewSeen = new Map();
- 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);
- /** Global discussion staff, or a person (signed in themselves) this space lists as its moderator. */
- const moderates = async (v, space) => moderator(v)
- || (!!space && person(v) && v.kind === 'user' && await store.moderatesSpace(db, space.id, v.subject));
-
- // ── a space's chat room (OpenVibe.Chat) ──────────────────
- const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
- const shapeChatRoom = (r) => (r ? {
- slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,
- url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,
- attached_at: isoTime(r.attached_at),
- } : null);
- /** The space's owner (its creator, or the creator whose VIP members' space it is) or discussion staff. */
- const canManageChatRoom = async (v, space) => await moderates(v, space) || (person(v) && (space.created_by === v.subject || space.members_only_owner === v.subject));
-
- // ── access ───────────────────────────────────────────────
- function canRead(v, space) {
- if (space.visibility === 'public') return true;
- if (space.visibility === 'members') return person(v) || moderator(v);
- return moderator(v);
- }
-
- // ── members-only (OpenVibe.VIP) ──────────────────────────
- /** { owner, join_url } for a gated row, null for an open one. */
- async function membersOnly(owner, projections = null) {
- if (!owner) return null;
- const p = (projections || await authors.projectionsFor([owner])).get(owner);
- const username = p && p.username ? p.username : null;
- return { owner, owner_username: username, join_url: vip ? vip.joinUrl(owner, username) : null };
- }
-
- /**
- * Throw 403 vip.members_only unless the viewer may use every gated level named: the space and/or
- * the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).
- */
- async function requireMembership(v, space, thread = null) {
- if (await moderates(v, space)) return;
- const gates = [];
- if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });
- if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });
- for (const g of gates) {
- const subject = person(v) ? v.subject : null;
- const d = vip ? await vip.check({ subject, type: g.type, id: g.id, owner: g.owner })
- : (subject && subject === g.owner ? { allow: true } : { allow: false, reason: 'vip_unavailable' });
- if (d.allow) continue;
- const mo = await membersOnly(g.owner);
- fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {
- reason: d.reason || 'denied', gate: g.type, members_only: mo,
- space: { slug: space.slug, name: space.name, description: space.description || null },
- // A thread's title is shown only for its own gate: inside a members-only space it is not listed, and
- // post ids are sequential (GET /posts/:id/versions would name every thread in the space).
- thread: thread && g.type === 'thread' ? { slug: thread.slug, title: thread.title } : null,
- });
- }
- }
-
- /** A creator subject a viewer may gate something to: themselves, or anyone for moderators (mod: of the space). */
- function gateOwner(v, requested, fallbackOwner, mod) {
- if (requested === null || requested === false || requested === undefined) return null;
- const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);
- if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');
- if (!mod && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');
- return owner;
- }
-
- /** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
- async function spaceFor(v, slug) {
- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
- if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');
- if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');
- return space;
- }
-
- async function threadFor(v, spaceSlug, threadSlug) {
- const space = await spaceFor(v, spaceSlug);
- const thread = await store.getThreadBySlug(db, space.id, threadSlug);
- if (!thread) fail(404, 'thread.not_found', 'No such thread');
- return { space, thread };
- }
-
- async function postFor(v, postId) {
- const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;
- const thread = post ? await store.getThread(db, post.thread_id) : null;
- const space = thread ? await store.getSpaceById(db, thread.space_id) : null;
- if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
- // A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).
- try { await spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }
- return { post, thread, space };
- }
-
- /** Who a write is attributed to. → { author, origin, key } (key: the rate-limit bucket) */
- function writer(v) {
- if (v && v.kind === 'service' && v.origin === 'ai') return { author: null, origin: 'ai', key: null };
- if (person(v)) return { author: v.subject, origin: 'user', key: `s:${v.subject}` };
- return fail(401, 'auth.required', 'Sign in with your OpenVibe account to post');
- }
-
- function mayPostIn(v, space) {
- if (space.visibility === 'staff' && !moderator(v)) fail(403, 'space.staff_only', 'Only staff post here');
- }
-
- const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();
- function cleanBody(b) {
- const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');
- if (!body.trim()) fail(400, 'post.empty', 'Write something first');
- if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);
- return body;
- }
-
- // ── shapes ───────────────────────────────────────────────
- const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
-
- const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
-
- async function shapeThread(t, space, v, projections, votes) {
- const mo = t.members_only_owner || null;
- const p = mo && projections ? projections.get(mo) : null;
- return {
- id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
- kind: t.kind || 'discussion', status: t.status || null,
- category: t.category_id ? shapeCategory(await store.getCategoryById(db, t.category_id)) : null,
- members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
- author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
- pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,
- pages: Math.max(Math.ceil(((t.reply_count || 0) + 1) / POSTS_PER_PAGE), 1),
- last_post: t.last_post_id ? { id: t.last_post_id, author: authors.author(t.last_author_subject, t.last_origin, projections || new Map()) } : null,
- crosspost_of: t.crosspost_of || null,
- last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
- my_vote: (votes && votes.get(t.id)) || 0,
- };
- }
-
- const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });
- /** post id → [attachment], in order. */
- async function attachmentsOf(postIds) {
- const out = new Map();
- if (!postIds.length) return out;
- const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
- for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
- return out;
- }
-
- /** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */
- async function claimable(w, ids) {
- if (ids === undefined || ids === null) return [];
- const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];
- if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);
- if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');
- const rows = (await Promise.all(list.map(async (id) => (MED_ID.test(id) ? await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null))));
- if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
- return rows;
- }
- async function attach(rows, postId) {
- const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
- for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
- }
-
- function shapePost(p, v, projections, attachments = null, mod = moderator(v)) {
- const deleted = !!p.deleted_at;
- const mine = person(v) && p.author_subject === v.subject;
- return {
- attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),
- id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
- author: deleted ? null : authors.author(p.author_subject, p.origin, projections, p.relay_author),
- body_markdown: deleted ? null : p.body_markdown,
- body_html: deleted ? null : renderMarkdown(p.body_markdown),
- revision: p.revision, deleted,
- created_at: isoTime(p.created_at), updated_at: isoTime(p.updated_at),
- can_edit: !deleted && (mine || mod),
- };
- }
-
- async function shapeThreads(rows, spaceOf, v) {
- const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);
- const votes = await myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
- return (await Promise.all(rows.map(async (r) => await shapeThread(r, spaceOf(r), v, projections, votes))));
- }
-
- async function shapeSpace(s, mo = null) {
- return {
- slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
- thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
- style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,
- group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,
- parent: s.parent_slug ? { slug: s.parent_slug, name: s.parent_name } : null,
- post_count: s.post_count != null ? s.post_count : undefined,
- members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
- thread_count: s.thread_count != null ? s.thread_count : undefined,
- last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
- chat_room: shapeChatRoom(await chatRoomRow(s.id)),
- };
- }
-
- /**
- * Newly gated threads leave Discord at once: what the relay had not sent yet is skipped and
- * what it had sent is deleted there (reads and sends re-check as well).
- */
- async function hideGated(threadIds) {
- if (!threadIds.length) return;
- if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
- }
-
- async function removeThread(v, thread, space) {
- const mine = person(v) && thread.author_subject === v.subject;
- if (!mine && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
- await db.tx(async () => {
- await store.softDeleteThread(db, thread.id);
- if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
- });
- if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
- pingThread(space, thread, { withSpace: true });
- return { ok: true, id: thread.id, deleted: 'thread' };
- }
-
- /**
- * Where a thread came from and where else it was crossposted, as far as the viewer may see.
- * → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }
- */
- async function crosspostInfo(v, thread) {
- const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));
- let from = null;
- if (thread.crosspost_of) {
- const t = await store.getThread(db, thread.crosspost_of);
- const sp = t ? await store.getSpaceById(db, t.space_id) : null;
- if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
- }
- const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))
- .map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))
- .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
- const targets = person(v) && !thread.members_only_owner
- ? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
- .map((sp) => ({ slug: sp.slug, name: sp.name }))
- : [];
- return { from, to, targets };
- }
-
- /** Where a space sits on the board index (breadcrumbs). → { group, parent } */
- async function placeOf(space) {
- const g = space.group_id ? await db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;
- const p = space.parent_id ? await db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;
- return { group: g || null, parent: p || null };
- }
-
- /** A space's moderators, with who they are. → [{ subject, username, display_name, added_by, added_at }] */
- async function moderatorList(space) {
- const rows = await store.listModerators(db, space.id);
- const projections = await authors.projectionsFor(rows.map((r) => r.subject_id));
- return rows.map((r) => {
- const a = authors.author(r.subject_id, 'user', projections);
- return { subject: r.subject_id, username: a ? a.username : null, display_name: a ? a.display_name : null, added_by: r.added_by || null, added_at: isoTime(r.created_at) };
- });
- }
-
- /**
- * The person a moderator form or API call names: a Network user subject (usr_…) as it is, or an @username
- * resolved through the Network (identity.subject.resolve). 400 when it is neither, 404 when nobody holds the name.
- */
- async function moderatorSubject(who) {
- const s = String(who || '').trim();
- if (isUserSubject(s)) return s;
- if (!/^@?[A-Za-z0-9_.-]{1,40}$/.test(s)) fail(400, 'moderator.invalid_subject', 'Name the person by their @username');
- let subject = null;
- if (network && network.subjectForUsername) {
- try { subject = await network.subjectForUsername(s); }
- catch { fail(503, 'identity.unavailable', 'Accounts cannot be looked up right now; try again in a minute'); }
- }
- if (!subject) fail(404, 'moderator.unknown_user', `No OpenVibe account is called @${s.replace(/^@/, '')}`);
- return subject;
- }
-
- /** The space, when the viewer moderates it: 404 (no such space), else 403. */
- async function moderatedSpace(v, slug, what) {
- const space = await spaceFor(v, slug);
- if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);
- return space;
- }
-
- /** Validated space fields from a settings body (only what was sent). */
- async function spaceFields(body, space) {
- const f = {};
- if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }
- if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;
- if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum'); f.style = body.style; }
- for (const k of ['votes', 'reactions']) if (body[k] !== undefined) f[k] = (body[k] === true || body[k] === 1 || body[k] === '1' || body[k] === 'true') ? 1 : 0;
- if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
- if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }
- if (body.group !== undefined) {
- const g = body.group ? await db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;
- if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');
- f.group_id = g ? g.id : null;
- }
- if (body.parent !== undefined) {
- const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;
- if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');
- if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');
- if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
- f.parent_id = p ? p.id : null;
- }
- return f;
- }
-
- // ═════════════════════════════════════════════════════════
- return {
- SORTS: store.SORTS,
- THREADS_PER_PAGE,
- POSTS_PER_PAGE,
-
- isModerator: moderator,
- /** Whether the viewer moderates this space (global discussion staff, or one of its own moderators). */
- async canModerate(v, slug) {
- if (moderator(v)) return true;
- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
- return !!space && await moderates(v, space);
- },
-
- /** A space's own moderators — whoever can read the space. → { space, moderators } */
- async moderators(v, spaceSlug) {
- const space = await spaceFor(v, spaceSlug);
- await requireMembership(v, space);
- return { space: space.slug, moderators: await moderatorList(space) };
- },
-
- /**
- * Add a person as one of the space's moderators — its moderators or discussion staff. A Network user
- * subject (usr_…); not someone who blocked the one adding them, or whom they blocked. Idempotent.
- */
- async addModerator(v, spaceSlug, who) {
- const space = await moderatedSpace(v, spaceSlug, 'add moderators');
- const subject = await moderatorSubject(who);
- if (person(v) && v.subject !== subject && (await blocks.hasBlocked(db, subject, v.subject) || await blocks.hasBlocked(db, v.subject, subject))) {
- fail(403, 'space.blocked', 'You cannot add this person as a moderator: one of you blocked the other');
- }
- if (!await store.moderatesSpace(db, space.id, subject)) {
- await db.tx(async () => {
- await store.addModerator(db, space.id, subject, person(v) ? v.subject : (v && v.service) || null);
- await events.moderationAction(v, 'space.moderator_added', { type: 'space', id: space.slug }, { details: { subject } });
- });
- }
- return { space: space.slug, moderators: await moderatorList(space) };
- },
-
- /** Remove one of the space's moderators (themselves included) — its moderators or discussion staff. Idempotent. */
- async removeModerator(v, spaceSlug, who) {
- const space = await moderatedSpace(v, spaceSlug, 'remove moderators');
- const subject = await moderatorSubject(who);
- await db.tx(async () => {
- if (await store.removeModerator(db, space.id, subject)) await events.moderationAction(v, 'space.moderator_removed', { type: 'space', id: space.slug }, { details: { subject } });
- });
- return { space: space.slug, moderators: await moderatorList(space) };
- },
- /** The board index's groups (for the settings and new-space forms). */
- groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),
-
- /** Whether images can be attached (Media and the service principal are configured). */
- attachmentsEnabled: () => !!(media && media.configured),
-
- /** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */
- async listSpaces(v) {
- const vis = ['public'];
- if (person(v) || moderator(v)) vis.push('members');
- if (moderator(v)) vis.push('staff');
- const rows = await store.listSpaces(db, vis);
- const last = await store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));
- const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);
- const shaped = await Promise.all(rows.map(async (r) => {
- const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
- const l = last.get(r.id);
- sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
- return sp;
- }));
- // The board index: groups in order, each with its top-level spaces; child boards under their parent.
- const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));
- for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);
- const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));
- const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))
- .filter((g) => g.spaces.length);
- const other = top.filter((sp) => !sp.group);
- if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });
- return { spaces: shaped, groups };
- },
-
- /** The board index's groups — moderators create or change one { name, description?, position? }. */
- async putGroup(v, slug, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');
- if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');
- const name = cleanTitle(body.name);
- if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');
- await db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)
- ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
- .run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);
- return { group: await db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };
- },
-
- /**
- * A space's settings — moderators. { name?, description?, style?: feed|forum, votes?, reactions?, group?: slug|null,
- * parent?: slug|null, position?, kind?: discussion|request|roadmap }
- */
- async updateSpaceSettings(v, spaceSlug, body = {}) {
- const space = await moderatedSpace(v, spaceSlug, 'change it');
- // Where a space sits on the board index (its group, parent board and position) is the board index's: staff.
- if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');
- 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)) };
- },
-
- /** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */
- async createSpace(v, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');
- const slug = String(body.slug || '').trim().toLowerCase();
- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
- if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
- const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
- const fields = await spaceFields({ ...body, name: body.name }, null);
- if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');
- const style = fields.style || 'feed';
- await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),
- 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) };
- },
-
- async space(v, slug) {
- const s = await spaceFor(v, slug);
- return { space: await shapeSpace(s, await membersOnly(s.members_only_owner)) };
- },
-
- /**
- * Attach a chat room to the space { room: slug or openvibe.chat link } — the space's owner or staff,
- * signed in themselves (Chat checks they manage the room). Attaching the same room again is a no-op;
- * another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }
- */
- async attachChatRoom(v, spaceSlug, body = {}) {
- const space = await spaceFor(v, spaceSlug);
- if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');
- if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
- if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
- const slug = chatRooms.parseRoomRef(body.room);
- if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');
- const before = await chatRoomRow(space.id);
- const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });
- const same = !!before && before.room_slug === out.room.slug;
- await db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,
- room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,
- attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,
- attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`)
- .run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
- 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 };
- },
-
- /** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
- async detachChatRoom(v, spaceSlug) {
- const space = await spaceFor(v, spaceSlug);
- if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
- const before = await chatRoomRow(space.id);
- if (!before) return { detached: false, chat: null };
- 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 };
- },
-
- /** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
- async listThreads(v, spaceSlug, q = {}) {
- const space = await spaceFor(v, spaceSlug);
- await requireMembership(v, space);
- const forumStyle = space.style === 'forum';
- let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');
- if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';
- const page = Math.max(parseInt(q.page, 10) || 1, 1);
- const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
- const category = q.category ? await store.getCategory(db, space.id, q.category) : null;
- if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');
- const statuses = STATUSES[space.thread_kind] || [];
- if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');
- const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
- const index = await this.listSpaces(v);
- const self = index.spaces.find((x) => x.slug === space.slug) || {};
- const mod = await moderates(v, space);
- return {
- space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
- sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
- categories: (await store.listCategories(db, space.id)).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: mod, signed_in: person(v), can_manage_chat_room: await canManageChatRoom(v, space) },
- moderators: mod ? await moderatorList(space) : undefined,
- children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
- groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
- threads: await shapeThreads(rows, () => space, v),
- };
- },
-
- /**
- * Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.
- * People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in
- * OpenVibe.Media as a public med_ object the person owns. Name it in `attachments` when posting.
- */
- async uploadAttachment(v, spaceSlug, file) {
- if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');
- const space = await spaceFor(v, spaceSlug);
- if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');
- mayPostIn(v, space);
- await requireMembership(v, space);
- if (!file || !Buffer.isBuffer(file.buffer) || !file.buffer.length) fail(400, 'attachments.missing', 'Choose an image');
- if (file.buffer.length > ATTACH_BYTES) fail(413, 'attachments.too_large', 'Images are limited to 8 MB');
- const mime = sniffImage(file.buffer);
- if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');
- uploadLimiter.check(`s:${v.subject}`);
- const buffer = stripImageMetadata(file.buffer, mime);
- const filename = String(file.originalname || 'image').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'image';
- let stored;
- try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {
- console.warn('[Forum] attachment upload failed:', err.message);
- fail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');
- }
- uploadLimiter.record(`s:${v.subject}`);
- await db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')
- .run(stored.id, v.subject, filename, mime, buffer.length, stored.url);
- return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
- },
-
- /**
- * Rate a post { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late | null }.
- * One rating per person per post: the same one again takes it back, another replaces it. Not your own post,
- * not in a space with ratings off, not on a locked thread. → { post_id, mine, reactions }
- */
- async react(v, postId, body = {}) {
- if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
- const { post, thread, space } = await postFor(v, postId);
- if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
- await requireMembership(v, space, thread);
- if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
- if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');
- const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
- if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
- reactLimiter.check(`s:${v.subject}`);
- const mine = await reactions.setReaction(db, post.id, v.subject, reaction);
- reactLimiter.record(`s:${v.subject}`);
- const list = (await reactions.reactionsFor(db, [post.id], v.subject)).get(post.id) || [];
- return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };
- },
-
- /**
- * Crosspost a thread to another space { to: slug } — people. The new thread links back to the original and
- * quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).
- */
- async crosspost(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- const w = writer(v);
- if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');
- await requireMembership(v, space, thread);
- if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');
- const target = await spaceFor(v, String(body.to || ''));
- if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');
- mayPostIn(v, target);
- await requireMembership(v, target);
- const kind = target.thread_kind || 'discussion';
- if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');
- if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
- const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
- const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
- const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
- const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
- kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
- 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) };
- },
-
- /** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */
- async quote(v, postId) {
- const { post, thread, space } = await postFor(v, postId);
- await requireMembership(v, space, thread);
- const projections = await authors.projectionsFor([post.author_subject]);
- const a = authors.author(post.author_subject, post.origin, projections, post.relay_author);
- const name = a ? (a.username ? `@${a.username}` : a.display_name || 'Someone') : 'Someone';
- const quoted = String(post.body_markdown || '').split('\n').slice(0, 40).map((l) => `> ${l}`).join('\n');
- return { markdown: `**${name}** wrote:\n${quoted}\n\n` };
- },
-
- /** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */
- async recordView(threadId, viewerKey) {
- const key = `${threadId}|${viewerKey || '?'}`;
- const now = Date.now();
- const seen = viewSeen.get(key);
- if (seen && now - seen < 30 * 60 * 1000) return false;
- if (viewSeen.size > 50000) viewSeen.clear();
- viewSeen.set(key, now);
- await store.bumpViews(db, threadId);
- return true;
- },
-
- /** The categories of a space. */
- async categories(v, spaceSlug) {
- const space = await spaceFor(v, spaceSlug);
- return { space: space.slug, categories: (await store.listCategories(db, space.id)).map(shapeCategory) };
- },
-
- /** Create or change a category { slug, name, description?, position? } — moderators. */
- async putCategory(v, spaceSlug, slug, body = {}) {
- const space = await moderatedSpace(v, spaceSlug, 'manage categories');
- if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
- const name = cleanTitle(body.name);
- 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;
- 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. */
- async deleteCategory(v, spaceSlug, slug) {
- const space = await moderatedSpace(v, spaceSlug, 'manage categories');
- if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
- pingSpace(space);
- return { ok: true };
- },
-
- /** Move a thread to a category { category: slug | null } — its author or a moderator. */
- async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
- await requireMembership(v, space, thread);
- const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
- 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) };
- },
-
- /** A request's or roadmap item's status { status } — moderators. */
- async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators change a status');
- const statuses = STATUSES[thread.kind] || [];
- 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) };
- },
-
- /** A thread with a page of its posts. ?page= */
- async getThread(v, spaceSlug, threadSlug, q = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- await requireMembership(v, space, thread);
- const page = Math.max(parseInt(q.page, 10) || 1, 1);
- const { rows, total } = await store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
- const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
- const votes = await myVotes(db, 'thread', [thread.id], v && v.subject);
- const ids = rows.map((p) => p.id);
- const att = await attachmentsOf(ids);
- const rated = space.reactions !== 0 ? await reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();
- const authorSubjects = rows.map((p) => p.author_subject);
- const stats = await store.authorStats(db, authorSubjects);
- const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();
- const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
- const mod = await moderates(v, space);
- return {
- space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },
- thread: await shapeThread(thread, space, v, projections, votes),
- posts: rows.map((p) => {
- const sp = shapePost(p, v, projections, att, mod);
- const st = p.author_subject ? stats.get(p.author_subject) : null;
- sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;
- sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,
- raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));
- sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || mod);
- return sp;
- }),
- reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
- crosspost: await crosspostInfo(v, thread),
- categories: (await store.listCategories(db, space.id)).map(shapeCategory),
- attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
- page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
- viewer: {
- signed_in: person(v),
- can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || mod) && (space.visibility !== 'staff' || moderator(v)),
- can_vote: person(v) && !thread.locked && space.votes !== 0,
- can_moderate: mod,
- can_delete: mod || (person(v) && thread.author_subject === v.subject),
- can_gate: mod || (person(v) && thread.author_subject === v.subject),
- },
- };
- },
-
- /**
- * New thread { title, body, members_only? } → { thread, post }. members_only: true gates it to
- * the author's own VIP members; { owner } names the creator (moderators, or the author themselves).
- */
- async createThread(v, spaceSlug, body = {}) {
- const space = await spaceFor(v, spaceSlug);
- const w = writer(v);
- mayPostIn(v, space);
- await requireMembership(v, space);
- const gate = gateOwner(v, body.members_only, w.author, await moderates(v, space));
- const kind = space.thread_kind || 'discussion';
- if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
- const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
- const title = cleanTitle(body.title);
- if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
- const text = cleanBody(body.body != null ? body.body : body.body_markdown);
- const images = await claimable(w, body.attachments);
- if (w.key) {
- threadLimiter.check(w.key, title);
- if (threadsPerDay > 0 && await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
- }
- const { thread, post } = await store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
- kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
- threadLimiter.record(w.key, title);
- await attach(images, post.id);
- 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])) };
- },
-
- /** Reply { body } → { post } */
- async reply(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- const w = writer(v);
- mayPostIn(v, space);
- await requireMembership(v, space, thread);
- if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
- // Platform blocks: nobody replies in a thread whose author blocked them.
- await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
- const text = cleanBody(body.body != null ? body.body : body.body_markdown);
- const images = await claimable(w, body.attachments);
- postLimiter.check(w.key, text);
- const post = await store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
- postLimiter.record(w.key, text);
- await attach(images, post.id);
- if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));
- const projections = await authors.projectionsFor([post.author_subject]);
- // Where the new post lands: its page in the thread (posts are numbered in id order).
- const position = (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])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
- },
-
- /** Edit { body } — the author (not on a locked thread) or a moderator. */
- async editPost(v, postId, body = {}) {
- const { post, thread, space } = await postFor(v, postId);
- await requireMembership(v, space, thread);
- const mine = person(v) && post.author_subject === v.subject;
- const mod = await moderates(v, space);
- if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
- if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');
- 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, null, null, mod) };
- },
-
- /** Edit history — the author or a moderator. */
- async postVersions(v, postId) {
- const { post, thread, space } = await postFor(v, postId);
- await requireMembership(v, space, thread);
- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
- const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
- return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
- },
-
- /** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
- async deletePost(v, postId) {
- const { post, thread, space } = await postFor(v, postId);
- if (!(person(v) && post.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
- if (post.is_opening) return await removeThread(v, thread, space);
- await db.tx(async () => {
- await store.softDeletePost(db, post.id);
- if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.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 };
- },
-
- /** Delete a thread — its author or a moderator. */
- async deleteThread(v, spaceSlug, threadSlug) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- return await removeThread(v, thread, space);
- },
-
- /** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
- async voteThread(v, spaceSlug, threadSlug, body = {}) {
- const value = parseVote(body.value);
- if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
- if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');
- await requireMembership(v, space, thread);
- if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
- voteLimiter.check(`s:${v.subject}`);
- const out = await applyVote(db, 'thread', thread.id, v.subject, value);
- voteLimiter.record(`s:${v.subject}`);
- return { thread_id: thread.id, ...out };
- },
-
- /** Pin / lock { pinned?, locked? } — moderators. */
- async moderateThread(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (!await moderates(v, space)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
- const flags = {};
- if (body.pinned !== undefined) flags.pinned = !!body.pinned;
- if (body.locked !== undefined) flags.locked = !!body.locked;
- if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
- const next = await db.tx(async () => {
- const r = await store.setThreadFlags(db, thread.id, flags);
- if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
- return r;
- });
- const projections = await authors.projectionsFor([next.author_subject]);
- pingThread(space, next);
- return { thread: await shapeThread(next, space, v, projections, null) };
- },
-
- /**
- * Members-only for a thread { owner: 'usr_…' | null } (or { members_only: … }). Its author gates
- * it to themselves (and opens it again); moderators name any creator. Gating cancels pending
- * Discord relay deliveries.
- */
- async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- const mine = person(v) && thread.author_subject === v.subject;
- const mod = await moderates(v, space);
- if (!mine && !mod) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
- await requireMembership(v, space, null);
- const requested = body.members_only !== undefined ? body.members_only : body.owner;
- const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject, mod);
- if (!owner && thread.members_only_owner && !mod && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
- 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) };
- },
-
- /** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
- async setSpaceMembersOnly(v, spaceSlug, body = {}) {
- const space = await moderatedSpace(v, spaceSlug, 'make it members-only');
- const requested = body.members_only !== undefined ? body.members_only : body.owner;
- const owner = gateOwner(v, requested === undefined ? null : requested, null, true);
- 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)) };
- },
-
- /** Latest threads in public spaces, with their opening post (sitemap, feeds). */
- async recentPublic({ limit = 50, space = null } = {}) {
- const rows = await store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');
- return (await Promise.all(rows.map(async (t) => ({ ...t, opening: (await opening.get(t.id) || {}).body_markdown || '' }))));
- },
-
- /** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
- async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },
- };
-}
-
-module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage, STYLES, REACTIONS: reactions.REACTIONS };
deleted file mode 100644
deleted file mode 100644
@@ -1,330 +0,0 @@
@@ -1,330 +0,0 @@
-'use strict';
-
-/**
- * Forum store — pure SQL over spaces / threads / posts / post_versions (server/db.js). Policy
- * lives in service.js, votes in server/votes.js.
- *
- * A thread's opening post is its first post (is_opening = 1); reply_count counts the other
- * live posts. Deletes are soft: a deleted post keeps its place (a tombstone) so the posts
- * after it keep their numbering; a deleted thread disappears from every listing.
- */
-
-const SORTS = ['hot', 'new', 'top', 'active'];
-
-// ── Spaces ───────────────────────────────────────────────────
-
-async function listSpaces(db, visibilities) {
- return await db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,
- ps.slug AS parent_slug, ps.name AS parent_name,
- (SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
- (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,
- (SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at
- FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id
- WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})
- ORDER BY g.position IS NULL, g.position, s.position, s.id ASC`).all(...visibilities);
-}
-
-/**
- * The newest live post in each space (the board index's "Last post"), members-only threads never.
- * → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }
- */
-async function lastPosts(db, spaceIds) {
- const out = new Map();
- const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id
- FROM posts p JOIN threads t ON t.id = p.thread_id
- WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL
- ORDER BY p.id DESC LIMIT 1`);
- for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }
- return out;
-}
-
-async function listGroups(db) {
- return await db.prepare('SELECT * FROM space_groups ORDER BY position, lower(name)').all();
-}
-
-/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */
-async function updateSpace(db, id, fields) {
- const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];
- const keys = Object.keys(fields).filter((k) => allowed.includes(k));
- if (keys.length) await db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });
- return await getSpaceById(db, id);
-}
-
-async function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {
- await db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);
- return await getSpace(db, slug);
-}
-
-/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */
-async function authorStats(db, subjects) {
- const out = new Map();
- const list = [...new Set(subjects.filter(Boolean))];
- if (!list.length) return out;
- const rows = await db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts
- WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);
- for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });
- return out;
-}
-
-/** One more view of a thread (the forum's Views column). */
-async function bumpViews(db, threadId) {
- await db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);
-}
-
-async function getSpace(db, slug) {
- return await db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;
-}
-
-async function getSpaceById(db, id) {
- return await db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;
-}
-
-// ── Threads ──────────────────────────────────────────────────
-
-/** URL slug from a title: lowercase ascii words joined by '-', at most 60 characters. */
-function slugify(title) {
- const s = String(title || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
- .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '');
- return s || 'thread';
-}
-
-async function uniqueSlug(db, spaceId, base) {
- const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
- if (!await taken.get(spaceId, base)) return base;
- for (let n = 2; n < 10_000; n++) {
- const candidate = `${base.slice(0, 54)}-${n}`;
- if (!await taken.get(spaceId, candidate)) return candidate;
- }
- throw new Error('Could not find a free thread slug');
-}
-
-async function getThread(db, id) {
- return await db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;
-}
-
-async function getThreadBySlug(db, spaceId, slug) {
- return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;
-}
-
-/** New thread + its opening post, in one transaction. → { thread, post } */
-async function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {
- return await db.tx(async () => {
- const slug = await uniqueSlug(db, space_id, slugify(title));
- const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
- .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);
- const threadId = info.lastInsertRowid;
- const p = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?) RETURNING id')
- .run(threadId, author_subject, origin, body_markdown);
- const thread = await getThread(db, threadId);
- const space = await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);
- // A members-only (VIP) space's threads are not public either.
- await require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // space.thread.created
- return { thread, post: await getPost(db, p.lastInsertRowid) };
- });
-}
-
-/**
- * One page of a space's threads. Pinned threads lead every sort.
- * hot ov_hot(score, age in hours at `now`) — see server/db.js for the formula
- * new newest first
- * top highest score first
- * → { rows, total }
- */
-async function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {
- const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
- const order = {
- hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
- new: 'pinned DESC, created_at DESC, id DESC',
- top: 'pinned DESC, score DESC, created_at DESC, id DESC',
- active: 'pinned DESC, last_activity_at DESC, id DESC',
- }[SORTS.includes(sort) ? sort : 'hot'];
- const where = 'space_id = @space AND deleted_at IS NULL AND (@category::bigint IS NULL OR category_id = @category) AND (@status::text IS NULL OR status = @status)';
- const params = { space: spaceId, category: categoryId, status };
- // last_* : the newest live post, for the forum's "Last post" column.
- const rows = await db.prepare(`SELECT threads.*,
- (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,
- (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,
- (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id
- FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
- .all({ ...params, now: nowSql, limit, offset });
- const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
- return { rows, total };
-}
-
-// ── Categories (WS-J task 1) ─────────────────────────────────
-
-async function listCategories(db, spaceId) {
- return await db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count
- FROM categories c WHERE c.space_id = ? ORDER BY c.position, lower(c.name)`).all(spaceId);
-}
-
-async function getCategory(db, spaceId, slug) {
- return await db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;
-}
-
-async function getCategoryById(db, id) {
- return id ? await db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;
-}
-
-async function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {
- await db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
- .run(spaceId, slug, name, description, position);
- return await getCategory(db, spaceId, slug);
-}
-
-/** Threads keep their place; they just lose the category. */
-async function deleteCategory(db, spaceId, slug) {
- return await db.tx(async () => {
- const c = await getCategory(db, spaceId, slug);
- if (!c) return false;
- await db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);
- await db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);
- return true;
- });
-}
-
-async function setThreadCategory(db, id, categoryId) {
- await db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);
- return await getThread(db, id);
-}
-
-async function setThreadStatus(db, id, status) {
- await db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);
- return await getThread(db, id);
-}
-
-async function getThreadByKey(db, spaceId, key) {
- return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;
-}
-
-/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
-async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
- return await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
- WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (?::text IS NULL OR s.slug = ?)
- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
- ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
-}
-
-async function setThreadFlags(db, id, { pinned, locked }) {
- const sets = [], params = [];
- if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
- if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }
- if (sets.length) await db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
- return await getThread(db, id);
-}
-
-/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */
-async function setThreadMembersOnly(db, id, owner) {
- await db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
- return await getThread(db, id);
-}
-async function setSpaceMembersOnly(db, id, owner) {
- await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
- return await getSpaceById(db, id);
-}
-
-async function softDeleteThread(db, id) {
- return (await db.prepare('UPDATE threads SET deleted_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(id)).changes;
-}
-
-/** Threads a subject opened since `sinceSql` (a datetime modifier, e.g. '-1 day'); deleted ones count. */
-async function countThreadsSince(db, subject, sinceSql) {
- return (await db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
-}
-
-// ── Posts ────────────────────────────────────────────────────
-
-async function getPost(db, id) {
- return await db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;
-}
-
-/**
- * A reply; bumps the thread's reply_count and last activity. relay_author: the name a reply written
- * on Discord carries (origin 'discord', no author subject; server/relay/inbound.js).
- */
-async function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {
- return await db.tx(async () => {
- const info = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?) RETURNING id')
- .run(thread_id, author_subject, origin, body_markdown, relay_author);
- await db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = ov_now() WHERE id = ?').run(thread_id);
- const post = await getPost(db, info.lastInsertRowid);
- const thread = await getThread(db, thread_id);
- const space = thread && await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);
- if (thread) await require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // space.post.created
- return post;
- });
-}
-
-/** Page of a thread's posts in order (tombstones included). → { rows, total } */
-async function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
- const rows = await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);
- const { total } = await db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);
- return { rows, total };
-}
-
-/**
- * Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original
- * as revision 1, so the history is complete without copying posts nobody edits (as pastes do).
- */
-async function editPost(db, id, body, editedBy = null) {
- return await db.tx(async () => {
- const cur = await getPost(db, id);
- if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;
- if (!await db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {
- await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')
- .run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);
- }
- await db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = ov_now() WHERE id = ?').run(body, id);
- const next = await getPost(db, id);
- await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);
- return next;
- });
-}
-
-async function listPostVersions(db, postId) {
- return await db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);
-}
-
-/** Soft delete (body scrubbed); the thread's reply_count is recounted. */
-async function softDeletePost(db, id) {
- return await db.tx(async () => {
- const p = await getPost(db, id);
- if (!p || p.deleted_at) return 0;
- await db.prepare("UPDATE posts SET deleted_at = ov_now(), body_markdown = '', updated_at = ov_now() WHERE id = ?").run(id);
- await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);
- return 1;
- });
-}
-// ── per-space moderators ─────────────────────────────────────
-async function listModerators(db, spaceId) {
- return await db.prepare('SELECT subject_id, added_by, created_at FROM space_moderators WHERE space_id = ? ORDER BY created_at, subject_id').all(spaceId);
-}
-
-async function moderatesSpace(db, spaceId, subject) {
- if (!subject) return false;
- return !!await db.prepare('SELECT 1 FROM space_moderators WHERE space_id = ? AND subject_id = ?').get(spaceId, subject);
-}
-
-async function addModerator(db, spaceId, subject, addedBy) {
- await db.prepare('INSERT INTO space_moderators (space_id, subject_id, added_by) VALUES (?, ?, ?) ON CONFLICT (space_id, subject_id) DO NOTHING').run(spaceId, subject, addedBy || null);
-}
-
-async function removeModerator(db, spaceId, subject) {
- return (await db.prepare('DELETE FROM space_moderators WHERE space_id = ? AND subject_id = ?').run(spaceId, subject)).changes > 0;
-}
-
-async function countPostsSince(db, subject, sinceSql) {
- return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
-}
-
-module.exports = {
- SORTS, slugify,
- listSpaces, getSpace, getSpaceById,
- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
- listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
- lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
- listModerators, moderatesSpace, addModerator, removeModerator,
- getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,111 +0,0 @@
@@ -1,111 +0,0 @@
-'use strict';
-
-/**
- * Shared pieces of the /api/v1/* routers (spaces, posts, space-groups, relay): errors as RFC 9457
- * problems (contracts errors.problem@1, which keeps the legacy { error } field), capability
- * guards for service tokens, JSON bodies, cursors and CORS for the embeddable read/write APIs.
- */
-const express = require('express');
-const contracts = require('openvibe-contracts');
-const { checkCapability } = require('../identity/capabilities');
-
-/** A refusal with a stable problem code (e.g. 404 'thread.not_found'). */
-class ApiError extends Error {
- constructor(status, code, detail, extra) {
- super(detail || code);
- this.name = 'ApiError';
- this.status = status;
- this.code = code;
- this.extra = extra || null;
- }
-}
-const fail = (status, code, detail, extra) => { throw new ApiError(status, code, detail, extra); };
-
-/** Wrap a handler: its return value is the JSON body; ApiErrors become problems. */
-function run(fn, status = 200) {
- return async (req, res) => {
- try {
- const out = await fn(req, res);
- if (out === undefined || res.headersSent) return;
- res.status(typeof status === 'function' ? status(out) : status).json(out);
- } catch (err) {
- if (res.headersSent) return;
- if (err instanceof ApiError) {
- if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
- return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
- }
- console.error('[API]', err && err.stack ? err.stack : err);
- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
- }
- };
-}
-
-/** A service token must hold `cap`; browsers and anonymous callers pass (judged by identity later). */
-function serviceCap(cap) {
- return (req, res, next) => {
- const v = req.viewer;
- if (!v || v.kind !== 'service') return next();
- const c = checkCapability(v.claims, cap);
- if (c.allowed) return next();
- return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
- };
-}
-
-/** A service token must hold at least one of `caps` (reads: a token meant for something else is no pass). */
-function serviceAnyCap(caps) {
- return (req, res, next) => {
- const v = req.viewer;
- if (!v || v.kind !== 'service' || caps.some((c) => checkCapability(v.claims, c).allowed)) return next();
- return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `needs one of ${caps.join(', ')}`, ctx: req.ov });
- };
-}
-
-const jsonParser = express.json({ limit: '256kb' });
-/** JSON body parser whose syntax errors are problems too. */
-function jsonBody(req, res, next) {
- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
-}
-
-// ── Cursors: opaque base64url of a small JSON tuple ──────────
-function encodeCursor(values) { return Buffer.from(JSON.stringify(values)).toString('base64url'); }
-function decodeCursor(cursor, arity) {
- if (cursor == null || cursor === '') return null;
- try {
- const v = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
- if (Array.isArray(v) && v.length === arity) return v;
- } catch { /* fall through */ }
- return fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');
-}
-
-/**
- * CORS for the embeddable APIs: other OpenVibe sites call them from the browser with the
- * visitor's Network JWT as a Bearer token (never with cookies — no credentials mode).
- */
-function cors(origins) {
- const allowed = new Set(origins);
- return (req, res, next) => {
- const origin = req.get('origin');
- if (origin && allowed.has(origin)) {
- res.set('Access-Control-Allow-Origin', origin);
- res.set('Vary', 'Origin');
- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
- res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
- res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
- res.set('Access-Control-Max-Age', '600');
- }
- if (req.method === 'OPTIONS') return res.status(204).end();
- next();
- };
-}
-
-const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
-
-/** Text timestamps 'YYYY-MM-DD HH:MM:SS' (UTC) ↔ ISO. */
-function sqlTime(date = new Date()) { return new Date(date).toISOString().replace('T', ' ').slice(0, 19); }
-function isoTime(v) {
- if (!v) return null;
- const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
-}
-
-module.exports = { ApiError, fail, run, serviceCap, serviceAnyCap, jsonBody, encodeCursor, decodeCursor, cors, intIn, sqlTime, isoTime };
deleted file mode 100644
deleted file mode 100644
@@ -1,154 +0,0 @@
@@ -1,154 +0,0 @@
-'use strict';
-/**
- * Account export and deletion → Space (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at the events
- * consumer (POST /internal/events) and are applied once per export or deletion (account_data_events); the delivery is
- * answered after Network took the part or the confirmation, so a failure is redelivered without erasing twice.
- *
- * network.account.export_requested Space's part (POST /internal/account-exports/:id/parts with a service token):
- * threads, posts, attachments, votes and reactions, blocks, the cached profile
- * and the spaces they moderate.
- * network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with
- * someone else's reply anywhere beneath it stays as an authorless tombstone
- * ("[deleted]"), so the replies keep their place:
- * - a thread others posted in, and its opening post.
- * Votes and reactions go and cached counts are recomputed. Blocks both ways, the
- * cached profile and their place as a space's moderator go. Spaces the person
- * created, and moderators they added, stay without them. Space then confirms with counts.
- */
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
-const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
-const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
-const TOMBSTONE = '[deleted]';
-const ROW_LIMIT = 5000;
-
-const hasTable = async (db, t) => !!await db.prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?').get(t);
-const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);
-const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
-
-
-// ── Export ─────────────────────────────────────────────────────
-
-const EXPORTS = [
- ['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
- ['thread_votes.json', 'thread_votes', 'subject_id'], ['post_reactions.json', 'post_reactions', 'subject_id'],
- ['blocks.json', 'network_blocks', 'blocker_subject'],
- ['space_moderators.json', 'space_moderators', 'subject_id'],
-];
-
-async function exportPart(db, subject) {
- const files = [];
- const truncated = [];
- for (const [name, table, col] of EXPORTS) {
- if (!await hasColumn(db, table, col)) continue;
- // Newest first: by id where the table has one, else by its timestamp.
- const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at' : 'updated_at';
- const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
- if (!rows.length) continue;
- if (rows.length > ROW_LIMIT) truncated.push(name);
- files.push({ name, content: rows.slice(0, ROW_LIMIT) });
- }
- return { files, truncated, note: 'Post images are downloaded from their URLs.' };
-}
-
-// ── Deletion ───────────────────────────────────────────────────
-
-async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
- const erased = {};
- const retained = {};
- const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
- const S = inList(subjects);
- await db.tx(async () => {
- // Votes and reactions first, so the counts recomputed below see only what stays.
- const recount = [];
- for (const [table, item] of [['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {
- if (!await hasTable(db, table)) continue;
- const items = (await db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects)).map((r) => r.i);
- add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
- recount.push([table, items]);
- }
- // Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.
- if (await hasTable(db, 'threads')) {
- for (const t of await db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {
- const others = await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);
- if (others) { await db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }
- else { await db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }
- }
- }
- if (await hasTable(db, 'posts')) {
- for (const p of await db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {
- const others = p.is_opening && await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);
- if (others) { await db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, 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 posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }
- }
- if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
- }
- if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
- for (const [table, where, key] of [['subject_projection', `subject_id IN ${S}`, 'profile'],
- ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {
- if (!await hasTable(db, table)) continue;
- const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
- add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);
- }
- if (await hasColumn(db, 'spaces', 'created_by')) await db.prepare(`UPDATE spaces SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
- if (await hasColumn(db, 'space_moderators', 'added_by')) await db.prepare(`UPDATE space_moderators SET added_by = NULL WHERE added_by IN ${S}`).run(...subjects);
- // The cached counts and scores follow the rows that stay.
- for (const [table, items] of recount) {
- for (const i of items) {
- if (table === 'thread_votes') await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);
- }
- }
- });
- return { erased, retained };
-}
-
-// ── Events ─────────────────────────────────────────────────────
-
-function createSender({ config, fetchImpl = globalThis.fetch } = {}) {
- const { serviceAuth } = require('openvibe-contracts');
- const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });
- return async function send(path, body, retried = false) {
- const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
- if (res.status === 401 && !retried) { tokens.invalidate(); return await send(path, body, true); }
- return res;
- };
-}
-
-/** One envelope → 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>'; throws to be redelivered. */
-async function apply(db, ev, { send, log = console } = {}) {
- if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
- if (ev.source !== 'network') return 'ignored:source';
- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
- if (ev.event_type === 'network.account.export_requested') {
- if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- const seen = await db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
- if (seen && seen.sent_at) return 'unchanged';
- const part = await exportPart(db, p.subject);
- const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
- const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
- if (!outcome) throw new Error(`export part refused: ${res.status}`);
- await db.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`)
- .run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
- return outcome;
- }
- if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- let rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
- let result = 'confirmed';
- if (!rec) {
- const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
- const counts = await erase(db, subjects);
- await db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
- log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
- rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
- result = 'erased';
- }
- if (rec.sent_at) return 'unchanged';
- const o = JSON.parse(rec.outcome || '{}');
- const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
- if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
- await db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
- return result;
-}
-
-module.exports = { apply, exportPart, erase, createSender, TOPICS };
deleted file mode 100644
deleted file mode 100644
@@ -1,44 +0,0 @@
@@ -1,44 +0,0 @@
-'use strict';
-
-/**
- * Author display for threads and posts: Network subjects → names and pictures from
- * subject_projection (refreshed through the Network's resolve-batch, see network.js). AI output is
- * labelled as AI and never attributed to a person (roadmap §33).
- */
-const projections = require('./projection');
-
-const AI_DISPLAY_NAME = 'OpenVibe AI';
-
-function createAuthors({ db, network = null }) {
- /** Map subject → projection row (bounded wait for missing ones when the Network is there). */
- async function projectionsFor(subjects) {
- const list = (subjects || []).filter(Boolean);
- if (!list.length) return new Map();
- if (!network) return await projections.getProjections(db, list);
- try { return await network.projections(list); } catch { return await projections.getProjections(db, list); }
- }
-
- /**
- * { subject, username, display_name, avatar_url, profile_color } for a person, a fixed AI
- * label for AI output, the Discord name (relayName; is_relay) for a reply written on Discord,
- * null for anonymous writes.
- */
- function author(subject, origin, projections, relayName = null) {
- if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };
- if (origin === 'discord') return { subject: null, username: null, display_name: relayName || 'Discord', avatar_url: null, profile_color: null, is_relay: true, relay: 'discord' };
- if (origin === 'system' && !subject) return { subject: null, username: null, display_name: 'OpenVibe', avatar_url: null, profile_color: null, is_system: true };
- if (!subject) return null;
- const p = projections.get(subject);
- return {
- subject,
- username: p ? p.username : null,
- display_name: p ? (p.display_name || p.username) : null,
- avatar_url: p ? p.avatar_url : null,
- profile_color: p ? p.profile_color : null,
- };
- }
-
- return { projectionsFor, author };
-}
-
-module.exports = { createAuthors, AI_DISPLAY_NAME };
deleted file mode 100644
deleted file mode 100644
@@ -1,53 +0,0 @@
@@ -1,53 +0,0 @@
-'use strict';
-/**
- * Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed).
- *
- * People block each other once, on OpenVibe.Network, and Community honours it: nobody replies in a forum
- * thread whose author blocked them, replies to a comment whose author blocked them, or comments on a paste
- * (or a forum post's comment thread) whose owner blocked them. Network announces every change as
- * network.block.changed (blocker, blocked, active, a per-pair revision); ../pulse/consumer.js applies each
- * one here, keeping only the newest revision per (blocker, blocked), so a late or replayed event never
- * undoes a newer one. Moderation (hide, lock, delete, statuses) is never affected by a block.
- */
-const { validate } = require('openvibe-contracts');
-const { fail } = require('../http/v1');
-
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const ready = new WeakSet();
-
-
-/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */
-function payloadOf(event) {
- if (!event || event.source !== 'network') return 'ignored:source';
- const p = event.payload;
- if (!p || typeof p !== 'object' || !validate('network.block.changed@1', p).valid) return 'ignored:payload';
- return p;
-}
-
-/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */
-async function apply(db, p, now = Date.now()) {
- const r = await db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
- WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);
- if (!r.changes) return 'blocks:unchanged';
- return p.active ? 'blocks:blocked' : 'blocks:unblocked';
-}
-
-/** Did `blocker` block `blocked` (subjects)? */
-async function hasBlocked(db, blocker, blocked) {
- if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;
- return !!await db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);
-}
-
-/**
- * Refuse (403 space.blocked) when any of `owners` blocked `writer`. `what` names the thing in the
- * message: "You cannot <what>: its author blocked you".
- */
-async function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {
- if (!writer) return;
- for (const owner of owners) {
- if (await hasBlocked(db, owner, writer)) fail(403, 'space.blocked', `You cannot ${what}: ${whose} blocked you`);
- }
-}
-
-module.exports = { payloadOf, apply, hasBlocked, refuseIfBlocked };
deleted file mode 100644
deleted file mode 100644
@@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
-'use strict';
-
-/**
- * Capability checks for service tokens, through openvibe-contracts' manifests.
- */
-const { capabilities } = require('openvibe-contracts');
-
-/** → { allowed, code, reason } like capabilities.check(). */
-function checkCapability(claims, capabilityId) {
- return capabilities.check(claims, capabilityId);
-}
-
-/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */
-function serviceHas(viewer, capabilityId) {
- return !!(viewer && viewer.kind === 'service' && checkCapability(viewer.claims, capabilityId).allowed);
-}
-
-/**
- * Staff for the forum: a browser holding staff.moderation.discussions, or a service that vouches
- * for a staff person with X-OV-Staff: 1 and holds space.forum.manage.
- */
-function discussionStaff(viewer) {
- if (!viewer) return false;
- if (viewer.kind === 'user') return !!(viewer.discussionStaff !== undefined ? viewer.discussionStaff : viewer.staff);
- return viewer.kind === 'service' && !!viewer.vouchesStaff && serviceHas(viewer, 'space.forum.manage');
-}
-
-/**
- * A forum moderator: discussion staff, or a service holding space.forum.manage that acts as itself
- * (no X-OV-Subject) — e.g. an owner service hiding the thread of an entity it took down. A service
- * acting for a person moderates only when it vouches that person is staff. A space's own moderators
- * (space_moderators) are people, and forum/service.js asks for them separately.
- */
-function discussionModerator(viewer) {
- return discussionStaff(viewer) || (!!viewer && viewer.kind === 'service' && !viewer.subject && serviceHas(viewer, 'space.forum.manage'));
-}
-
-module.exports = { checkCapability, serviceHas, discussionStaff, discussionModerator };
deleted file mode 100644
deleted file mode 100644
@@ -1,179 +0,0 @@
@@ -1,179 +0,0 @@
-'use strict';
-
-/**
- * OpenVibe.Network identity client — who a subject is, for display.
- *
- * Space stores people as Network subject ids (usr_… / gst_…). Names and pictures come from
- * the Network's resolve-batch endpoint (capability identity.subject.resolve, called with a
- * client-credentials service token) and are cached in subject_projection. The cache is never
- * authority: an entry older than PROJECTION_TTL_MS is refreshed in the background, a missing one
- * is fetched before the response (bounded by RESOLVE_WAIT_MS so a slow Network only costs names).
- *
- * POST /internal/identity/resolve-batch
- * { subject_ids: [...] } or { system, type: 'user', ids: [...] } (≤ 500 per call)
- * → { results: { <key>: { subject: {type,id}, network_user_id, username, display_name, avatar_url, banned } | null } }
- */
-const { serviceAuth } = require('openvibe-contracts');
-const store = require('./projection');
-
-const BATCH = 500;
-const PROJECTION_TTL_MS = 6 * 60 * 60 * 1000;
-const RESOLVE_WAIT_MS = 2500;
-
-function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}) {
- const base = config.networkInternalUrl;
- const tokens = serviceAuth.createTokenClient({
- tokenUrl: `${base}/oauth/token`,
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- audience: 'openvibe.network',
- scope: 'identity.subject.resolve',
- fetchImpl,
- });
-
- /** Relative avatar paths the Network hands out are relative to the Network. */
- const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
-
- async function post(body, retried = false) {
- const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
- body: JSON.stringify(body),
- signal: AbortSignal.timeout(8000),
- });
- // A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.
- if (res.status === 401 && !retried) { tokens.invalidate(); return await post(body, true); }
- const data = await res.json().catch(() => null);
- if (!res.ok || !data || typeof data.results !== 'object') {
- const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
- err.status = res.status;
- throw err;
- }
- return data.results;
- }
-
- /** Resolve many keys in ≤500 chunks. `shape(chunk)` builds the request body. → Map key → projection|null */
- async function resolveChunks(keys, shape) {
- const out = new Map();
- const list = [...new Set(keys.map(String))];
- for (let i = 0; i < list.length; i += BATCH) {
- const chunk = list.slice(i, i + BATCH);
- const results = await post(shape(chunk));
- for (const k of chunk) out.set(k, results[k] || null);
- }
- return out;
- }
-
- /** Store a Network projection in the cache. */
- async function remember(p) {
- if (!p || !p.subject || !p.subject.id) return;
- await store.upsertProjection(db, {
- subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,
- avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,
- });
- }
-
- /** Subject ids → projections (and cache them). */
- async function resolveSubjects(subjectIds) {
- const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));
- for (const p of map.values()) await remember(p);
- return map;
- }
-
- /** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */
- async function resolveLegacy(system, ids, { cache = true } = {}) {
- const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));
- if (cache) for (const p of map.values()) await remember(p);
- return map;
- }
-
- /**
- * The subject of a Network account id, for user JWTs issued before tokens carried
- * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).
- */
- async function subjectForNetworkUser(networkUserId) {
- if (networkUserId == null || networkUserId === '') return null;
- const hit = await store.mapGet(db, 'network', 'user', networkUserId);
- if (hit && hit.target_type === 'subject') return hit.target_id;
- const map = await resolveLegacy('network', [networkUserId]);
- const p = map.get(String(networkUserId));
- if (!p || !p.subject || p.subject.type !== 'user') return null;
- await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
- return p.subject.id;
- }
-
- /** What a verified user JWT says about its holder is a fresh projection too. */
- async function rememberClaims(subjectId, claims) {
- if (!subjectId || !claims) return;
- await store.upsertProjection(db, {
- subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,
- avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,
- });
- }
-
- /**
- * The user subject an @username names right now (a leading @ and case are ignored), or null when nobody holds it
- * or the account is banned or deleted (Network answers 404). Remembered in the projection cache like any lookup.
- */
- async function subjectForUsername(username, retried = false) {
- const name = String(username || '').trim().replace(/^@/, '');
- if (!/^[A-Za-z0-9_.-]{1,40}$/.test(name)) return null;
- const res = await fetchImpl(`${base}/internal/identity/resolve?username=${encodeURIComponent(name)}`, {
- headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
- signal: AbortSignal.timeout(8000),
- });
- if (res.status === 401 && !retried) { tokens.invalidate(); return await subjectForUsername(name, true); }
- if (res.status === 404) return null;
- const p = await res.json().catch(() => null);
- if (!res.ok || !p || !p.subject) {
- const err = new Error(`resolve ${res.status}: ${(p && (p.detail || p.error)) || 'bad response'}`);
- err.status = res.status;
- throw err;
- }
- if (p.subject.type !== 'user') return null;
- await remember(p);
- return p.subject.id;
- }
-
- let inflight = null;
- const pending = new Set();
- /** Background refresh of stale entries, batched and deduplicated. */
- function refreshLater(ids) {
- for (const id of ids) pending.add(id);
- if (inflight) return;
- inflight = Promise.resolve().then(async () => {
- while (pending.size) {
- const batch = [...pending].slice(0, BATCH);
- batch.forEach((id) => pending.delete(id));
- try { await resolveSubjects(batch); } catch (err) { console.warn('[Identity] projection refresh failed:', err.message); pending.clear(); }
- }
- }).finally(() => { inflight = null; });
- }
-
- /**
- * Projections for display. Missing entries are fetched now (bounded wait); stale ones are
- * served as they are and refreshed behind the response. → Map subject_id → projection row
- */
- async function projections(subjectIds) {
- const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];
- if (!ids.length) return new Map();
- let map = await store.getProjections(db, ids);
- const missing = ids.filter((id) => !map.has(id));
- const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);
- if (stale.length) refreshLater(stale);
- if (missing.length) {
- let timer;
- const wait = new Promise((resolve) => { timer = setTimeout(resolve, RESOLVE_WAIT_MS); });
- // The lookup keeps running (and fills the cache) if the wait runs out first.
- const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));
- await Promise.race([lookup, wait]);
- clearTimeout(timer);
- map = await store.getProjections(db, ids);
- }
- return map;
- }
-
- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };
-}
-
-module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
deleted file mode 100644
deleted file mode 100644
@@ -1,55 +0,0 @@
@@ -1,55 +0,0 @@
-'use strict';
-
-/**
- * The identity bookkeeping Space keeps locally (moved here from Community's pastes/store.js when the
- * forum became its own service): a display cache of what the Network says about a subject, and the
- * map from pre-subject Network numeric ids to the subjects they became. Neither is authority — the
- * Network is (ADR-009); both are only ever read to render a name or resolve an old id.
- */
-
-/** Map subject id → projection row. */
-async function getProjections(db, subjectIds) {
- const out = new Map();
- const ids = [...new Set((subjectIds || []).filter(Boolean))];
- const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');
- for (const id of ids) { const r = await stmt.get(id); if (r) out.set(id, r); }
- return out;
-}
-
-/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */
-async function upsertProjection(db, p) {
- if (!p || !p.subject_id) return;
- await db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)
- VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, ov_now())
- ON CONFLICT(subject_id) DO UPDATE SET
- username = COALESCE(excluded.username, subject_projection.username),
- display_name = COALESCE(excluded.display_name, subject_projection.display_name),
- avatar_url = COALESCE(excluded.avatar_url, subject_projection.avatar_url),
- profile_color = COALESCE(excluded.profile_color, subject_projection.profile_color),
- refreshed_at = ov_now()`)
- .run({
- subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,
- avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,
- });
-}
-
-/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */
-async function subjectsByUsername(db, username) {
- return (await db.prepare('SELECT subject_id FROM subject_projection WHERE lower(username) = lower(?) ORDER BY refreshed_at DESC')
- .all(String(username))).map((r) => r.subject_id);
-}
-
-// ── Legacy id map ────────────────────────────────────────────
-
-async function mapGet(db, system, type, id) {
- return await db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
- .get(String(system), String(type), String(id)) || null;
-}
-
-async function mapSet(db, system, type, id, targetType, targetId) {
- await db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)
- .run(String(system), String(type), String(id), String(targetType), String(targetId));
-}
-
-module.exports = { getProjections, upsertProjection, subjectsByUsername, mapGet, mapSet };
deleted file mode 100644
deleted file mode 100644
@@ -1,70 +0,0 @@
@@ -1,70 +0,0 @@
-'use strict';
-/**
- * network.subject.merged → Space (roadmap WS-B task 5, ADR-029; Contracts 0.69.0). Two Network accounts became
- * one: `from` is an alias of `into`. In one transaction Space repoints what it keys by subject:
- * authorship threads, posts, attachments, spaces a moderator was added by
- * one per person per item thread votes and post reactions: the survivor's stays where both
- * had one (the other is dropped), then the item's cached score is recomputed from its rows
- * the block projection: the survivor's stays; blocking oneself is dropped
- * space moderators the folded-in account's spaces become the survivor's (one row per space and person)
- * Idempotent: the events consumer's inbox applies an event once, and a second run finds nothing under `from`.
- */
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-const AUTHORSHIP = [
- ['threads', 'author_subject'], ['posts', 'author_subject'], ['attachments', 'owner_subject'],
- ['space_moderators', 'added_by'],
-];
-// Per person per item: [table, item column, recompute(db, itemId)]
-const PER_ITEM = [
- ['thread_votes', 'thread_id', async (db, id) => await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],
- ['post_reactions', 'post_id', null],
-];
-
-const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);
-
-/** The payload, or 'ignored:<why>'. */
-function payloadOf(event) {
- if (!event || event.event_type !== 'network.subject.merged') return 'ignored:type';
- if (event.source !== 'network') return 'ignored:source';
- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
- if (!MERGE_RE.test(String(p.merge_id || '')) || !SUBJECT_RE.test(String(p.from || '')) || !SUBJECT_RE.test(String(p.into || '')) || p.from === p.into) return 'ignored:payload';
- return { merge_id: p.merge_id, from: p.from, into: p.into };
-}
-
-/** Apply one merge → 'merge:applied' (with counts logged). */
-async function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {
- const counts = await db.tx(async () => {
- const c = { authored: 0, moved: 0, dropped: 0 };
- for (const [table, col] of AUTHORSHIP) {
- if (await hasColumn(db, table, col)) c.authored += (await db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from)).changes;
- }
- for (const [table, item, recompute] of PER_ITEM) {
- if (!await hasColumn(db, table, 'subject_id')) continue;
- for (const r of await db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {
- const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);
- if (clash) { await db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }
- else { await db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }
- }
- }
- if (await hasColumn(db, 'space_moderators', 'subject_id')) {
- await db.prepare('DELETE FROM space_moderators WHERE subject_id = ? AND space_id IN (SELECT space_id FROM space_moderators WHERE subject_id = ?)').run(from, into);
- c.moved += (await db.prepare('UPDATE space_moderators SET subject_id = ? WHERE subject_id = ?').run(into, from)).changes;
- }
- if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {
- for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
- for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
- const clash = b.o === into || await db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
- if (clash) await db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
- else await db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
- }
- }
- }
- return c;
- });
- log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);
- return 'merge:applied';
-}
-
-module.exports = { payloadOf, apply };
deleted file mode 100644
deleted file mode 100644
@@ -1,155 +0,0 @@
@@ -1,155 +0,0 @@
-'use strict';
-
-/**
- * Who is calling — resolved once per request into `req.viewer`:
- *
- * { kind: 'anonymous' }
- * { kind: 'user', subject: 'usr_…'|null, staff, user: <JWT claims> }
- * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from
- * the token's subject_id; older tokens without one are resolved through the Network.
- * role admin/global_mod makes the viewer staff. X-OV-* headers are ignored for browsers.
- * { kind: 'service', service: 'svc:live', claims, subject: 'usr_…'|'gst_…'|null, origin, sourceRef, vouchesStaff }
- * A first-party service holding a Network client-credentials token for audience
- * openvibe.space. It names the person it acts for in X-OV-Subject; with no subject the
- * write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a
- * person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
- * X-OV-Staff: 1 vouches that the acting person is staff; it needs space.forum.manage.
- * Developer-app (app:…) and module (mod:…) tokens act only for the person in their
- * on_behalf_of claim: X-OV-Subject naming anyone else is refused, and sandbox tokens are too.
- *
- * Identity never comes from a request body or query. A request that presents a service token is
- * judged on that token alone: a bad one is refused, never downgraded to anonymous.
- */
-const contracts = require('openvibe-contracts');
-const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
-const { checkCapability } = require('./capabilities');
-
-const { ids, capabilities, serviceAuth, http } = contracts;
-const { staff: staffMap } = require('openvibe-contracts');
-// The roles that hold staff moderation today (kept for callers; gates ask staffMap.can instead).
-const STAFF_ROLES = new Set(['admin', 'global_mod']);
-const PRINCIPAL_SUB = /^(svc|app|mod):/;
-const AUDIENCE = 'openvibe.space';
-
-class ViewerError extends Error {
- constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
-}
-
-const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
-
-function isActingSubject(v) {
- return typeof v === 'string' && (ids.isSubjectId('user', v) || ids.isSubjectId('guest', v));
-}
-
-/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */
-function hasCap(viewer, capabilityId) {
- return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);
-}
-
-function createViewerResolver({ auth, config, network, revocations = null }) {
- async function fromServiceToken(req, token) {
- const publicKey = await auth.ensureKey();
- if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
- if (!r.ok) throw new ViewerError(401, r.code, r.reason);
- const claims = r.claims;
- // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person
- // who authorized them (the token's on_behalf_of), never for whoever X-OV-Subject names.
- // Only first-party service principals (svc:…) are trusted to name the acting subject.
- const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');
- if (!firstParty && claims.env !== undefined && claims.env !== 'production') {
- throw new ViewerError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.space');
- }
-
- const originHeader = req.get('x-ov-origin');
- if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
- const origin = originHeader === 'ai' ? 'ai' : 'user';
-
- const subjectHeader = req.get('x-ov-subject');
- let subject = null;
- if (subjectHeader) {
- if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
- if (!firstParty && subjectHeader !== claims.on_behalf_of) {
- throw new ViewerError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');
- }
- // AI output is never attributed to a person, even if the caller also names one.
- subject = origin === 'ai' ? null : subjectHeader;
- } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {
- subject = origin === 'ai' ? null : claims.on_behalf_of;
- }
-
- let sourceRef = null;
- const refHeader = req.get('x-ov-source-ref');
- if (refHeader) {
- try { sourceRef = JSON.parse(refHeader); } catch { sourceRef = undefined; }
- if (!sourceRef || !contracts.validate('common.entity-ref@1', sourceRef).valid) {
- throw new ViewerError(400, 'request.invalid_source_ref', 'X-OV-Source-Ref must be a JSON EntityRef {service, type, id}');
- }
- }
-
- // X-OV-Staff: 1 vouches that the acting person is forum staff; space.forum.manage is what makes
- // the header acceptable (identity/capabilities.js discussionStaff).
- let vouchesStaff = false;
- if (req.get('x-ov-staff') === '1') {
- const c = checkCapability(claims, 'space.forum.manage');
- if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs space.forum.manage');
- vouchesStaff = true;
- }
- return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff: false, vouchesStaff };
- }
-
- async function fromUserToken(token) {
- const claims = await auth.verify(token);
- if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
- // Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).
- if (revocations && revocations.isRevoked(claims)) return null;
- let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
- if (!subject && network && claims.sub != null) {
- try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
- }
- if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
- const user = claimsToUser(claims);
- if (subject && !user.subject_id) user.subject_id = subject;
- // Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.
- // Space is the forum, so discussion staff is the grant that matters (there are no pastes here).
- const discussionStaff = staffMap.can(claims, 'staff.moderation.discussions');
- return { kind: 'user', subject, staff: discussionStaff, discussionStaff, origin: 'user', user, token };
- }
-
- /**
- * Resolve the caller. opts.services=false (server-rendered pages) treats a service token as
- * no identity at all: pages are for browsers.
- */
- async function resolve(req, opts = {}) {
- const header = String(req.headers.authorization || '');
- if (header.startsWith('Bearer ')) {
- const token = header.slice(7).trim();
- const payload = decodeJwtPayload(token);
- if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
- if (opts.services === false) return ANONYMOUS;
- return await fromServiceToken(req, token);
- }
- }
- const token = extractToken(req);
- if (!token) return ANONYMOUS;
- return (await fromUserToken(token)) || ANONYMOUS;
- }
-
- /** Express middleware: sets req.viewer (and req.user/req.token for the page renderers). */
- function middleware(opts = {}) {
- return async (req, res, next) => {
- try {
- req.viewer = await resolve(req, opts);
- if (req.viewer.kind === 'user') { req.user = req.viewer.user; req.token = req.viewer.token; }
- next();
- } catch (err) {
- if (!(err instanceof ViewerError)) return next(err);
- http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
- }
- };
- }
-
- return { resolve, middleware };
-}
-
-module.exports = { createViewerResolver, hasCap, isActingSubject, ANONYMOUS, STAFF_ROLES, ViewerError };
@@ -8,59 +8,28 @@ const config = require('./config');
@@ -8,59 +8,28 @@ const config = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
(async () => {
(async () => {
- // PostgreSQL first (migrations run as the owner), then the app: its revocation cutoffs load before it serves.
+ // PostgreSQL first (migrations run as the owner). Space holds no user content yet, but the
+ // database carries the shared schema and the readiness check reports it.
await require('./db').initDb(config);
await require('./db').initDb(config);
const app = await createApp();
const app = await createApp();
- if (config.discordRelay.enabled) {
- const rs = await app.locals.relay.status();
- const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);
- console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);
- }
- // Space → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.
- try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
- // The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).
- try {
- const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());
- if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
- } catch (err) { console.warn('[Roadmap] not synced:', err.message); }
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
- console.log(`[Space] forums: spaces, threads and posts (PostgreSQL); identity via ${config.networkUrl}`);
+ console.log(`[Space] code and dynamic pages (nothing published here yet); the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
- // Subscribe to the network events Space applies at /internal/events (idempotent; off without EVENTS_URL / SPACE_EVENTS_SECRET).
- let subscriptions = null;
- try {
- const secret = String(process.env.SPACE_EVENTS_SECRET || '').split(',')[0].trim();
- subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret });
- } catch (err) { console.warn('[Events consumer] not subscribed:', err.message); }
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
- // SIGTERM: the subscription retries stop (nothing new starts); the server stops taking connections,
- // closes idle keep-alive ones (it keeps them 65 s otherwise) and lets requests in flight finish
- // (4 s at most); then the Discord relay's drain and its Events worker's page (the gateway closes at
- // once) and the events outbox's send finish (unsent rows stay in their tables for the next start),
- // space.db closes, and the process exits 0, within the manifest's 5 s.
- const { gracefulStop, within } = require('openvibe-sdk/service');
+ // SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
+ // otherwise) and lets requests in flight finish (4 s at most); then space.db closes and the
+ // process exits 0, within the manifest's 5 s.
+ const { gracefulStop } = require('openvibe-sdk/service');
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
- stop: [
- () => { if (subscriptions) subscriptions.stop(); },
- ],
+ stop: [],
close: [
close: [
- () => within(1000, app.locals.relay && app.locals.relay.stop()),
- () => within(1500, require('./events').stop()),
async () => {
async () => {
- const ev = await require('./events').backlog().catch(() => require('./events').status());
- let relay = 'off';
- if (config.discordRelay.enabled) {
- let rs = null;
- try { rs = await app.locals.relay.status(); } catch { /* the database is gone */ }
- relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';
- }
- console.log(`[Space] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);
- if (app.locals.valkey) app.locals.valkey.close().catch(() => {});
+ console.log('[Space] stopped');
return await require('./db').closeDb();
return await require('./db').closeDb();
},
},
],
],
deleted file mode 100644
deleted file mode 100644
@@ -1,56 +0,0 @@
@@ -1,56 +0,0 @@
-'use strict';
-
-/**
- * Per-person write limits for the forum (in memory, per process). Keyed by the acting subject, so a
- * person is limited
- * the same whether they write from a browser or through a service naming them in X-OV-Subject.
- *
- * cooldownSec minimum gap between two writes
- * perMinute writes per rolling minute
- * duplicate refuse the same text twice in a row
- *
- * Anonymous writes are limited per address by the app's shared anonymous-write limiter instead.
- */
-const { ApiError } = require('./http/v1');
-
-function createPersonLimiter({ cooldownSec = 0, perMinute = 0, duplicate = true, noun = 'posts' } = {}) {
- const recent = new Map(); // subject → [{ at, text }] newest first
-
- function list(key, now) {
- const l = (recent.get(key) || []).filter((e) => now - e.at < 60_000);
- recent.set(key, l);
- return l;
- }
-
- /** Throws a 429/400 ApiError when this write would go over; call record() after it succeeds. */
- function check(key, text = null) {
- if (!key) return;
- const now = Date.now();
- const l = list(key, now);
- if (cooldownSec > 0 && l.length && now - l[0].at < cooldownSec * 1000) {
- const wait = Math.ceil((cooldownSec * 1000 - (now - l[0].at)) / 1000);
- throw new ApiError(429, 'request.rate_limited', `Please wait ${wait}s before writing again`, { retry_after: wait });
- }
- if (perMinute > 0 && l.length >= perMinute) {
- throw new ApiError(429, 'request.rate_limited', `Too many ${noun}. Please slow down.`, { retry_after: Math.ceil((60_000 - (now - l[l.length - 1].at)) / 1000) });
- }
- if (duplicate && text != null && l.length && l[0].text === text) throw new ApiError(400, 'request.duplicate', 'Duplicate — you just posted that');
- }
-
- function record(key, text = null) {
- if (!key) return;
- const l = recent.get(key) || [];
- l.unshift({ at: Date.now(), text });
- recent.set(key, l.slice(0, Math.max(perMinute, 1) + 1));
- }
-
- const sweep = setInterval(() => {
- const now = Date.now();
- for (const [k, l] of recent) if (!l.length || now - l[0].at > 120_000) recent.delete(k);
- }, 10 * 60_000);
- if (sweep.unref) sweep.unref();
-
- return { check, record };
-}
-
-module.exports = { createPersonLimiter };
deleted file mode 100644
deleted file mode 100644
@@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
-'use strict';
-
-/**
- * OpenVibe.Media Object API v2 client — images on forum posts (WS-J task 2). Space keeps a
- * `med_` reference and the public URL; the bytes live in Media under the `space` tenant, owned by
- * the person who attached them (X-OV-Subject), with Space's service token (media.object.upload,
- * namespace space):
- *
- * POST /api/v2/space/objects init { kind, visibility: 'unlisted', size_bytes, mime_type, filename, content_hash }
- * PUT /api/v2/space/objects/:id/content the bytes
- * POST /api/v2/space/objects/:id/complete Media checks size, hash and type → ready
- *
- * Public bytes are served at <media>/o/:id.
- */
-const crypto = require('crypto');
-const { serviceAuth } = require('openvibe-contracts');
-
-function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
- const configured = !!(config.oauth && config.oauth.clientSecret);
- const tokens = configured ? serviceAuth.createTokenClient({
- tokenUrl: `${config.networkInternalUrl}/oauth/token`,
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- audience: 'openvibe.media',
- scope: 'media.object.upload',
- fetchImpl,
- }) : null;
- const base = `${config.mediaInternalUrl}/api/v2/space/objects`;
-
- async function call(method, path, { json, body, headers = {} } = {}, retried = false) {
- const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };
- if (json !== undefined) h['Content-Type'] = 'application/json';
- const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });
- if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }
- const data = await res.json().catch(() => null);
- if (!res.ok) {
- const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
- err.status = res.status; err.code = data && data.code;
- throw err;
- }
- return data || {};
- }
-
- /**
- * Store one image. → { id (med_…), url, size_bytes, mime }
- * owner: the person (usr_…) the object belongs to, when there is one; kind: 'file' (post images) or
- * 'screenshot' (screenshot pastes); visibility 'unlisted': served to anyone with the link (the object id is
- * unguessable), never listed on openvibe.media — the post or paste decides who sees the link.
- */
- async function uploadImage({ buffer, mime, filename, owner = null, kind = 'file', visibility = 'unlisted', source = 'space.attachment' }) {
- if (!configured) { const e = new Error('Attachments need Community\'s service principal (OV_OAUTH_CLIENT_SECRET)'); e.status = 503; throw e; }
- const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
- const init = await call('POST', '', {
- json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },
- headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
- });
- const id = init.id || (init.object && init.object.id);
- if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
- await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });
- await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });
- return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
- }
-
- return { configured, uploadImage };
-}
-
-module.exports = { createMediaObjects };
deleted file mode 100644
deleted file mode 100644
@@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
-'use strict';
-
-/**
- * Drop embedded metadata (EXIF incl. GPS, XMP, text chunks) from an uploaded image without
- * re-encoding it. Media used to do this by re-encoding with sharp; Community has no image
- * library, and the pixels don't need touching — only the side-channel segments do.
- *
- * JPEG — APP1 (EXIF/XMP) and APP13 (IPTC/Photoshop) segments and COM comments are removed;
- * APP0 (JFIF), APP2 (ICC colour profile) and APP14 (Adobe) stay.
- * PNG — eXIf, tEXt, zTXt, iTXt and tIME chunks are removed.
- * WebP — EXIF and XMP chunks are removed and the VP8X flags updated.
- * GIF — left alone (animations survive; GIF carries no EXIF).
- * Anything that doesn't parse cleanly is returned unchanged.
- */
-
-function stripJpeg(buf) {
- if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return buf;
- const parts = [buf.subarray(0, 2)];
- let i = 2;
- while (i + 4 <= buf.length) {
- if (buf[i] !== 0xff) return buf;
- const marker = buf[i + 1];
- if (marker === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts); } // start of scan: rest is image data
- if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue; }
- const len = buf.readUInt16BE(i + 2);
- if (len < 2 || i + 2 + len > buf.length) return buf;
- const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
- if (!drop) parts.push(buf.subarray(i, i + 2 + len));
- i += 2 + len;
- }
- return buf;
-}
-
-const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
-const PNG_DROP = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);
-function stripPng(buf) {
- if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) return buf;
- const parts = [PNG_SIG];
- let i = 8;
- while (i + 12 <= buf.length) {
- const len = buf.readUInt32BE(i);
- const type = buf.toString('latin1', i + 4, i + 8);
- const end = i + 12 + len;
- if (end > buf.length) return buf;
- if (!PNG_DROP.has(type)) parts.push(buf.subarray(i, end));
- i = end;
- if (type === 'IEND') return Buffer.concat(parts);
- }
- return buf;
-}
-
-function stripWebp(buf) {
- if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf;
- const chunks = [];
- let i = 12;
- while (i + 8 <= buf.length) {
- const type = buf.toString('latin1', i, i + 4);
- const len = buf.readUInt32LE(i + 4);
- const end = i + 8 + len + (len & 1);
- if (i + 8 + len > buf.length) return buf;
- if (type !== 'EXIF' && type !== 'XMP ') chunks.push(Buffer.from(buf.subarray(i, Math.min(end, buf.length))));
- i = end;
- }
- for (const c of chunks) {
- // VP8X flags byte: bit 3 = EXIF present, bit 2 = XMP present.
- if (c.toString('latin1', 0, 4) === 'VP8X' && c.length > 8) c[8] &= ~0x0c;
- }
- const body = Buffer.concat(chunks);
- const head = Buffer.alloc(12);
- head.write('RIFF', 0, 'latin1');
- head.writeUInt32LE(body.length + 4, 4);
- head.write('WEBP', 8, 'latin1');
- return Buffer.concat([head, body]);
-}
-
-function stripImageMetadata(buf, mime) {
- try {
- if (mime === 'image/jpeg') return stripJpeg(buf);
- if (mime === 'image/png') return stripPng(buf);
- if (mime === 'image/webp') return stripWebp(buf);
- } catch { /* fall through: unchanged */ }
- return buf;
-}
-
-module.exports = { stripImageMetadata, stripJpeg, stripPng, stripWebp };
@@ -3,27 +3,15 @@
@@ -3,27 +3,15 @@
* Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).
* Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).
*
*
* db required a real query on Space's PostgreSQL database (the schema is there and answers)
* db required a real query on Space's PostgreSQL database (the schema is there and answers)
- * 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
- * media optional Media answers /healthz (image attachments on posts)
+ * network_jwks optional the Network signing key has loaded. Without it the home page and the
+ * discovery files still work, but nobody can sign in, so it degrades
+ * rather than fails
*
*
- * Request metrics come from openvibe-shared/metrics in app.js. Content counts (spaces, threads,
- * posts) are deliberately not metrics.
+ * Request metrics come from openvibe-shared/metrics in app.js.
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-const PING_TTL_MS = 15_000;
-
-function probe(url, fetchImpl) {
- return async () => {
- const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
- try { await res.body?.cancel(); } catch { /* not needed */ }
- return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };
- };
-}
-
-function createSpaceReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch, valkey = null }) {
+function createSpaceReadiness({ db, auth, config, release = null } = {}) {
const checks = [
const checks = [
{
{
name: 'db', required: true,
name: 'db', required: true,
@@ -35,33 +23,17 @@ function createSpaceReadiness({ db, auth, config, relay = null, release = null,
@@ -35,33 +23,17 @@ function createSpaceReadiness({ db, auth, config, relay = null, release = null,
return n > 0 ? { ok: true, detail: r.detail } : 'database has no tables (migrations did not run)';
return n > 0 ? { ok: true, detail: r.detail } : 'database has no tables (migrations did not run)';
},
},
},
},
- { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
if (auth.client.publicKey) return true;
if (auth.client.publicKey) return true;
// Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.
// Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.
auth.ensureKey().catch(() => {});
auth.ensureKey().catch(() => {});
- return 'Network signing key not loaded yet: sign-in and signed-in writes are unavailable';
+ return 'Network signing key not loaded yet: sign-in is unavailable';
},
},
},
},
];
];
- checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });
- return createReadiness({
- service: 'space',
- release,
- checks,
- details: async (body) => {
- 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();
- const brief = (x) => (x.enabled ? { enabled: true, state: x.state || (x.running ? 'running' : 'stopped'), last_error: x.last_error || null, ...(x.lag != null ? { lag: x.lag } : {}) } : { enabled: false, reason: x.reason });
- out.discord_relay = { enabled: true, deliveries: st.deliveries, creates_from: st.creates_from, events_worker: brief(st.events_worker), inbound: brief(st.inbound), inbound_failures: st.inbound_failures };
- }
- return out;
- },
- });
+ return createReadiness({ service: 'space', release, checks });
}
}
module.exports = { createSpaceReadiness };
module.exports = { createSpaceReadiness };
deleted file mode 100644
deleted file mode 100644
@@ -1,103 +0,0 @@
@@ -1,103 +0,0 @@
-'use strict';
-
-/**
- * /api/v1/relay — Discord relay administration, staff only (an admin/global_mod browser, or a
- * service holding space.forum.manage: acting as itself, or vouching with X-OV-Staff).
- *
- * GET /status the queue by status, the Events worker (cursor, lag,
- * last error, gaps) and the inbound gateway's state
- * GET /deliveries?status=pending|delivered|failed|dropped|skipped&action=create|edit|delete&limit=
- * what was sent, what is failing and why (failed = the dead letters)
- * POST /deliveries/:id/retry queue a delivery again, with a fresh attempt budget
- * POST /deliveries/:id/drop give up on a waiting or failed one (kept as 'dropped')
- * GET /inbound?all=1&limit= messages from Discord that did not become posts, and why
- * POST /inbound/:id/dismiss mark one seen
- * GET /mappings space → webhook variable name, Discord channel/thread, inbound
- * POST /mappings { space, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? }
- * map a space (the URL stays in the env; allow-listed names only)
- * PUT /mappings/:id { enabled?, inbound?, discord_channel_id?, discord_thread_id? }
- *
- * Responses never contain a webhook URL or the bot token, only variable names and whether they are set.
- */
-const express = require('express');
-const contracts = require('openvibe-contracts');
-const { run, jsonBody, fail, intIn } = require('../http/v1');
-const { discussionModerator } = require('../identity/capabilities');
-const forumStore = require('../forum/store');
-const { SNOWFLAKE, DELIVERY_STATUSES } = require('./discord');
-
-function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
-
- router.use((req, res, next) => {
- const v = req.viewer;
- if (discussionModerator(v)) return next();
- if (v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'Sign in as staff', ctx: req.ov });
- return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'Relay administration is for staff', ctx: req.ov });
- });
- // Per-actor limits (server/actor-limits.js), after the staff check: reads take the defaults, and
- // retries, drops, dismissals and mapping changes are a person's clicks, 30 a minute at most.
- router.use(limits.reads('space.relay.read'));
- const manage = limits('space.relay.manage', { minute: 30, hour: 300 });
-
- const idOf = (req) => (/^\d{1,15}$/.test(req.params.id) ? Number(req.params.id) : fail(404, 'route.not_found', 'Not found'));
- /** Discord ids in a mapping body: digits, or null/'' to clear; undefined when absent. */
- function discordIds(b) {
- const out = {};
- for (const k of ['discord_channel_id', 'discord_thread_id']) {
- if (b[k] === undefined) continue;
- if (b[k] !== null && b[k] !== '' && !SNOWFLAKE.test(String(b[k]))) fail(400, 'relay.invalid_discord_id', `${k} is a Discord id (15 to 21 digits), or null`);
- out[k] = b[k] === '' ? null : b[k];
- }
- return out;
- }
-
- router.get('/status', run(async () => await relay.status()));
- router.get('/deliveries', run(async (req) => {
- const status = DELIVERY_STATUSES.includes(req.query.status) ? req.query.status : null;
- const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;
- return { enabled: relay.enabled, deliveries: await relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };
- }));
- router.post('/deliveries/:id/retry', manage, run(async (req) => {
- if (!await relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
- return { ok: true };
- }));
- router.post('/deliveries/:id/drop', manage, run(async (req) => {
- if (!await relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');
- return { ok: true };
- }));
- router.get('/inbound', run(async (req) => ({
- enabled: !!inbound, failures: inbound ? await inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],
- })));
- router.post('/inbound/:id/dismiss', manage, run(async (req) => {
- if (!inbound || !await inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');
- return { ok: true };
- }));
- router.get('/mappings', run(async () => ({ enabled: relay.enabled, mappings: await relay.listMappings() })));
- router.post('/mappings', manage, jsonBody, run(async (req) => {
- const b = req.body || {};
- const space = await forumStore.getSpace(db, String(b.space || ''));
- if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
- const ref = String(b.webhook_url_ref || '');
- if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
- if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
- const ids = discordIds(b);
- return { mapping: await relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };
- }, 201));
- router.put('/mappings/:id', manage, jsonBody, run(async (req) => {
- const b = req.body || {};
- const fields = { ...discordIds(b) };
- if (b.enabled !== undefined) fields.enabled = !!b.enabled;
- if (b.inbound !== undefined) fields.inbound = !!b.inbound;
- const m = await relay.updateMapping(idOf(req), fields);
- if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');
- return { mapping: m };
- }));
-
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-module.exports = { createRelayApi };
deleted file mode 100644
deleted file mode 100644
@@ -1,242 +0,0 @@
@@ -1,242 +0,0 @@
-'use strict';
-
-/**
- * A minimal Discord gateway client (API v10, JSON, no compression) for the relay's inbound side
- * (roadmap WS-J task 6; DISCORD_RELAY_INBOUND=on with DISCORD_BOT_TOKEN).
- *
- * One session: HELLO → IDENTIFY (or RESUME on resume_gateway_url after a drop); heartbeats at the
- * interval Discord asks for, the first one jittered, and a heartbeat that was not ACKed by the next
- * one means a zombied connection (closed, then resumed); op 1 asks for a heartbeat now; op 7
- * RECONNECT resumes; op 9 INVALID_SESSION identifies again (or resumes when Discord says it may).
- * Reconnects back off exponentially (minBackoffMs · 2^n, at most maxBackoffMs), reset once READY or
- * RESUMED. Close codes that reconnecting cannot heal (4004 a wrong token; 4010–4014 shard, version
- * or intents) stop it, with the reason shown to staff; 4007 and 4009 start a fresh session.
- *
- * Intents: GUILD_MESSAGES | MESSAGE_CONTENT (privileged: turn it on for the bot in the Developer
- * Portal). MESSAGE_CREATE / MESSAGE_UPDATE / MESSAGE_DELETE / MESSAGE_DELETE_BULK go to onDispatch
- * (relay/inbound.js); everything else is dropped here. The WebSocket is the client built into
- * Node 22 (no dependency); without one the gateway stays off and says so. The token never leaves
- * the IDENTIFY/RESUME payloads and is never logged.
- */
-const INTENTS = { GUILD_MESSAGES: 1 << 9, MESSAGE_CONTENT: 1 << 15 };
-const DEFAULT_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
-const FATAL = {
- 4004: 'authentication failed: DISCORD_BOT_TOKEN is wrong',
- 4010: 'invalid shard',
- 4011: 'sharding required (the bot is in too many servers for one connection)',
- 4012: 'invalid gateway API version',
- 4013: 'invalid intents',
- 4014: 'disallowed intents: turn on the MESSAGE CONTENT intent for the bot in the Discord Developer Portal',
-};
-const FRESH = new Set([4007, 4009]);
-const DISPATCH = new Set(['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK']);
-const OPEN = 1;
-
-/** The gateway URL with the version and encoding this client speaks (a resume URL comes without them). */
-function withQuery(u) {
- try {
- const url = new URL(u);
- url.searchParams.set('v', '10');
- url.searchParams.set('encoding', 'json');
- return url.toString();
- } catch { return u; }
-}
-
-function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = globalThis.WebSocket, onDispatch = () => {}, intents = INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT, minBackoffMs = 1000, maxBackoffMs = 60_000, helloTimeoutMs = 30_000, invalidSessionMs = 1000, jitter = Math.random, now = () => Date.now() } = {}) {
- let reason = null;
- if (!token) reason = 'DISCORD_BOT_TOKEN is not set';
- else if (typeof WebSocketImpl !== 'function') reason = 'this Node has no built-in WebSocket client (Node 22 or later)';
- const on = !reason;
- const state = { status: on ? 'idle' : 'off', session_id: null, seq: null, resume_url: null, bot_user_id: null, last_error: null, last_reconnect: null, ready_at: null, connects: 0, reconnects: 0, dispatched: 0 };
- let ws = null;
- let firstBeat = null;
- let beats = null;
- let reconnectTimer = null;
- let helloTimer = null;
- let acked = true;
- let failures = 0;
- let stopped = true;
-
- const iso = () => new Date(now()).toISOString();
- function send(obj) {
- if (ws && ws.readyState === OPEN) ws.send(JSON.stringify(obj));
- }
- function clearHeartbeat() {
- if (helloTimer) clearTimeout(helloTimer);
- helloTimer = null;
- if (firstBeat) clearTimeout(firstBeat);
- if (beats) clearInterval(beats);
- firstBeat = null;
- beats = null;
- }
- function beat() {
- acked = false;
- send({ op: 1, d: state.seq });
- }
- function heartbeatEvery(ms) {
- clearHeartbeat();
- acked = true;
- const interval = Math.max(Number(ms) || 41_250, 10);
- firstBeat = setTimeout(() => {
- firstBeat = null;
- beat();
- beats = setInterval(() => {
- if (!acked) return reconnect('no heartbeat ACK (zombied connection)');
- beat();
- }, interval);
- if (beats.unref) beats.unref();
- }, Math.floor(interval * jitter()));
- if (firstBeat.unref) firstBeat.unref();
- }
-
- function connect() {
- reconnectTimer = null;
- if (stopped) return;
- const resuming = !!(state.session_id && state.resume_url);
- state.status = resuming ? 'resuming' : 'connecting';
- state.connects++;
- let sock;
- try {
- sock = new WebSocketImpl(resuming ? withQuery(state.resume_url) : url);
- } catch (err) {
- state.last_error = `cannot open the gateway: ${err.message}`;
- return scheduleReconnect();
- }
- ws = sock;
- helloTimer = setTimeout(() => { helloTimer = null; if (ws === sock) reconnect('no HELLO from the gateway'); }, helloTimeoutMs);
- if (helloTimer.unref) helloTimer.unref();
- sock.addEventListener('message', (ev) => { if (ws === sock) onMessage(ev.data); });
- sock.addEventListener('close', (ev) => { if (ws === sock) onClose(ev.code, ev.reason); });
- sock.addEventListener('error', () => { /* a close event follows */ });
- }
-
- let dispatching = Promise.resolve();
- let pendingDispatches = 0;
- function onMessage(raw) {
- let p;
- try { p = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8')); } catch { return; }
- if (!p || typeof p !== 'object') return;
- switch (p.op) {
- case 10: // HELLO
- heartbeatEvery(p.d && p.d.heartbeat_interval); // also clears the HELLO timeout
- if (state.session_id && state.seq != null) send({ op: 6, d: { token, session_id: state.session_id, seq: state.seq } });
- else send({ op: 2, d: { token, intents, properties: { os: process.platform, browser: 'openvibe-space', device: 'openvibe-space' } } });
- break;
- case 11: // HEARTBEAT_ACK
- acked = true;
- break;
- case 1: // HEARTBEAT (asked for one now)
- beat();
- break;
- case 7: // RECONNECT
- reconnect('Discord asked to reconnect');
- break;
- case 9: // INVALID_SESSION (d: may resume)
- if (!p.d) { state.session_id = null; state.seq = null; state.resume_url = null; }
- reconnect('invalid session', invalidSessionMs + Math.floor(jitter() * 4 * invalidSessionMs)); // Discord: wait 1 to 5 s
- break;
- case 0: // DISPATCH
- if (p.s != null) state.seq = p.s;
- dispatch(p.t, p.d);
- break;
- default:
- break;
- }
- }
-
- function dispatch(t, d) {
- if (t === 'READY') {
- state.session_id = d && d.session_id ? String(d.session_id) : null;
- state.resume_url = d && d.resume_gateway_url ? String(d.resume_gateway_url) : null;
- state.bot_user_id = d && d.user && d.user.id ? String(d.user.id) : null;
- state.status = 'ready';
- state.ready_at = iso();
- state.last_error = null;
- failures = 0;
- return;
- }
- if (t === 'RESUMED') {
- state.status = 'ready';
- state.last_error = null;
- failures = 0;
- return;
- }
- if (!DISPATCH.has(t)) return;
- state.dispatched++;
- // One at a time, in arrival order: a MESSAGE_UPDATE never overtakes its MESSAGE_CREATE (handling is async).
- // Idle, the handler starts at once; otherwise the event waits behind the one in progress.
- const ctx = { botUserId: state.bot_user_id };
- const go = () => { try { return Promise.resolve(onDispatch(t, d || {}, ctx)); } catch (err) { return Promise.reject(err); } };
- const run = pendingDispatches === 0 ? go() : dispatching.then(go);
- pendingDispatches++;
- dispatching = run.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message)).finally(() => { pendingDispatches--; });
- }
-
- function onClose(code, why) {
- clearHeartbeat();
- ws = null;
- if (stopped) { state.status = 'stopped'; return; }
- if (FATAL[code]) {
- state.status = 'failed';
- state.last_error = `Discord closed the gateway (${code}): ${FATAL[code]}`;
- console.warn(`[Relay] ${state.last_error}; inbound stays off until a restart`);
- return;
- }
- if (FRESH.has(code)) { state.session_id = null; state.seq = null; state.resume_url = null; }
- state.last_error = `gateway closed (${code}${why ? `: ${String(why).slice(0, 200)}` : ''})`;
- state.last_reconnect = { reason: state.last_error, at: iso() };
- scheduleReconnect();
- }
-
- /** Drop this connection (keeping the session, so the next one resumes) and connect again. */
- function reconnect(why, delayMs = null) {
- const sock = ws;
- ws = null;
- clearHeartbeat();
- state.last_error = why;
- state.last_reconnect = { reason: why, at: iso() };
- if (sock) { try { sock.close(4000, why.slice(0, 120)); } catch { /* already closed */ } }
- scheduleReconnect(delayMs);
- }
-
- function scheduleReconnect(delayMs = null) {
- if (stopped || reconnectTimer) return;
- failures++;
- state.reconnects++;
- state.status = 'waiting';
- const delay = delayMs != null ? delayMs : Math.min(minBackoffMs * Math.pow(2, Math.max(failures - 1, 0)), maxBackoffMs);
- reconnectTimer = setTimeout(connect, delay);
- if (reconnectTimer.unref) reconnectTimer.unref();
- }
-
- function start() {
- if (!on || !stopped) return;
- stopped = false;
- failures = 0;
- connect();
- }
- /** Close the connection (1000) and cancel every timer. */
- function stop() {
- stopped = true;
- clearHeartbeat();
- if (reconnectTimer) clearTimeout(reconnectTimer);
- reconnectTimer = null;
- const sock = ws;
- ws = null;
- if (sock) { try { sock.close(1000, 'stopping'); } catch { /* already closed */ } }
- if (on) state.status = 'stopped';
- return Promise.resolve();
- }
-
- function status() {
- if (!on) return { enabled: false, reason };
- return {
- enabled: true, state: state.status, session: !!state.session_id, bot_user_id: state.bot_user_id,
- ready_at: state.ready_at, connects: state.connects, reconnects: state.reconnects, last_reconnect: state.last_reconnect, events_handled: state.dispatched, last_error: state.last_error,
- };
- }
-
- return { enabled: on, reason, start, stop, status, _state: state };
-}
-
-module.exports = { createDiscordGateway, INTENTS, DEFAULT_URL, FATAL };
deleted file mode 100644
deleted file mode 100644
@@ -1,517 +0,0 @@
@@ -1,517 +0,0 @@
-'use strict';
-
-/**
- * Discord relay (DISCORD_RELAY_ENABLED; off by default), roadmap WS-J tasks 5 and 6.
- *
- * Out: a new thread in a public space with an enabled relay_mappings row is announced through the
- * mapping's Discord webhook; its replies follow (into the same channel, or the Discord thread the
- * mapping names); edits and deletes follow them through the external message map (relay_message_map).
- * In: with the gateway on, replies written on Discord come back as posts (server/relay/inbound.js).
- *
- * The queue is relay_deliveries, one row per dedupe key:
- * create thread:<id>:mapping:<m> | post:<id>:mapping:<m> queued by the Events worker from
- * space.thread.created / space.post.created (server/relay/events-worker.js), or by the
- * forum itself while that worker is off
- * edit edit:<thread|post>:<id>:mapping:<m>:r<revision> an edited opening post or reply that is on Discord
- * delete delete:<thread|post>:<id>:mapping:<m> deleted, moderated away, or gated members-only
- * A create is POSTed with ?wait=true, so Discord answers with the message and its id goes into the map;
- * an edit or delete PATCHes or DELETEs /messages/<id> of the same webhook (?thread_id= when it was sent
- * into a Discord thread). Nothing already in the map is created twice, and a reply waits for its
- * thread's own message (a reply to a thread that is not on Discord through that mapping is skipped).
- *
- * - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an
- * environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at
- * send time. Only allow-listed names: DISCORD_RELAY_WEBHOOK_VARS (exact names) when set, else
- * DISCORD_WEBHOOK_*. Staff can therefore never point the relay at the URL in some other
- * variable (an internal service's base URL), nor learn which other variables are set; a
- * mapping outside the list is refused when made and never sent.
- * - Visibility: public spaces only, never members-only (VIP) spaces or threads, checked when
- * queueing and again when sending. Gating a thread later deletes what the relay had sent.
- * - Loop prevention: a thread or post whose origin is 'discord' is never relayed out, checked both
- * when queueing and when sending (inbound.js adds the other half).
- * - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with
- * exponential backoff (baseMs · 2^(attempt−1), capped at an hour; a 429's retry_after is
- * honoured, and the rest of that webhook's queue waits for the next pass) up to maxAttempts.
- * Then the delivery is 'failed': the dead letter, with its error, that staff retry or drop.
- * Other 4xx fail at once. A delete that finds the message already gone counts as delivered.
- * - Mentions are disabled (allowed_mentions.parse = []), so nothing relayed can ping anyone.
- * Staff see and act on all of it at /api/v1/relay (relay/api.js).
- */
-const projections = require('../identity/projection');
-const forumStore = require('../forum/store');
-const { markdownToText } = require('../render/markdown');
-const { sqlTime, isoTime } = require('../http/v1');
-const { AI_DISPLAY_NAME } = require('../identity/authors');
-
-const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
-const WEBHOOK_VAR = /^DISCORD_WEBHOOK_[A-Z0-9_]+$/;
-const SNOWFLAKE = /^\d{15,21}$/;
-const MAX_BACKOFF_MS = 60 * 60_000;
-const SEND_TIMEOUT_MS = 10_000;
-const POSTS_PER_PAGE = 50; // forum/service.js: a reply's link names its page
-const UNKNOWN_MESSAGE = 10008; // Discord's error code: the message is gone
-const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skipped'];
-
-/** Discord markdown in names/titles shown as plain text. */
-const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
-
-/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */
-function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, kickMs = 250, now = () => Date.now(), webhookVars = null } = {}) {
- const base = (config.baseUrl || '').replace(/\/$/, '');
- const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;
- /** May a mapping name this variable? */
- const refAllowed = (name) => typeof name === 'string' && ENV_NAME.test(name) && (allowed ? allowed.has(name) : WEBHOOK_VAR.test(name));
- let timer = null;
- let draining = null;
- let stopped = false;
- let kickTimer = null;
- // While the Events worker runs it queues every create from the event stream; the forum's direct
- // calls then queue nothing (the dedupe keys would make them harmless anyway).
- let createsFromEvents = false;
- const parts = { worker: null, gateway: null, inbound: null };
-
- const keys = {
- create: (type, id, m) => `${type}:${id}:mapping:${m}`,
- edit: (type, id, m, rev) => `edit:${type}:${id}:mapping:${m}:r${rev}`,
- remove: (type, id, m) => `delete:${type}:${id}:mapping:${m}`,
- };
- /** Kept for callers of the first relay: the create key of a thread. */
- function deliveryKey(threadId, mappingId) { return keys.create('thread', threadId, mappingId); }
-
- const insertStmt = db.prepare(`INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at)
- VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING`);
- async function insert(row) {
- return (await insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) })).changes;
- }
- const outMappings = async (spaceId) => await db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);
- const mapRow = async (mappingId, type, id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;
-
- /** Why this thread (and a post of it) may not leave the site, or null. */
- function refusal(thread, space, post = null) {
- if (thread.origin === 'discord' || (post && post.origin === 'discord')) return `loop prevention: ${post && post.origin === 'discord' ? 'post' : 'thread'} came from Discord`;
- if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted space)';
- return null;
- }
- /** Queue-time filter: the same rules, quietly. */
- const mayLeave = (thread, space, post = null) => !!(thread && space && !thread.deleted_at && !(post && post.deleted_at) && !refusal(thread, space, post));
-
- // ── queueing ─────────────────────────────────────────────
- /**
- * A new thread, for every enabled out-mapping of its space. source 'events' (the Events worker,
- * eventId) or 'direct' (the forum; ignored while the worker queues creates). → number queued
- */
- async function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {
- if (!enabled || !thread || !space) return 0;
- if (source === 'direct' && createsFromEvents) return 0;
- if (!mayLeave(thread, space)) return 0;
- let queued = 0;
- for (const m of await outMappings(space.id)) {
- queued += await insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });
- }
- if (queued) kick();
- return queued;
- }
-
- /**
- * A new reply: to every mapping its thread went to (sent, or still waiting to be). The opening post
- * travels with its thread. → number queued
- */
- async function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {
- if (!enabled || !post || !thread || !space || post.is_opening) return 0;
- if (source === 'direct' && createsFromEvents) return 0;
- if (!mayLeave(thread, space, post)) return 0;
- let queued = 0;
- for (const m of await outMappings(space.id)) {
- const parent = await db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));
- if (!parent && !await mapRow(m.id, 'thread', thread.id)) continue;
- queued += await insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });
- }
- if (queued) kick();
- return queued;
- }
-
- /**
- * An edited post: the Discord message showing it (the thread's, for an opening post) is edited.
- * Only what is already on Discord; a create still waiting carries the new text anyway.
- */
- async function enqueueEdit(post) {
- if (!enabled || !post) return 0;
- const type = post.is_opening ? 'thread' : 'post';
- const id = post.is_opening ? post.thread_id : post.id;
- const rows = await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);
- let queued = 0;
- for (const r of rows) {
- queued += await insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });
- }
- if (queued) kick();
- return queued;
- }
-
- /**
- * A thread (postId null: with every reply of it) or one reply left the public site — deleted,
- * moderated away or gated members-only: what is still waiting is skipped and what Discord shows is
- * deleted. Queued even while the relay is off, so it happens once it is on again.
- */
- async function enqueueDelete(threadId, postId = null, reason = 'deleted') {
- const note = `skipped: ${reason} before it was sent`;
- return await db.tx(async () => {
- if (postId == null) {
- await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);
- } else {
- await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);
- }
- const rows = postId == null
- ? await db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)
- : await db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);
- let queued = 0;
- for (const r of rows) {
- queued += await insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });
- }
- if (queued && enabled) kick();
- return queued;
- });
- }
- /** Threads gated members-only (or otherwise no longer public): take them off Discord. */
- async function hideThreads(threadIds, reason = 'made members-only') {
- let n = 0;
- for (const id of threadIds) n += await enqueueDelete(id, null, reason);
- return n;
- }
-
- // A drain soon after the change commits (never before: it would not see the row), kickMs later so that changes made
- // together (an edit and the delete right after it) go out as one pass, the edit skipped.
- function kick() {
- db.afterCommit(() => {
- if (kickTimer || stopped) return;
- kickTimer = setTimeout(() => { kickTimer = null; drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, kickMs);
- if (kickTimer.unref) kickTimer.unref();
- });
- }
-
- // ── messages ─────────────────────────────────────────────
- async function authorName(subject, origin) {
- if (origin === 'ai') return AI_DISPLAY_NAME;
- if (origin === 'system' && !subject) return 'OpenVibe';
- if (subject) {
- const p = (await projections.getProjections(db, [subject])).get(subject);
- if (p) return p.display_name || p.username || 'someone';
- }
- return 'someone';
- }
- const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;
- async function postUrl(post, thread, space) {
- 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);
- return `${threadUrl(thread, space)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}`;
- }
-
- /** The thread's message: who started it, the title, an excerpt of the opening post, the link. */
- async function message(thread, space) {
- const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
- const url = threadUrl(thread, space);
- return {
- username: 'OpenVibe.Space',
- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
- allowed_mentions: { parse: [] },
- embeds: [{
- title: String(thread.title).slice(0, 256),
- url,
- description: markdownToText(opening ? opening.body_markdown : '', 300),
- footer: { text: `OpenVibe.Space · s/${space.slug}` },
- timestamp: isoTime(thread.created_at) || undefined,
- }],
- };
- }
-
- /** A reply's message: who replied to which thread, an excerpt, the link to the post. */
- async function postMessage(post, thread, space) {
- const url = await postUrl(post, thread, space);
- const title = String(thread.title).slice(0, 200);
- return {
- username: 'OpenVibe.Space',
- content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,
- allowed_mentions: { parse: [] },
- embeds: [{
- title: `Re: ${title}`.slice(0, 256),
- url,
- description: markdownToText(post.body_markdown, 500),
- footer: { text: `OpenVibe.Space · s/${space.slug}` },
- timestamp: isoTime(post.created_at) || undefined,
- }],
- };
- }
-
- // ── sending ──────────────────────────────────────────────
- function backoff(attempts, retryAfterSec) {
- const exp = Math.min(baseMs * Math.pow(2, Math.max(attempts - 1, 0)), MAX_BACKOFF_MS);
- return Math.max(exp, retryAfterSec ? retryAfterSec * 1000 : 0);
- }
-
- /** One send happened: delivered, retried later, or failed (the dead letter). */
- async function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {
- const attempts = d.attempts + 1;
- if (ok) {
- await db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = ov_now(), updated_at = ov_now() WHERE id = ?")
- .run(attempts, status, note, d.id);
- return 'delivered';
- }
- const giveUp = !retry || attempts >= maxAttempts;
- await db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = ov_now() WHERE id = ?')
- .run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);
- return giveUp ? 'failed' : 'retry';
- }
- /** Nothing was sent: already done (delivered) or nothing left to do (skipped). */
- async function settle(d, status, reason) {
- await db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = ov_now(),
- delivered_at = CASE WHEN ? = 'delivered' THEN ov_now() ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);
- return status === 'delivered' ? 'delivered' : 'skipped';
- }
- /** Not yet (a reply waiting for its thread's message): later, without using an attempt. */
- async function defer(d, untilSql, reason) {
- const until = Math.max(Date.parse(`${String(untilSql || '').replace(' ', 'T')}Z`) || 0, now() + 1000);
- await db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = ov_now() WHERE id = ?').run(sqlTime(until), reason, d.id);
- return 'retry';
- }
-
- async function send(d) {
- const m = await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);
- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);
- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
- const post = d.post_id ? await forumStore.getPost(db, d.post_id) : null;
- const type = d.post_id ? 'post' : 'thread';
- const localId = d.post_id || d.thread_id;
- const gone = !thread || thread.deleted_at || !space || (d.post_id && (!post || post.deleted_at));
- const mapped = await mapRow(d.mapping_id, type, localId);
-
- if (d.action === 'create') {
- if (gone) return await settle(d, 'skipped', `the ${type} was deleted before it was sent`);
- const why = refusal(thread, space, post);
- if (why) return await record(d, { ok: false, error: why });
- if (mapped) return await settle(d, 'delivered', 'already on Discord');
- if (type === 'post' && !await mapRow(d.mapping_id, 'thread', thread.id)) {
- const parent = await db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));
- if (parent && parent.status === 'pending') return await defer(d, parent.next_attempt_at, "waiting for the thread's own message");
- return await settle(d, 'skipped', 'the thread is not on Discord through this mapping');
- }
- } else {
- if (!mapped || mapped.direction !== 'out') return await settle(d, 'skipped', 'not on Discord');
- if (mapped.external_deleted_at) return await settle(d, 'skipped', 'already deleted on Discord');
- if (d.action === 'edit') {
- if (gone) return await settle(d, 'skipped', `the ${type} was deleted`);
- const why = refusal(thread, space, post);
- if (why) return await settle(d, 'skipped', why);
- }
- }
-
- if (!m || !refAllowed(m.webhook_url_ref)) return await record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
- const raw = env[m.webhook_url_ref];
- if (!raw) return await record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });
- let url = null;
- try { url = new URL(raw); } catch { /* below */ }
- if (!url || !/^https?:$/.test(url.protocol)) return await record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });
-
- let method = 'POST';
- let body = null;
- let threadTarget = null;
- if (d.action === 'create') {
- threadTarget = (SNOWFLAKE.test(m.discord_thread_id || '') ? m.discord_thread_id : null) || url.searchParams.get('thread_id') || null;
- url.searchParams.set('wait', 'true');
- if (threadTarget) url.searchParams.set('thread_id', threadTarget);
- body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);
- } else {
- method = d.action === 'edit' ? 'PATCH' : 'DELETE';
- url.pathname = `${url.pathname.replace(/\/+$/, '')}/messages/${mapped.external_message_id}`;
- url.search = '';
- if (mapped.external_thread_id) url.searchParams.set('thread_id', mapped.external_thread_id);
- if (d.action === 'edit') body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);
- }
-
- let res;
- try {
- res = await fetchImpl(url.toString(), {
- method,
- headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), 'User-Agent': 'OpenVibe.Space relay' },
- body: body ? JSON.stringify(body) : undefined,
- signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
- });
- } catch (err) {
- return await record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });
- }
- let answer = null;
- if (res.status !== 204) { try { answer = await res.json(); } catch { /* no JSON */ } }
- if (res.ok) {
- if (d.action === 'create') return await created(d, res.status, answer, { type, localId, threadTarget, mapping: m });
- if (d.action === 'delete') await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);
- else await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(mapped.id);
- return await record(d, { ok: true, status: res.status });
- }
- // The message itself is gone on Discord (someone deleted it there): a delete is done, an edit has nothing to edit.
- if (res.status === 404 && d.action !== 'create' && answer && Number(answer.code) === UNKNOWN_MESSAGE) {
- await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);
- return d.action === 'delete' ? await record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : await settle(d, 'skipped', 'the message is gone on Discord');
- }
- const detail = answer && (answer.message || answer.error) ? String(answer.message || answer.error) : '';
- let retryAfter = Number(res.headers.get('retry-after')) || 0;
- if (answer && answer.retry_after) retryAfter = Math.max(retryAfter, Number(answer.retry_after) || 0);
- const retry = res.status === 429 || res.status === 408 || res.status >= 500;
- const outcome = await record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });
- return res.status === 429 ? { outcome, rateLimited: true } : outcome;
- }
-
- /** A create was answered: map the message (both ways unique) and learn the mapping's channel. */
- async function created(d, status, msg, { type, localId, threadTarget, mapping }) {
- const id = msg && SNOWFLAKE.test(String(msg.id || '')) ? String(msg.id) : null;
- const channel = msg && SNOWFLAKE.test(String(msg.channel_id || '')) ? String(msg.channel_id) : null;
- if (!id || !channel) return await record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });
- return await db.tx(async () => {
- await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)
- VALUES ('discord', ?, 'out', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)
- .run(mapping.id, type, localId, d.thread_id, channel, threadTarget ? channel : null, id, msg.webhook_id ? String(msg.webhook_id) : null);
- if (!threadTarget) await db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);
- return await record(d, { ok: true, status });
- });
- }
-
- /**
- * Send every due delivery once, oldest first. One drain at a time. A 429 leaves the rest of that
- * webhook's queue for the next pass. → { delivered, retry, failed, skipped }
- */
- function drain() {
- if (draining) return draining;
- if (stopped) return Promise.resolve({ delivered: 0, retry: 0, failed: 0, skipped: 0 });
- draining = (async () => {
- const summary = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
- if (!enabled) return summary;
- // Deletes go out even for a disabled mapping: what left the site leaves Discord too.
- const due = await db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id
- WHERE d.status = 'pending' AND (m.enabled = 1 OR d.action = 'delete') AND d.next_attempt_at <= ?
- ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));
- const paused = new Set();
- for (const d of due) {
- if (stopped) break;
- if (paused.has(d.mapping_id)) continue;
- const r = await send(d);
- if (r && typeof r === 'object') { paused.add(d.mapping_id); summary[r.outcome]++; } else summary[r]++;
- }
- return summary;
- })().finally(() => { draining = null; });
- return draining;
- }
-
- // ── lifecycle ────────────────────────────────────────────
- /** The Events worker, the gateway and the inbound handler (app.js), started and stopped with the relay. */
- function attach({ worker = null, gateway = null, inbound = null } = {}) { Object.assign(parts, { worker, gateway, inbound }); }
- /** The Events worker says it queues creates (true) or no longer does. */
- function useEventsForCreates(on) { createsFromEvents = !!on; }
-
- function start() {
- if (!enabled || timer) return;
- stopped = false;
- timer = setInterval(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, pollMs);
- if (timer.unref) timer.unref();
- kick();
- if (parts.worker) parts.worker.start();
- if (parts.gateway) parts.gateway.start();
- }
- /** Graceful stop: no further drains or reads; resolves when the drain and page in progress finished (the rest stay queued). */
- function stop() {
- stopped = true;
- if (timer) clearInterval(timer);
- timer = null;
- if (kickTimer) clearTimeout(kickTimer);
- kickTimer = null;
- const waits = [draining || Promise.resolve()];
- if (parts.worker) waits.push(parts.worker.stop());
- if (parts.gateway) waits.push(parts.gateway.stop());
- return Promise.all(waits).then(() => undefined);
- }
-
- // ── admin (staff) ────────────────────────────────────────
- function shapeMapping(m) {
- return {
- id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: refAllowed(m.webhook_url_ref) && !!env[m.webhook_url_ref],
- enabled: !!m.enabled, discord_channel_id: m.discord_channel_id || null, discord_thread_id: m.discord_thread_id || null, inbound: !!m.inbound, created_at: isoTime(m.created_at),
- };
- }
- const mappingQuery = 'SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id';
- async function listMappings() { return (await db.prepare(`${mappingQuery} ORDER BY m.id`).all()).map(shapeMapping); }
- const snowflakeOrNull = (v, name) => {
- if (v === undefined) return undefined;
- if (v === null || v === '') return null;
- if (!SNOWFLAKE.test(String(v))) throw new Error(`${name} is a Discord id (digits)`);
- return String(v);
- };
- /** { space_id, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? } → mapping (made, or updated) */
- async function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {
- if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);
- const channel = snowflakeOrNull(discord_channel_id, 'discord_channel_id');
- const thread = snowflakeOrNull(discord_thread_id, 'discord_thread_id');
- await db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)
- ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0, channel || null, thread || null, inbound ? 1 : 0);
- const row = await db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);
- return await updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });
- }
- /** { enabled?, inbound?, discord_channel_id?, discord_thread_id? } → mapping, or null when there is no such mapping */
- async function updateMapping(id, fields = {}) {
- const sets = [];
- const params = [];
- if (fields.enabled !== undefined) { sets.push('enabled = ?'); params.push(fields.enabled ? 1 : 0); }
- if (fields.inbound !== undefined) { sets.push('inbound = ?'); params.push(fields.inbound ? 1 : 0); }
- for (const k of ['discord_channel_id', 'discord_thread_id']) {
- const v = snowflakeOrNull(fields[k], k);
- if (v !== undefined) { sets.push(`${k} = ?`); params.push(v); }
- }
- if (sets.length && !(await db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)).changes) return null;
- const row = await db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);
- return row ? shapeMapping(row) : null;
- }
- async function setMappingEnabled(id, on) { return await updateMapping(id, { enabled: on }); }
-
- async function listDeliveries({ status = null, action = null, limit = 50 } = {}) {
- return (await db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug
- FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id
- WHERE (?::text IS NULL OR d.status = ?) AND (?::text IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit))
- .map((d) => ({
- id: d.id, dedupe_key: d.dedupe_key, action: d.action, source: d.source, event_id: d.event_id || null,
- status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
- next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),
- mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },
- thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },
- post: d.post_id ? { id: d.post_id } : null,
- }));
- }
- /** Put a delivery that was not delivered back in the queue now, with a fresh attempt budget. */
- async function retry(id) {
- const n = (await db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ov_now() WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id)).changes;
- if (n) kick();
- return n;
- }
- /** Staff give up on a waiting or dead delivery: 'dropped', kept for the record. */
- async function drop(id) {
- return (await db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = ov_now() WHERE id = ? AND status IN ('pending', 'failed')").run(id)).changes;
- }
-
- /** What staff (and /api/ready) see: the queue by status, and the Events worker's and the gateway's state. */
- async function status() {
- const deliveries = Object.fromEntries(DELIVERY_STATUSES.map((s) => [s, 0]));
- for (const r of await db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;
- const off = (reason) => ({ enabled: false, reason });
- return {
- enabled,
- creates_from: createsFromEvents ? 'events' : 'forum',
- deliveries,
- dead_letters: deliveries.failed,
- messages_mapped: (await db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get()).n,
- events_worker: parts.worker ? await parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),
- inbound: parts.gateway ? { ...parts.gateway.status(), ...(parts.inbound ? { handled: parts.inbound.stats() } : {}) } : off(enabled ? 'DISCORD_RELAY_INBOUND is off' : 'the relay is off'),
- inbound_failures: (await db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get()).n,
- };
- }
-
- return {
- enabled, enqueueThread, enqueuePost, enqueueEdit, enqueueDelete, hideThreads, drain, start, stop, attach, useEventsForCreates, status,
- listMappings, addMapping, updateMapping, setMappingEnabled, listDeliveries, retry, drop, deliveryKey, ENV_NAME, refAllowed, message, postMessage,
- };
-}
-
-module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME, WEBHOOK_VAR, SNOWFLAKE, DELIVERY_STATUSES };
deleted file mode 100644
deleted file mode 100644
@@ -1,164 +0,0 @@
@@ -1,164 +0,0 @@
-'use strict';
-
-/**
- * The Discord relay's Events worker (roadmap WS-J task 6): reads Space's own
- * space.thread.* and space.post.* from OpenVibe.Events' pull API (GET /api/v1/events,
- * capability events.event.read, Space's service token on audience openvibe.events) and queues
- * the relay's creates from them (relay/discord.js).
- *
- * - Cursor: the relay_cursors row 'discord-relay:events', moved in the same transaction as the
- * deliveries its page queued. A crash replays at most the page in progress, and the dedupe keys
- * make that replay queue nothing twice.
- * - First start (no cursor yet): it begins at Events' head. Threads from before are never announced.
- * - Events carry ids, never content: the worker reads the thread or post as it is now and the relay's
- * rules decide (public spaces only, never members-only, never origin 'discord'). Redacted events
- * and events from any other source are ignored.
- * - A gap (retention pruned events the worker had not read yet) is logged and shown to staff; the
- * creates in it are not relayed.
- * - While it runs, the forum's own create calls queue nothing (the worker is the one path). Off
- * without EVENTS_URL and OV_OAUTH_CLIENT_SECRET, or with DISCORD_RELAY_EVENTS=off: then the forum
- * queues creates itself, as the first relay did. Edits and deletes are queued by the forum in both
- * cases (Contracts has no space.thread/post update or delete events yet).
- */
-const forumStore = require('../forum/store');
-
-const TOPICS = ['space.thread.*', 'space.post.*'];
-const CURSOR = 'discord-relay:events';
-const MAX_PAGES_PER_TICK = 20;
-
-/**
- * events: an openvibe-sdk events client (tests); otherwise one is made from eventsUrl, clientSecret and
- * tokenUrl (the Network's /oauth/token, client credentials of clientId).
- */
-function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, clientSecret = null, tokenUrl = null, clientId = 'space', fetchImpl, enabled = true, pollMs = 5000, pageLimit = 100, now = () => Date.now() } = {}) {
- let reason = null;
- if (!relay || !relay.enabled) reason = 'the Discord relay is off (DISCORD_RELAY_ENABLED)';
- else if (!enabled) reason = 'DISCORD_RELAY_EVENTS=off';
- else if (!events && (!eventsUrl || !clientSecret || !tokenUrl)) reason = 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set';
- let client = events;
- if (!reason && !client) {
- const { createClient } = require('openvibe-sdk/core');
- const { createServiceTokenClient } = require('openvibe-sdk/auth');
- const { createEventsClient } = require('openvibe-sdk/events');
- const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret, fetch: fetchImpl });
- const core = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
- client = createEventsClient(core, { source: 'space' });
- }
- const on = !reason;
- const stats = { seen: 0, queued: 0, pages: 0, last_error: null, last_error_at: null, last_ok_at: null, last_gap: null, started_at_seq: null };
- let timer = null;
- let running = null;
- let stopped = false;
-
- const iso = () => new Date(now()).toISOString();
- const readCursor = async () => await db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;
- async function writeCursor(cursor, latest) {
- await db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, ov_now())
- ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = ov_now()`).run(CURSOR, cursor, latest == null ? null : latest);
- }
-
- /** One event → deliveries queued (0 when it is not for the relay). */
- async function handle(ev) {
- stats.seen++;
- if (!ev || ev.source !== 'space' || !ev.payload || ev.payload.redacted) return 0;
- const p = ev.payload;
- if (p.visibility && p.visibility !== 'public') return 0;
- if (ev.event_type === 'space.thread.created') {
- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));
- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
- return await relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });
- }
- if (ev.event_type === 'space.post.created') {
- const post = await forumStore.getPost(db, Number(p.post_id));
- const thread = post ? await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;
- const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
- return await relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });
- }
- return 0;
- }
-
- /** Read everything new (at most MAX_PAGES_PER_TICK pages). One tick at a time. */
- function tick() {
- if (!on) return Promise.resolve({ queued: 0 });
- if (running) return running;
- running = (async () => {
- let queuedNow = 0;
- try {
- let cur = await readCursor();
- if (!cur) {
- const head = await client.pull({ topic: TOPICS, afterSeq: 0, limit: 1 });
- const latest = Number(head.latest_seq) || 0;
- await writeCursor(latest, latest);
- stats.started_at_seq = latest;
- console.log(`[Relay] Events worker starts at seq ${latest} (earlier threads are not announced)`);
- cur = { cursor: latest, latest_seq: latest };
- }
- let cursor = Number(cur.cursor) || 0;
- for (let pages = 0; pages < MAX_PAGES_PER_TICK && !stopped; pages++) {
- const page = await client.pull({ topic: TOPICS, afterSeq: cursor, limit: pageLimit });
- if (stopped) break;
- if (page.gap) {
- stats.last_gap = { from_seq: page.gap.from_seq, to_seq: page.gap.to_seq, at: iso() };
- console.warn(`[Relay] Events gap: seq ${page.gap.from_seq}..${page.gap.to_seq} were pruned before the relay read them; threads and replies in it are not relayed`);
- }
- const next = Number(page.next_after_seq);
- const latest = Number(page.latest_seq);
- const moved = Number.isFinite(next) && next > cursor ? next : cursor;
- queuedNow += await db.tx(async () => {
- let n = 0;
- for (const item of page.events || []) n += await handle(item && item.event);
- await writeCursor(moved, Number.isFinite(latest) ? latest : null);
- return n;
- });
- stats.pages++;
- if (moved === cursor || !Number.isFinite(latest) || moved >= latest) break;
- cursor = moved;
- }
- stats.queued += queuedNow;
- stats.last_ok_at = iso();
- if (stats.last_error) console.log('[Relay] Events worker reading again');
- stats.last_error = null;
- stats.last_error_at = null;
- } catch (err) {
- const m = err && err.message ? err.message : String(err);
- if (m !== stats.last_error) console.warn('[Relay] Events worker cannot read (will retry):', m);
- stats.last_error = m;
- stats.last_error_at = iso();
- }
- return { queued: queuedNow };
- })().finally(() => { running = null; });
- return running;
- }
-
- function start() {
- if (!on || timer) return;
- stopped = false;
- relay.useEventsForCreates(true);
- timer = setInterval(() => { tick(); }, pollMs);
- if (timer.unref) timer.unref();
- tick();
- }
- /** Graceful stop: no further reads; resolves when the page in progress has been handled. */
- function stop() {
- stopped = true;
- if (timer) clearInterval(timer);
- timer = null;
- return running ? running.then(() => undefined) : Promise.resolve();
- }
-
- async function status() {
- if (!on) return { enabled: false, reason };
- const cur = await readCursor();
- return {
- enabled: true, running: !!timer, topics: TOPICS,
- cursor: cur ? cur.cursor : null, latest_seq: cur ? cur.latest_seq : null,
- lag: cur && cur.latest_seq != null ? Math.max(cur.latest_seq - cur.cursor, 0) : null,
- events_seen: stats.seen, deliveries_queued: stats.queued, started_at_seq: stats.started_at_seq,
- last_ok_at: stats.last_ok_at, last_error: stats.last_error, last_error_at: stats.last_error_at, last_gap: stats.last_gap,
- };
- }
-
- return { enabled: on, reason, tick, start, stop, status, TOPICS, CURSOR };
-}
-
-module.exports = { createRelayEventsWorker, TOPICS, CURSOR };
deleted file mode 100644
deleted file mode 100644
@@ -1,199 +0,0 @@
@@ -1,199 +0,0 @@
-'use strict';
-
-/**
- * Replies from Discord (roadmap WS-J task 6, inbound; DISCORD_RELAY_INBOUND=on with DISCORD_BOT_TOKEN).
- * The gateway (relay/discord-gateway.js) hands message events here.
- *
- * What becomes a post: only messages in a mapped channel (a mapping with inbound on: its
- * discord_channel_id or discord_thread_id, or a Discord thread started from one of its messages), and
- * only replies to something in the external message map:
- * - a Discord reply (message_reference) to a mapped message in the same channel, or
- * - any message in a Discord thread started from a mapped message (that thread's id is the message's).
- * Everything else in the channel is ignored. The post goes into the mapped message's local thread with
- * origin 'discord', no author subject (never attributed to anyone on the site; the Discord display name
- * is kept in posts.relay_author and shown with a "Discord" badge), and the map gets its direction 'in'
- * row. Edits on Discord edit the post (a new revision, edited_by 'discord'); deletes on Discord delete
- * it. A delete of a message the relay sent only marks it gone there (the thread stays).
- *
- * Loop prevention: webhook messages (the relay's own among them), bots, the bot itself and system
- * messages are never taken in; a message already in the map is never taken twice (and the map's unique
- * key refuses it anyway); posts with origin 'discord' are never relayed out (relay/discord.js).
- * Limits: perMinute messages per Discord author per mapping and 10× that per mapping (a rolling
- * minute), maxChars per message (longer text is cut). Mentions, roles, channels, custom emoji and
- * timestamps become plain text and @everyone/@here are defused; the rest is Markdown, which the
- * forum's safe renderer shows (render/markdown.js: no raw HTML, safe links only). Attachments are
- * noted, never fetched. The thread must still be public, open (not locked) and not members-only.
- * What could not be applied goes to relay_inbound_failures: ids and the reason, never the text.
- */
-const forumStore = require('../forum/store');
-const { SNOWFLAKE } = require('./discord');
-
-const KEEP_FAILURES = 1000;
-const MESSAGE_TYPES = new Set([0, 19]); // DEFAULT, REPLY
-
-/** A Discord display name as plain text: no control or format characters, one line, at most 32. */
-function cleanName(s) {
- const t = String(s == null ? '' : s).replace(/[\u0000-\u001F\u007F-\u009F\u200b-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
- return t.slice(0, 32);
-}
-
-/** Discord message text → forum Markdown (see the header). '' when there is nothing to show. */
-function toMarkdown(d, maxChars = 4000) {
- const names = new Map((Array.isArray(d.mentions) ? d.mentions : []).map((u) => [String(u && u.id), cleanName(u && (u.global_name || u.username))]));
- let text = (typeof d.content === 'string' ? d.content : '')
- .replace(/<@!?(\d{15,21})>/g, (_, id) => `@${names.get(id) || 'someone'}`)
- .replace(/<@&\d{15,21}>/g, '@role')
- .replace(/<#\d{15,21}>/g, '#channel')
- .replace(/<\/([\w-]{1,32}(?: [\w-]{1,32}){0,2}):\d{15,21}>/g, '/$1')
- .replace(/<a?:(\w{1,32}):\d{15,21}>/g, ':$1:')
- .replace(/<t:(-?\d{1,13})(?::[tTdDfFR])?>/g, (_, sec) => {
- const t = new Date(Number(sec) * 1000);
- return Number.isNaN(t.getTime()) ? '' : `${t.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
- })
- .replace(/@(everyone|here)/g, '@\u200b$1')
- .replace(/\r\n?/g, '\n')
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
- .trim();
- if (text.length > maxChars) text = `${text.slice(0, Math.max(maxChars - 1, 0)).trimEnd()}…`;
- const files = Array.isArray(d.attachments) ? d.attachments.length : 0;
- if (files) text += `${text ? '\n\n' : ''}_(${files} attachment${files === 1 ? '' : 's'} on Discord)_`;
- return text;
-}
-
-function createDiscordInbound({ db, perMinute = 6, maxChars = 4000, now = () => Date.now() } = {}) {
- const windows = new Map(); // key → timestamps in the last minute
- const counts = { applied: 0, ignored: 0, failed: 0 };
-
- function allow(key, limit) {
- const t = now();
- const list = (windows.get(key) || []).filter((x) => t - x < 60_000);
- if (list.length >= limit) { windows.set(key, list); return false; }
- list.push(t);
- windows.set(key, list);
- if (windows.size > 5000) for (const [k, l] of windows) if (!l.length || t - l[l.length - 1] >= 60_000) windows.delete(k);
- return true;
- }
-
- const mapByMessage = async (id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;
- const mappingById = async (id) => await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;
-
- function ignore(reason) { counts.ignored++; return { status: 'ignored', reason }; }
- async function failure(event, d, mappingId, error) {
- await db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)
- VALUES (?, ?, ?, ?, ?, ?)`).run(mappingId || null, event, d.channel_id ? String(d.channel_id) : null, d.id ? String(d.id) : null, d.author && d.author.id ? String(d.author.id) : null, String(error).slice(0, 300));
- await db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);
- counts.failed++;
- return { status: 'failed', error };
- }
- /** Is the local thread still somewhere a reply from Discord may land? → null or why not */
- async function closed(thread) {
- if (!thread || thread.deleted_at) return 'the thread was deleted';
- const space = await forumStore.getSpaceById(db, thread.space_id);
- if (!space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is not public';
- if (thread.locked) return 'the thread is locked';
- return null;
- }
-
- async function create(d, botUserId) {
- if (d.webhook_id) return ignore('a webhook message (the relay\'s own included)');
- const author = d.author || {};
- if (!author.id || author.bot || author.system || (botUserId && String(author.id) === String(botUserId))) return ignore('a bot or system message');
- if (d.type !== undefined && !MESSAGE_TYPES.has(Number(d.type))) return ignore('not a plain message or a reply');
- if (!SNOWFLAKE.test(String(d.id || '')) || !SNOWFLAKE.test(String(d.channel_id || ''))) return ignore('malformed ids');
- if (await mapByMessage(d.id)) return ignore('already relayed');
- const channel = String(d.channel_id);
- // A Discord thread started from a mapped message, else a reply to one in the same channel.
- let anchor = await mapByMessage(channel);
- // (message_reference type 1 is a forward, not a reply: never taken in.)
- if (!anchor && d.message_reference && d.message_reference.message_id && !Number(d.message_reference.type || 0)) {
- const ref = await mapByMessage(String(d.message_reference.message_id));
- if (ref && ref.external_channel_id === channel) anchor = ref;
- }
- if (!anchor) return ignore('not a reply to a relayed message');
- const mapping = await mappingById(anchor.mapping_id);
- if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
- const mapped = [mapping.discord_channel_id, mapping.discord_thread_id].filter(Boolean);
- const inMapped = mapped.includes(channel) || (channel === anchor.external_message_id && mapped.includes(anchor.external_channel_id));
- if (!inMapped) return ignore('not a mapped channel');
- const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);
- const why = await closed(thread);
- if (why) return await failure('MESSAGE_CREATE', d, mapping.id, why);
- if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return await failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');
- const body = toMarkdown(d, maxChars);
- if (!body) return await failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');
- const name = cleanName((d.member && d.member.nick) || author.global_name || author.username) || 'Discord';
- try {
- const post = await db.tx(async () => {
- const p = await forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });
- await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
- VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)`).run(mapping.id, p.id, thread.id, channel, String(d.id));
- return p;
- });
- counts.applied++;
- return { status: 'applied', post_id: post.id };
- } catch (err) {
- if (err.code === '23505') return ignore('already relayed');
- return await failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);
- }
- }
-
- async function update(d) {
- if (!d.id) return ignore('malformed ids');
- const row = await mapByMessage(d.id);
- if (!row) return ignore('not a relayed message');
- if (row.direction !== 'in') return ignore('the relay\'s own message');
- if (typeof d.content !== 'string') return ignore('no text change');
- const mapping = await mappingById(row.mapping_id);
- if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
- const post = await forumStore.getPost(db, row.local_id);
- if (!post || post.deleted_at) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');
- const why = await closed(await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));
- if (why) return await failure('MESSAGE_UPDATE', d, mapping.id, why);
- const body = toMarkdown(d, maxChars);
- if (!body) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');
- await forumStore.editPost(db, post.id, body, 'discord');
- await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(row.id);
- counts.applied++;
- return { status: 'applied', post_id: post.id };
- }
-
- /** A delete on Discord. Applied whatever the mapping's switches: taking something down never waits. */
- async function remove(id) {
- const row = await mapByMessage(id);
- if (!row) return ignore('not a relayed message');
- await db.tx(async () => {
- await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ? AND external_deleted_at IS NULL').run(row.id);
- if (row.direction === 'in') await forumStore.softDeletePost(db, row.local_id);
- });
- counts.applied++;
- return { status: 'applied', post_id: row.direction === 'in' ? row.local_id : null, note: row.direction === 'out' ? 'the relay\'s message was deleted on Discord; the thread stays' : undefined };
- }
-
- /** One gateway dispatch (type, data, { botUserId }) → result (an array for a bulk delete). */
- async function handle(type, d, { botUserId = null } = {}) {
- if (!d || typeof d !== 'object') return ignore('no data');
- if (type === 'MESSAGE_CREATE') return await create(d, botUserId);
- if (type === 'MESSAGE_UPDATE') return await update(d);
- if (type === 'MESSAGE_DELETE') return await remove(String(d.id || ''));
- if (type === 'MESSAGE_DELETE_BULK') return (await Promise.all((Array.isArray(d.ids) ? d.ids : []).map(async (id) => await remove(String(id)))));
- return ignore('not a message event');
- }
-
- // ── staff ────────────────────────────────────────────────
- async function listFailures({ all = false, limit = 50 } = {}) {
- return (await db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id
- WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit))
- .map((f) => ({
- id: f.id, event: f.event, error: f.error, mapping: f.mapping_id ? { id: f.mapping_id, space: f.space_slug || null } : null,
- discord: { channel_id: f.external_channel_id, message_id: f.external_message_id, author_id: f.external_author_id },
- dismissed: !!f.dismissed_at, created_at: f.created_at ? new Date(`${String(f.created_at).replace(' ', 'T')}Z`).toISOString() : null,
- }));
- }
- async function dismiss(id) {
- return (await db.prepare('UPDATE relay_inbound_failures SET dismissed_at = ov_now() WHERE id = ? AND dismissed_at IS NULL').run(id)).changes;
- }
-
- return { handle, listFailures, dismiss, stats: () => ({ ...counts }) };
-}
-
-module.exports = { createDiscordInbound, toMarkdown, cleanName };
deleted file mode 100644
deleted file mode 100644
@@ -1,286 +0,0 @@
@@ -1,286 +0,0 @@
-'use strict';
-
-/**
- * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,
- * posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics
- * first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,
- * posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js
- * (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings and images.
- * Complete without JavaScript: every action is a link or a form post.
- */
-const config = require('../config');
-const ld = require('./jsonld');
-const { renderPage, SITE_NAME } = require('./layout');
-const { escapeHtml: esc } = require('./highlight');
-const { markdownToText } = require('./markdown');
-const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');
-const f = require('./forum');
-
-const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
-const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
-const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
-const trail = (space) => [['Spaces', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];
-const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
-
-/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
-function boardRow(sp) {
- const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
- const last = sp.last_post
- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
- : '<span class="muted small">No posts yet</span>';
- const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
- return `<tr>
- <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
- <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
- ${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
- </div></td>
- <td class="num" data-label="Topics">${num(sp.thread_count)}</td>
- <td class="num" data-label="Posts">${num(sp.post_count)}</td>
- <td class="board-last" data-label="Last post">${last}</td>
- </tr>`;
-}
-
-function boardTable(spaces, caption) {
- return `<table class="board-table"><caption class="sr-only">${esc(caption)}</caption>
- <thead><tr><th scope="col">Space</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead>
- <tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
-}
-
-// ── /s ───────────────────────────────────────────────────────
-function boardIndexPage({ groups = [], user, canModerate = false }) {
- const body = `
-<header class="page-head">
- ${crumbs([['Spaces', '/s']])}
- <h1>Spaces</h1>
- <p class="muted">Forums and feeds for the people of OpenVibe. Forum boards read like a classic forum, newest reply on top. Feeds rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="${config.communityUrl}/pulse">Pulse</a>.</p>
- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></p>' : ''}
-</header>
-${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
- <h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
- ${g.description ? `<p class="muted small board-group-p">${esc(g.description)}</p>` : ''}
- ${boardTable(g.spaces, g.name)}
-</section>`).join('') : '<p class="empty">No spaces yet.</p>'}
-${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post.</p>'}`;
- return renderPage({
- title: 'Spaces',
- description: `Forums and feeds on ${SITE_NAME}: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.`,
- canonicalPath: '/s',
- active: 'spaces',
- feeds: [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }])],
- body,
- });
-}
-
-/** A topic's page links (1 2 3 … last) for the topic table. */
-function topicPages(base, pages) {
- if (pages <= 1) return '';
- const n = pages <= 5 ? Array.from({ length: pages }, (_, i) => i + 1) : [1, 2, 3, null, pages];
- return ` <span class="topic-pages">Pages: ${n.map((p) => (p ? `<a href="${esc(base)}${p > 1 ? `?page=${p}` : ''}">${p}</a>` : '…')).join(' ')}</span>`;
-}
-
-function topicRow(t, space) {
- const base = `/s/${space.slug}/t/${t.slug}`;
- const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';
- const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
- const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');
- return `<tr class="${t.pinned ? 'sticky' : ''}">
- <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
- <td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
- <span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>
- <td class="num" data-label="Replies">${num(t.reply_count)}</td>
- <td class="num" data-label="Views">${num(t.views)}</td>
- <td class="board-last" data-label="Last post">${timeTag(t.last_activity_at)}<span class="small muted">by ${who(t.last_post ? t.last_post.author : t.author)}${t.last_post ? ` <a href="${esc(base)}${t.pages > 1 ? `?page=${t.pages}` : ''}#post-${Number(t.last_post.id)}" aria-label="Go to the last post">»</a>` : ''}</span></td>
- </tr>`;
-}
-
-/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */
-function settingsForm(space, groups = []) {
- const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Space settings</summary>
-<form class="post-form" method="post" action="/s/${esc(space.slug)}/settings">
- <label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
- <label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
- <label class="check"><input type="checkbox" name="votes" value="1"${space.votes ? ' checked' : ''}> Up and down votes</label>
- <label class="check"><input type="checkbox" name="reactions" value="1"${space.reactions ? ' checked' : ''}> Ratings on posts (Agree, Winner, Funny…)</label>
- <label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !space.group)}${groups.map((g) => opt(g.slug, g.name, space.group && space.group.slug === g.slug)).join('')}</select></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit">Save settings</button></div>
-</form></details>`;
-}
-
-/**
- * Moderators: who moderates the space, a remove (or "Step down") button per person, and an add-by-@username form, no
- * JS. `me` is the viewer's subject; `error` is a refused add or remove, shown in the box (which then opens).
- */
-function moderatorsBox(space, moderators = [], { me = null, error = null } = {}) {
- const slug = esc(space.slug);
- const rows = moderators.map((m) => {
- const name = m.username ? `@${m.username}` : (m.display_name || 'this person');
- const self = me && m.subject === me;
- return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
- <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
- }).join('');
- return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
-${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
-${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this space yet; discussion staff do.</p>'}
-<form class="post-form" method="post" action="/s/${slug}/moderators">
- <label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
-</form></details>`;
-}
-
-// ── /s/:space (forum style) ──────────────────────────────────
-function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {
- const filtered = !!(category || status);
- const indexable = space.visibility === 'public' && !space.members_only && !filtered;
- const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
- const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
- const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
- const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
- const start = viewer.can_start !== false
- ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
- : '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
- const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
- const body = `
-<header class="page-head">
- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
- <h1>${esc(space.name)}${f.visBadge(space)}</h1>
- ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
-</header>
-${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
-${chatRoom}
-<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>
-${tags}${statuses}
-<section data-results>
-${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>
- <thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>
- <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
-${pagerHtml}
-</section>
-${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}`;
- return renderPage({
- title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
- description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on ${SITE_NAME}.`,
- canonicalPath: href({ page }),
- robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
- active: 'spaces',
- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
- body,
- });
-}
-
-/** The author's panel beside a post: picture, name, posts, since when, the ratings they get most. */
-function authorPanel(p) {
- const a = p.author;
- if (!a) return '<aside class="postbit-author"><span class="postbit-name">Anonymous</span></aside>';
- const pic = avatarUrl(a, 96);
- const name = a.display_name || a.username || (a.is_ai ? 'AI' : a.is_system ? 'OpenVibe' : 'Someone');
- const initial = esc(String(name).trim()[0] || '?').toUpperCase();
- const st = p.author_stats;
- return `<aside class="postbit-author">
- ${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}
- <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>
- ${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}
- ${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>
- ${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}
- </aside>`;
-}
-
-function postbit(p, n, { base, page, catalogue, canReply }) {
- if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
- const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';
- return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
- ${authorPanel(p)}
- <div class="postbit-main">
- <header class="postbit-head small muted"><a href="#post-${p.id}">${p.is_opening ? 'Topic' : `Reply #${n - 1}`}</a> · ${esc(fmtDate(p.created_at))}${p.revision > 1 ? ` · <span title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}${quote}</header>
- <div class="md">${p.body_html}</div>
- ${f.postImages(p)}
- ${f.reactionBar(p, base, page, catalogue)}
- </div>
- </article>`;
-}
-
-// ── /s/:space/t/:slug (forum style) ──────────────────────────
-function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
- const base = `/s/${space.slug}/t/${thread.slug}`;
- const opening = posts.find((p) => p.is_opening) || null;
- const gated = !!(space.members_only || thread.members_only);
- const indexable = space.visibility === 'public' && !gated;
- const first = (page - 1) * perPage;
- const replyBlock = viewer.can_reply
- ? `<form class="post-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
- <h2 class="h3">Reply</h2>
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
- ${f.imageField(attachmentsEnabled)}
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>
- </form>`
- : thread.locked ? '<p class="alert">This topic is locked.</p>'
- : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
- const pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);
- const body = `
-<article class="thread forum-topic">
- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}
- <header class="thread-head">
- ${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
- <div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
- ${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>
- </div>
- </header>
- ${f.crosspostBlock(crosspost, base)}
- ${f.modActions({ base, space, thread, viewer, categories })}
- ${pagerHtml}
- <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>
- ${pagerHtml}
- ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
-</article>`;
- const description = gated ? `A topic for VIP members in ${space.name} on ${SITE_NAME}.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
- return renderPage({
- title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
- description,
- canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,
- robots: indexable ? 'index,follow' : 'noindex,nofollow',
- ogType: 'article',
- published: thread.created_at || undefined,
- modified: (opening && opening.updated_at) || undefined,
- active: 'spaces',
- historyType: 'page',
- historyTitle: thread.title,
- footerVariant: 'compact',
- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [
- ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
- ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
- ],
- body,
- });
-}
-
-/** Moderators: a new space (either style), no JS. */
-function newSpacePage({ groups = [], error = null, values = {} }) {
- const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
- const body = `
-<header class="page-head">
- ${crumbs([['Spaces', '/s'], ['New space', '/s/new-space']])}
- <h1>New space</h1>
- <p class="muted">A forum board reads like a classic forum: the newest reply on top, author panels and quotes. A feed ranks posts by votes, like a subreddit. You can change the style later.</p>
-</header>
-<form class="post-form" method="post" action="/s/new-space">
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
- <label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
- <label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
- <label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
- <label class="check"><input type="checkbox" name="reactions" value="1"${values.reactions !== false ? ' checked' : ''}> Ratings on posts</label>
- <label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit">Create space</button></div>
-</form>`;
- return renderPage({ title: 'New space', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
-}
-
-module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox };
deleted file mode 100644
deleted file mode 100644
@@ -1,350 +0,0 @@
@@ -1,350 +0,0 @@
-'use strict';
-
-/**
- * Forum pages — /s (the board index), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.
- * Complete without JavaScript: sorting and pagination are links, replying, voting and the
- * moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
- * Markdown renderer's output (render/markdown.js).
- */
-const config = require('../config');
-const ld = require('./jsonld');
-const { renderPage, SITE_NAME } = require('./layout');
-const { escapeHtml: esc } = require('./highlight');
-const { markdownToText } = require('./markdown');
-const { timeTag, fmtDate, num, authorHtml } = require('./pages');
-
-const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
-const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };
-/** A request's or roadmap item's status, and a thread's category. */
-const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');
-const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');
-const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };
-
-function who(a) {
- if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';
- if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;
- if (a.is_system) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span>`;
- if (a.is_relay) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>`;
- return authorHtml(a);
-}
-
-function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = null } = {}) {
- const q = new URLSearchParams();
- if (category) q.set('category', category);
- if (status) q.set('status', status);
- if (sort && sort !== 'hot') q.set('sort', sort);
- if (page > 1) q.set('page', String(page));
- const s = q.toString();
- return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
-}
-
-function pager(href, page, pages, labels = ['Newer', 'Older']) {
- if (pages <= 1) return '';
- return `<nav class="pager" aria-label="Pages">
- ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}
- <span class="pager-info">Page ${page} of ${pages}</span>
- ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
- </nav>`;
-}
-
-const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);
-/** A members-only (OpenVibe.VIP) space or thread. */
-const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
-const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
-
-// ── /s ───────────────────────────────────────────────────────
-function spacesPage({ spaces }) {
- const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `
- <li class="space-item">
- <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
- ${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
- <p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
- </li>`).join('')}
- </ul>` : '<p class="empty">No spaces yet.</p>';
- const body = `
-<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a></nav>
- <h1>Spaces</h1>
- <p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="${config.communityUrl}/pulse">Pulse</a>.</p>
-</header>
-${list}`;
- return renderPage({
- title: 'Spaces',
- description: `Spaces on ${SITE_NAME}: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.`,
- canonicalPath: '/s',
- active: 'spaces',
- feeds: [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }])],
- body,
- });
-}
-
-// ── /s/:space ────────────────────────────────────────────────
-function threadRow(t, space) {
- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
- return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
- ${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
- <div class="thread-main">
- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
- <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
- </div>
- </li>`;
-}
-
-/**
- * The space's chat room on OpenVibe.Chat (a link), and for the space's owner or staff the no-JS forms to
- * attach one (by address or link) or detach it. Empty when there is none and the viewer cannot attach.
- */
-function chatRoomBox(space, { canManage = false, error = null } = {}) {
- const r = space.chat_room;
- if (!r && !canManage) return '';
- const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';
- const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
- const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <form class="post-form" method="post" action="/s/${esc(space.slug)}/chat-room">
- <label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>
- </form>
- ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
-</details>` : '';
- return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;
-}
-
-function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '', chatRoom = '' }) {
- const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
- const filtered = !!(category || status);
- const indexable = space.visibility === 'public' && !space.members_only && !filtered;
- const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
- const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';
- const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
- const canStart = viewer.can_start !== false;
- const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
- const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
- : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;
- const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
- const body = `
-<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
- <h1>${esc(space.name)}${visBadge(space)}</h1>
- ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
- ${start}
-</header>
-${chatRoom}
-${categoryNav}${statusNav}
-<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
-<section data-results>
- ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
- ${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
-</section>
-${footer}`;
- return renderPage({
- title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
- description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on ${SITE_NAME}.`,
- canonicalPath: spaceHref(space.slug, { sort, page, category, status }),
- robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
- active: 'spaces',
- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
- body,
- });
-}
-
-/** The pin / lock / delete / members-only / status / category buttons (no JS). */
-function modActions({ base, space, thread, viewer, categories = [] }) {
- return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
- ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
- </div>` : '';
-}
-
-// ── /s/:space/t/:slug ────────────────────────────────────────
-function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {
- if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
- return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
- <header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
- <div class="md">${p.body_html}</div>
- ${postImages(p)}
- ${reactionBar(p, base, page, catalogue)}
- </article>`;
-}
-/**
- * Where a thread came from, where else it lives, and (signed in) a form to crosspost it to another space.
- */
-function crosspostBlock(c, base) {
- if (!c) return '';
- const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';
- const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';
- const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crosspost</summary>
- <form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';
- return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';
-}
-
-/** A post's images. */
-function postImages(p) {
- return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;
-}
-/**
- * A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
- * and a picker with every rating (no JS: a <details> and a form). `base` is the thread's path.
- */
-function reactionBar(p, base, page, catalogue = []) {
- const given = p.reactions || [];
- if (!given.length && !p.can_react) return '';
- const title = (r) => `${r.label}${r.raters && r.raters.length ? `: ${r.raters.join(', ')}${r.count > r.raters.length ? ` and ${num(r.count - r.raters.length)} more` : ''}` : ''}`;
- const chip = (r) => (p.can_react
- ? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`
- : `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);
- const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {
- const list = catalogue.filter((r) => r.group === g);
- return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';
- }).join('')}</div></details>` : '';
- const inner = `${given.map(chip).join('')}${picker}`;
- return p.can_react
- ? `<form class="reactions" method="post" action="${esc(base)}/react" aria-label="Ratings"><input type="hidden" name="post" value="${Number(p.id)}"><input type="hidden" name="page" value="${Number(page) || 1}">${inner}</form>`
- : `<div class="reactions" aria-label="Ratings">${inner}</div>`;
-}
-
-const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');
-
-function voteForm(base, thread, viewer) {
- if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
- const btn = (value, icon, label) => {
- const active = thread.my_vote === value;
- return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="fa-solid ${icon}" aria-hidden="true"></i></button>`;
- };
- return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
-}
-
-function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
- const base = `/s/${space.slug}/t/${thread.slug}`;
- const opening = posts.find((p) => p.is_opening) || null;
- const gated = !!(space.members_only || thread.members_only);
- const indexable = space.visibility === 'public' && !gated;
- const first = (page - 1) * perPage;
- const replyBlock = viewer.can_reply
- ? `<form class="post-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- <label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
- ${imageField(attachmentsEnabled)}
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>
- </form>`
- : thread.locked ? '<p class="alert">This thread is locked.</p>'
- : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
- const modBlock = modActions({ base, space, thread, viewer, categories });
-
- const body = `
-<article class="thread">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
- <header class="thread-head">
- ${space.votes === false ? '' : voteForm(base, thread, viewer)}
- <div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
- ${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
- <p class="thread-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
- </div>
- </header>
- ${crosspostBlock(crosspost, base)}
- ${modBlock}
- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>
- ${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
- ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
-</article>`;
- const description = gated ? `A thread for VIP members in ${space.name} on ${SITE_NAME}.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
- return renderPage({
- title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
- description,
- canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,
- robots: indexable ? 'index,follow' : 'noindex,nofollow',
- ogType: 'article',
- published: thread.created_at || undefined,
- modified: (opening && opening.updated_at) || undefined,
- active: 'spaces',
- historyType: 'page',
- historyTitle: thread.title,
- footerVariant: 'compact',
- feeds: indexable ? [{ title: `${SITE_NAME} — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [
- ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
- ld.breadcrumbLd([{ name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
- ],
- body,
- });
-}
-
-// ── /s/:space/new ────────────────────────────────────────────
-function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
- const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
- const form = user ? `<form class="post-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
- ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
- ${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
- <label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
- ${categoryField}
- <label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
- ${imageField(attachments)}
- <label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
- <div class="form-actions">
- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
- <span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
- </div>
-</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
- const body = `
-<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
- <h1>New thread in ${esc(space.name)}</h1>
-</header>
-${form}`;
- return renderPage({
- title: `New thread · ${space.name}`,
- description: `Start a thread in ${space.name} on ${SITE_NAME}.`,
- canonicalPath: `/s/${space.slug}/new`,
- robots: 'noindex,follow',
- active: 'spaces',
- footerVariant: 'compact',
- body,
- });
-}
-
-// ── members-only teaser (403) ────────────────────────────────
-/**
- * What someone without the creator's VIP membership sees: the space (and thread title), why, and
- * the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).
- */
-function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {
- const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';
- const why = reason === 'not_signed_in' || !user
- ? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`
- : reason === 'vip_unavailable' || reason === 'entitlement_unknown'
- ? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
- : '<p class="muted">Your account does not have an active membership for this.</p>';
- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
- const body = `
-<header class="page-head">
- ${crumbs}
- <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>
- ${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
-</header>
-<section class="members-only">
- <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>
- <p>${vipJoin(mo)}</p>
- ${why}
-</section>`;
- return renderPage({
- title: `${thread ? thread.title : space.name} · members only`,
- description: `For ${creator}'s OpenVibe.VIP members on ${SITE_NAME}.`,
- canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,
- robots: 'noindex,nofollow',
- active: 'spaces',
- footerVariant: 'compact',
- body,
- });
-}
-
-module.exports = {
- spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who, chatRoomBox,
- pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, crosspostBlock, STATUS_LABELS,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,51 +0,0 @@
@@ -1,51 +0,0 @@
-'use strict';
-
-/**
- * Server-side syntax highlighting (highlight.js, common language set). Rendered into the
- * HTML so crawlers and no-JS readers get the same page as everyone else. Anything the
- * highlighter cannot name comes back escaped as plain text — never unhighlighted markup.
- */
-const hljs = require('highlight.js/lib/common');
-
-const MAX_HIGHLIGHT_BYTES = 200 * 1024; // beyond this the block is served as plain text
-
-// Language names people type → highlight.js names (and a few aliases).
-const ALIASES = {
- text: 'plaintext', txt: 'plaintext', plain: 'plaintext', log: 'plaintext',
- js: 'javascript', node: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
- py: 'python', sh: 'bash', shell: 'bash', zsh: 'bash', console: 'shell', ps1: 'powershell',
- yml: 'yaml', md: 'markdown', 'c++': 'cpp', cc: 'cpp', h: 'c', cs: 'csharp', 'c#': 'csharp',
- rb: 'ruby', rs: 'rust', golang: 'go', kt: 'kotlin', docker: 'dockerfile', html: 'xml', htm: 'xml', vue: 'xml', svg: 'xml',
- conf: 'ini', toml: 'ini', env: 'ini', make: 'makefile', mk: 'makefile', patch: 'diff',
-};
-
-function escapeHtml(s) {
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
-}
-
-function resolveLanguage(name) {
- const raw = String(name || 'text').trim().toLowerCase();
- const mapped = ALIASES[raw] || raw;
- return hljs.getLanguage(mapped) ? mapped : 'plaintext';
-}
-
-/**
- * @returns {{ html: string, language: string, lines: number, highlighted: boolean }}
- * html is the inner HTML of a <code> element.
- */
-function highlight(content, language) {
- const text = String(content || '');
- const lines = text.length ? text.split('\n').length : 0;
- const lang = resolveLanguage(language);
- if (lang === 'plaintext' || Buffer.byteLength(text, 'utf8') > MAX_HIGHLIGHT_BYTES) {
- return { html: escapeHtml(text), language: lang, lines, highlighted: false };
- }
- try {
- const out = hljs.highlight(text, { language: lang, ignoreIllegals: true });
- return { html: out.value, language: lang, lines, highlighted: true };
- } catch {
- return { html: escapeHtml(text), language: 'plaintext', lines, highlighted: false };
- }
-}
-
-module.exports = { highlight, escapeHtml, resolveLanguage };
deleted file mode 100644
deleted file mode 100644
@@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
-'use strict';
-
-/**
- * JSON-LD builders for the pages (WebSite, breadcrumbs, threads and their authors) and the
- * two small text helpers they share with the templates. renderPage emits them through
- * openvibe-shared/seo's jsonLdTag (via headTags).
- */
-const config = require('../config');
-const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./layout');
-
-function isoDate(v) {
- if (!v) return null;
- const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
-}
-
-function clean(s, max) {
- const t = String(s || '').replace(/\s+/g, ' ').trim();
- return t.length > max ? `${t.slice(0, max - 1)}…` : t;
-}
-
-// ── JSON-LD ──────────────────────────────────────────────────
-function websiteLd() {
- return {
- '@context': 'https://schema.org', '@type': 'WebSite',
- name: SITE_NAME, url: `${config.baseUrl}/s`, description: DEFAULT_DESCRIPTION,
- publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
- };
-}
-
-function breadcrumbLd(items) {
- return {
- '@context': 'https://schema.org', '@type': 'BreadcrumbList',
- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),
- };
-}
-
-/** Who wrote a thread or post, for JSON-LD: a person, the AI label (never a person), or anonymous. */
-function discussionAuthorLd(a) {
- if (!a) return { '@type': 'Person', name: 'Anonymous' };
- if (a.is_ai) return { '@type': 'Organization', name: a.display_name || 'OpenVibe AI' };
- const name = a.display_name || a.username || 'Anonymous';
- return a.username ? { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(a.username)}` } : { '@type': 'Person', name };
-}
-
-/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
-function threadLd({ space, thread, posts, opening, description }) {
- const { markdownToText } = require('./markdown');
- const url = abs(`/s/${space.slug}/t/${thread.slug}`);
- const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
- return {
- '@context': 'https://schema.org',
- '@type': 'DiscussionForumPosting',
- headline: clean(thread.title, 110),
- text: opening && !opening.deleted ? markdownToText(opening.body_markdown, 5000) : description,
- url,
- mainEntityOfPage: url,
- author: discussionAuthorLd(thread.author),
- datePublished: isoDate(thread.created_at) || undefined,
- dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
- commentCount: thread.reply_count,
- interactionStatistic: [
- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/LikeAction', userInteractionCount: Math.max(Number(thread.score) || 0, 0) },
- ],
- comment: replies.map((p) => ({
- '@type': 'Comment',
- text: markdownToText(p.body_markdown, 2000),
- author: discussionAuthorLd(p.author),
- datePublished: isoDate(p.created_at) || undefined,
- url: `${url}#post-${p.id}`,
- })),
- };
-}
-
-module.exports = { isoDate, clean, websiteLd, breadcrumbLd, threadLd, discussionAuthorLd };
@@ -4,8 +4,7 @@
@@ -4,8 +4,7 @@
* Page shell — every page on the site is server-rendered through openvibe-shared/shell: full <head>
* Page shell — every page on the site is server-rendered through openvibe-shared/shell: full <head>
* SEO (description, canonical, robots, Open Graph, Twitter card, JSON-LD and the ai-summary page
* SEO (description, canonical, robots, Open Graph, Twitter card, JSON-LD and the ai-summary page
* summary), the shared OpenVibe Frame (theme-loader first so there is no flash, navbar.js +
* summary), the shared OpenVibe Frame (theme-loader first so there is no flash, navbar.js +
- * footer.js from the Network, the SSR footer), plus this site's small stylesheet and its
- * progressive script.
+ * footer.js from the Network, the SSR footer), plus this site's small stylesheet.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -14,11 +13,15 @@ const appIcon = require('openvibe-shared/app-icon');
@@ -14,11 +13,15 @@ const appIcon = require('openvibe-shared/app-icon');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const config = require('../config');
const config = require('../config');
-const { escapeHtml } = require('./highlight');
+
+/** Escape a value for HTML text or a quoted attribute. */
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
const SITE_NAME = 'OpenVibe.Space';
const SITE_NAME = 'OpenVibe.Space';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const DEFAULT_DESCRIPTION = 'The forums of OpenVibe: public and members-only spaces, threads, replies, votes and categories — read without an account, sign in with your OpenVibe identity to post.';
+const DEFAULT_DESCRIPTION = 'OpenVibe.Space will host code and dynamic pages, and "spaces": apps people build that use their OpenVibe account, the network\'s services and the SDK. The forum is on OpenVibe.Community.';
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
@@ -59,11 +62,11 @@ function navbarInit(opts) {
@@ -59,11 +62,11 @@ function navbarInit(opts) {
const cfg = {
const cfg = {
service: 'space',
service: 'space',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
+ // Space's own navigation is the home page; the forum lives on OpenVibe.Community now (owner
+ // decision, 2026-10-08), so its link points there rather than at a /s that redirects.
links: [
links: [
- { label: 'Spaces', href: '/s', active: opts.active === 'spaces' },
- // Pulse is OpenVibe.Community's feed (D12): Space shows forums, the hub shows network activity.
- { label: 'Pulse', href: `${config.communityUrl}/pulse` },
- { label: 'Community', href: config.communityUrl },
+ { label: 'Home', href: '/', active: opts.active === 'home' },
+ { label: 'The forum', href: `${config.communityUrl}/s` },
],
],
menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
@@ -83,15 +86,13 @@ function footerInit(opts) {
@@ -83,15 +86,13 @@ function footerInit(opts) {
mount: '#ov-footer',
mount: '#ov-footer',
brandName: SITE_NAME,
brandName: SITE_NAME,
updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now
updates: `${config.communityUrl}/updates`, // this site ships with the community's log for now
- tagline: 'Forums for the people of OpenVibe — free speech within the rules.',
+ tagline: 'Code and dynamic pages for the people of OpenVibe.',
legalBase: config.liveUrl,
legalBase: config.liveUrl,
links: [{
links: [{
heading: 'Space',
heading: 'Space',
items: [
items: [
- { name: 'Spaces', url: '/s' },
- { name: 'Roadmap', url: '/s/roadmap' },
- { name: 'Feedback', url: '/s/feedback' },
- { name: 'Pulse', url: `${config.communityUrl}/pulse` },
+ { name: 'Home', url: '/' },
+ { name: 'The forum', url: `${config.communityUrl}/s` },
{ name: 'OpenVibe.Community', url: config.communityUrl },
{ name: 'OpenVibe.Community', url: config.communityUrl },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Space' },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Space' },
],
],
@@ -102,10 +103,10 @@ function footerInit(opts) {
@@ -102,10 +103,10 @@ function footerInit(opts) {
/**
/**
* @param {object} o
* @param {object} o
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
- * ogImage, imageAlt, jsonLd (array), alternates ([{ hreflang, href }]), body (main HTML), active ('spaces'),
- * feeds ([{ title, href }] RSS alternates; default: the latest-threads feed),
- * historyType ('page'|'thread'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
- * styles (Shared stylesheets by name, linked before space.css so this site's rules win: ['showcase.css']),
+ * ogImage, imageAlt, jsonLd (array), alternates ([{ hreflang, href }]), body (main HTML), active ('home'),
+ * feeds ([{ title, href }] RSS alternates; default: none),
+ * historyType, historyTitle, footerVariant ('full'|'compact'), bodyClass,
+ * styles (Shared stylesheets by name, linked before space.css so this site's rules win),
* published/modified (ISO, for article:*), noFrame (error pages during outages)
* published/modified (ISO, for article:*), noFrame (error pages during outages)
*/
*/
function renderPage(o) {
function renderPage(o) {
@@ -113,19 +114,19 @@ function renderPage(o) {
@@ -113,19 +114,19 @@ function renderPage(o) {
const canonical = abs(o.canonicalPath || '/');
const canonical = abs(o.canonicalPath || '/');
const nav = navbarInit(o);
const nav = navbarInit(o);
const foot = footerInit(o);
const foot = footerInit(o);
- const feeds = o.feeds || [{ title: `${SITE_NAME} — latest threads`, href: '/s/feed.xml' }];
+ const feeds = o.feeds || [];
// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js
// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
const html = shell.page({
const html = shell.page({
name: SITE_NAME, service: 'space', lang: 'en',
name: SITE_NAME, service: 'space', lang: 'en',
- title: o.title || `${SITE_NAME} — the forums of OpenVibe`,
+ title: o.title || `${SITE_NAME} — code and dynamic pages`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
jsonLd: o.jsonLd, alternates: o.alternates,
jsonLd: o.jsonLd, alternates: o.alternates,
summary: description, url: canonical, updated: o.modified,
summary: description, url: canonical, updated: o.modified,
- navbar: nav, home: '/s', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
+ navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
footer: { service: 'space', variant: 'full', updates: `${config.communityUrl}/updates` },
footer: { service: 'space', variant: 'full', updates: `${config.communityUrl}/updates` },
head: [
head: [
o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
@@ -135,7 +136,6 @@ function renderPage(o) {
@@ -135,7 +136,6 @@ function renderPage(o) {
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
- `<script src="${asset('js/space.js')}" defer></script>`,
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].filter(Boolean).join('\n'),
].filter(Boolean).join('\n'),
@@ -156,4 +156,4 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -156,4 +156,4 @@ document.addEventListener('DOMContentLoaded', function () {
return o.ogImage ? html : html.replace('<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:card" content="summary">');
return o.ogImage ? html : html.replace('<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:card" content="summary">');
}
}
-module.exports = { renderPage, asset, assetVersion, abs, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
+module.exports = { renderPage, asset, assetVersion, abs, setRelease, escapeHtml, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
deleted file mode 100644
deleted file mode 100644
@@ -1,226 +0,0 @@
@@ -1,226 +0,0 @@
-'use strict';
-
-/**
- * A small, safe Markdown renderer for forum posts.
- *
- * Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;
- * the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,
- * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep
- * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".
- * Fenced code blocks go through the same highlight.js wrapper posts use (which escapes).
- *
- * Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so
- * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an
- * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare
- * https:// links. No images, tables, footnotes or raw HTML.
- */
-const { escapeHtml: esc, highlight } = require('./highlight');
-
-const MAX_SOURCE = 100_000;
-const MAX_QUOTE_DEPTH = 3;
-// Every pattern here runs on untrusted text on each page view, so none may backtrack
-// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.
-const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;
-const FENCE = { test: (line) => FENCE_RE.test(line.trimEnd()) };
-const HEADING_RE = /^ {0,3}(#{1,6})\s+([\s\S]*)$/;
-const QUOTE = /^ {0,3}>\s?(.*)$/;
-const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
-const UL = /^ {0,3}[-*+]\s+(.*)$/;
-const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;
-const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;
-
-/** [, hashes, text] for an ATX heading (text without the optional closing #s), or null. */
-function headingMatch(line) {
- const m = line.match(HEADING_RE);
- if (!m) return null;
- const text = m[2].trimEnd().replace(/#+$/, '').trimEnd();
- return /[\u2028\u2029]/.test(text) ? null : [m[0], m[1], text];
-}
-const HEADING = { test: (line) => headingMatch(line) !== null };
-
-/**
- * Same result as s.replace(/<open>([\s\S]*?\S)<close>/g, …) for a delimiter pair, in linear time:
- * the lazy regex rescans to the end of the text for every opener that has no closer (quadratic).
- * Content runs from the end of an opener to the first closer after it; once one opener finds no
- * closer, no later opener can either, so the scan stops.
- */
-function pairUp(s, open, close, wrap) {
- const o = new RegExp(open.source, 'g');
- const c = new RegExp(close.source, 'g');
- let out = '';
- let pos = 0;
- for (;;) {
- o.lastIndex = pos;
- const m = o.exec(s);
- if (!m) break;
- const start = m.index + m[0].length;
- c.lastIndex = start;
- const k = c.exec(s);
- if (!k) break;
- const end = k.index + 1; // the closer pattern starts with the content's last (non-space) character
- out += s.slice(pos, m.index) + wrap(m, s.slice(start, end));
- pos = k.index + k[0].length;
- }
- return out + s.slice(pos);
-}
-
-/**
- * Same result as s.replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, …) in O(n log n): an opener
- * is the tail of a backtick run (longest first, as the regex tries it), its closer the next whole
- * run of exactly that length not preceded by a newline, with non-empty content not starting with one.
- */
-function codeSpans(s, onCode) {
- const runs = [];
- const byLen = new Map();
- const re = /`+/g;
- let m;
- while ((m = re.exec(s))) {
- runs.push([m.index, m[0].length]);
- if (s[m.index - 1] !== '\n') {
- if (!byLen.has(m[0].length)) byLen.set(m[0].length, []);
- byLen.get(m[0].length).push(m.index);
- }
- }
- const nextAt = (list, from) => {
- let lo = 0;
- let hi = list.length;
- while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid] < from) lo = mid + 1; else hi = mid; }
- return lo < list.length ? list[lo] : -1;
- };
- let out = '';
- let pos = 0;
- for (const [at, len] of runs) {
- if (at < pos) continue;
- const contentStart = at + len;
- if (contentStart >= s.length || s[contentStart] === '\n') continue;
- for (let n = len; n >= 1; n--) {
- const list = byLen.get(n);
- const close = list ? nextAt(list, contentStart + 1) : -1;
- if (close < 0) continue;
- out += s.slice(pos, at + len - n) + onCode(s.slice(contentStart, close));
- pos = close + n;
- break;
- }
- }
- return out + s.slice(pos);
-}
-
-function unescapeEntities(s) {
- return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
-}
-
-/** Inline formatting over one block's text (raw, unescaped in; safe HTML out). */
-function inline(raw) {
- const slots = [];
- const hold = (html) => `\u0000${slots.push(html) - 1}\u0000`;
- // 1. code spans keep their content literal
- let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));
- // 2. everything else is escaped before any tag is written
- s = esc(s);
- // 3. [text](url) — only safe schemes; the rest stays as text
- s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
- if (!SAFE_URL.test(unescapeEntities(url))) return m;
- return hold(`<a href="${url}" rel="nofollow ugc noopener">${emphasis(text)}</a>`);
- });
- // 4. bare links
- s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {
- let url = m, tail = '';
- while (/[.,;:!?)\]'"]$/.test(url) || /&(quot|#39|gt|lt);$/.test(url)) {
- const ent = url.match(/&(quot|#39|gt|lt);$/);
- const cut = ent ? ent[0].length : 1;
- tail = url.slice(-cut) + tail; url = url.slice(0, -cut);
- }
- return url.length > 8 ? hold(`<a href="${url}" rel="nofollow ugc noopener">${url}</a>`) + tail : m;
- });
- s = emphasis(s);
- s = s.replace(/\n/g, '<br>\n');
- // Restore held pieces (a link's text may itself hold a code span).
- for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => slots[Number(n)] || '');
- return s;
-}
-
-/** Bold / italic / strike over already-escaped text. */
-function emphasis(s) {
- s = pairUp(s, /\*\*(?=\S)/, /\S\*\*/, (_m, t) => `<strong>${t}</strong>`);
- s = pairUp(s, /(^|[^\w])__(?=\S)/, /\S__(?!\w)/, (m, t) => `${m[1]}<strong>${t}</strong>`);
- s = s
- .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
- .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>');
- return pairUp(s, /~~(?=\S)/, /\S~~/, (_m, t) => `<del>${t}</del>`);
-}
-
-function isBlockStart(line) {
- return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
-}
-
-function blocks(lines, depth) {
- const out = [];
- let i = 0;
- while (i < lines.length) {
- const line = lines[i];
- if (!line.trim()) { i++; continue; }
-
- let m = line.trimEnd().match(FENCE_RE);
- if (m) {
- const fence = m[1], lang = m[2];
- const closing = new RegExp(`^ {0,3}\\${fence[0]}{${fence.length},}\\s*$`);
- const body = [];
- i++;
- while (i < lines.length && !closing.test(lines[i])) body.push(lines[i++]);
- i++; // closing fence (or end of input)
- const code = body.join('\n');
- const hl = lang ? highlight(code, lang) : { html: esc(code), language: 'plaintext' };
- out.push(`<pre class="md-code"><code class="hljs language-${esc(hl.language)}">${hl.html}</code></pre>`);
- continue;
- }
- if (HR.test(line)) { out.push('<hr>'); i++; continue; }
- m = headingMatch(line);
- if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }
- if (QUOTE.test(line)) {
- const inner = [];
- while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
- out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);
- continue;
- }
- if (UL.test(line) || OL.test(line)) {
- const ordered = !UL.test(line);
- const re = ordered ? OL : UL;
- const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
- const items = [];
- while (i < lines.length && lines[i].trim()) {
- const mm = lines[i].match(re);
- if (mm) items.push(ordered ? mm[2] : mm[1]);
- else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
- else break;
- i++;
- }
- const tag = ordered ? 'ol' : 'ul';
- out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);
- continue;
- }
- const para = [];
- while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);
- out.push(`<p>${inline(para.join('\n'))}</p>`);
- }
- return out.join('\n');
-}
-
-/** Markdown source → safe HTML (see the header for what is supported). */
-function renderMarkdown(source) {
- const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
- return blocks(text.split('\n'), 0);
-}
-
-/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */
-function markdownToText(source, max = 300) {
- const t = String(source == null ? '' : source).replace(/\u0000/g, '')
- .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
- .replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')
- .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
- .replace(/(\*\*|__|~~|`)/g, '')
- .replace(/\s+/g, ' ')
- .trim();
- return t.length > max ? `${t.slice(0, max - 1)}…` : t;
-}
-
-module.exports = { renderMarkdown, markdownToText };
@@ -1,61 +1,29 @@
@@ -1,61 +1,29 @@
'use strict';
'use strict';
/**
/**
- * Page templates. Plain template strings, every user value escaped, no framework. The markup
- * is the whole page for crawlers and no-JS readers; public/js/space.js only adds the
- * comforts (in-place pagination, the reply form's upload path).
+ * Page templates. Plain template strings, every value escaped, no framework. The markup is the whole
+ * page for crawlers and no-JS readers; the shared Frame adds the navbar, footer and theme.
*
*
- * What is left of the shared page layer after the forum moved off Community: the author and time
- * helpers every forum template uses, and the error page. Pastes, comments, submissions and their
- * pages stayed on OpenVibe.Community.
+ * Space publishes one page of its own while it is rebuilt: the home page, which says what Space will
+ * host and points at the forum on OpenVibe.Community. It claims nothing Space does not have.
*/
*/
const config = require('../config');
const config = require('../config');
-const ld = require('./jsonld');
-const { renderPage } = require('./layout');
-const { escapeHtml: esc } = require('./highlight');
+const { renderPage, escapeHtml: esc } = require('./layout');
-// ── Small helpers ────────────────────────────────────────────
-function timeAgo(v) {
- const iso = ld.isoDate(v);
- if (!iso) return '';
- const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
- if (s < 60) return 'just now';
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
- if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
- if (s < 86400 * 365) return `${Math.floor(s / (86400 * 30))}mo ago`;
- return `${Math.floor(s / (86400 * 365))}y ago`;
-}
-function fmtDate(v) {
- const iso = ld.isoDate(v);
- if (!iso) return '';
- return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');
-}
-function timeTag(v) {
- const iso = ld.isoDate(v);
- return iso ? `<time datetime="${esc(iso)}" title="${esc(fmtDate(v))}">${esc(timeAgo(v))}</time>` : '';
-}
-function num(n) { return Number(n || 0).toLocaleString('en-US'); }
-function authorName(p) { return p.display_name || p.username || 'Anonymous'; }
-/** A person's picture at `size` px: their own, else the network's avatar address (a generated initial when none). */
-function avatarUrl(p, size = 96) {
- if (!p) return null;
- if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;
- return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;
-}
-function authorHtml(p, { link = true } = {}) {
- const name = authorName(p);
- const initial = esc(name.trim()[0] || '?').toUpperCase();
- const avatarSrc = p.avatar_url ? (/^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`) : null;
- // No picture on the post's own record: the network's avatar address answers for any account (the person's
- // picture, or a generated initial), so an author looks the same here as on every other OpenVibe site.
- const netAvatar = !avatarSrc && p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=44` : null;
- const avatar = (avatarSrc || netAvatar)
- ? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`
- : `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;
- const inner = `${avatar}<span>${esc(name)}</span>`;
- if (!link || !p.username) return `<span class="author">${inner}</span>`;
- return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
+const FORUM_URL = `${config.communityUrl}/s`;
+const FORUM_LABEL = FORUM_URL.replace(/^https?:\/\//, '');
+
+/** The home page: what Space will host, and where the forum went. */
+function homePage() {
+ const body = `
+<section class="hero">
+ <p class="eyebrow">OpenVibe.Space</p>
+ <h1>Code and dynamic pages for the OpenVibe network</h1>
+ <p class="lede">Space is being rebuilt to host <strong>code</strong> and <strong>dynamic pages</strong>, and <strong>spaces</strong>: apps people build that use their OpenVibe account, the network's services and the SDK.</p>
+ <p class="forum-note">The forum moved to OpenVibe.Community → <a href="${esc(FORUM_URL)}">${esc(FORUM_LABEL)}</a></p>
+ <p class="muted">Nothing is published here yet.</p>
+</section>`;
+ return renderPage({ canonicalPath: '/', active: 'home', body });
}
}
// ── Errors ───────────────────────────────────────────────────
// ── Errors ───────────────────────────────────────────────────
@@ -65,9 +33,9 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
@@ -65,9 +33,9 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
<p class="eyebrow">${status}</p>
<p class="eyebrow">${status}</p>
<h1>${esc(title)}</h1>
<h1>${esc(title)}</h1>
${message ? `<p class="lede">${esc(message)}</p>` : ''}
${message ? `<p class="lede">${esc(message)}</p>` : ''}
- ${links ? `<p><a class="btn" href="/s">All spaces</a></p>` : ''}
+ ${links ? `<p><a class="btn" href="/">Home</a> <a class="btn" href="${esc(FORUM_URL)}">The forum</a></p>` : ''}
</section>`;
</section>`;
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
}
-module.exports = { errorPage, timeAgo, timeTag, fmtDate, num, authorName, authorHtml, avatarUrl };
+module.exports = { homePage, errorPage };
deleted file mode 100644
deleted file mode 100644
@@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
-'use strict';
-
-/**
- * Space's seam to OpenVibe.VIP: members-only forum spaces and threads.
- *
- * gate.check({ subject, type: 'space'|'thread', id, owner }) → { allow, reason }
- * POST /api/v1/policies/evaluate through the product cache (createVipCache), with the owner
- * Space stored on the row and the product's default gate
- * { requirement: 'member', binding: 'space:members_only' } — the creator's own VIP rule for
- * the resource, if they set one, wins. The owner passes without asking VIP.
- * gate.joinUrl(owner, username) → the creator's page on openvibe.vip
- * gate.cache invalidate / handleEvent (vip.membership.changed, …) / clear
- *
- * Convergence: Space drops the member's cached answers at once when vip.membership.changed arrives
- * (server/events-consumer.js), so a cached "yes" outlives VIP's "no" by at most config.vip.ttlMs
- * (30 s by default, the bound if the event is missed); see OpenVibe.VIP's README for the end-to-end bound.
- * Fails closed: no client secret, VIP down, a refused token or a malformed answer is a "no".
- */
-const { serviceAuth, ids } = require('openvibe-contracts');
-const { createVipClient, createVipCache } = require('./vip-client');
-
-const FALLBACK = Object.freeze({ requirement: 'member', binding: 'space:members_only' });
-
-function createVipGate({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
- const vipConfig = config.vip || {};
- const secret = config.oauth && config.oauth.clientSecret;
- const tokenClient = secret ? serviceAuth.createTokenClient({
- tokenUrl: `${config.networkInternalUrl}/oauth/token`,
- clientId: config.oauth.clientId,
- clientSecret: secret,
- audience: 'openvibe.vip',
- scope: 'vip.resource.policy.evaluate',
- fetchImpl,
- }) : null;
- const client = createVipClient({
- baseUrl: vipConfig.internalUrl || 'http://127.0.0.1:4620',
- tokenClient,
- getToken: tokenClient ? null : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set'); },
- fetch: fetchImpl,
- timeoutMs: vipConfig.timeoutMs || 2000,
- log,
- });
- const cache = createVipCache({
- vip: client,
- ttlMs: vipConfig.ttlMs || 30_000,
- denyTtlMs: vipConfig.denyTtlMs || 10_000,
- unavailableTtlMs: vipConfig.unavailableTtlMs || 2_000,
- now,
- });
-
- async function check({ subject, type, id, owner }) {
- if (!owner) return { allow: true, reason: 'open' };
- if (subject && subject === owner) return { allow: true, reason: 'owner' };
- if (!subject) return { allow: false, reason: 'not_signed_in' };
- const d = await cache.evaluate({ subject, resource: { service: 'space', type, id: String(id) }, owner, fallback: FALLBACK });
- return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
- }
-
- const base = (vipConfig.publicUrl || 'https://openvibe.vip').replace(/\/$/, '');
- const joinUrl = (owner, username) => `${base}/${encodeURIComponent(username || owner)}`;
-
- return { check, joinUrl, cache, client, FALLBACK, bounds: cache.bounds };
-}
-
-const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);
-
-module.exports = { createVipGate, isUserSubject, FALLBACK };
deleted file mode 100644
deleted file mode 100644
@@ -1,4 +0,0 @@
@@ -1,4 +0,0 @@
-'use strict';
-// The VIP consumer seam is openvibe-sdk/vip (createVipClient, createVipCache), published once for every
-// product instead of a copy of openvibe-vip/client in each. Kept at this path so callers do not change.
-module.exports = require('openvibe-sdk/vip');
deleted file mode 100644
deleted file mode 100644
@@ -1,55 +0,0 @@
@@ -1,55 +0,0 @@
-'use strict';
-
-/**
- * Up/down votes on forum threads — one row per (thread, person), value 1 or -1.
- *
- * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same transaction, by
- * recomputing the target's score from the vote rows. The score is never incremented or decremented,
- * so interleaved votes from any number of connections or processes cannot drift it. The transaction
- * first locks the target row (SELECT … FOR UPDATE), so votes on one target queue and each recount
- * sees every vote committed before it.
- */
-const TARGETS = {
- thread: { votes: 'thread_votes', key: 'thread_id', table: 'threads', counts: false },
-};
-
-/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */
-async function applyVote(db, kind, targetId, subject, value) {
- const t = TARGETS[kind];
- if (!t) throw new Error(`unknown vote target ${kind}`);
- if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');
- return await db.tx(async () => {
- await db.prepare(`SELECT id FROM ${t.table} WHERE id = ? FOR UPDATE`).get(targetId);
- if (value === 0) {
- await db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);
- } else {
- await db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)
- ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = ov_now()`)
- .run(targetId, subject, value);
- }
- const agg = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS score,
- COUNT(*) FILTER (WHERE value = 1) AS upvotes,
- COUNT(*) FILTER (WHERE value = -1) AS downvotes
- FROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);
- await db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);
- return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };
- });
-}
-
-/** The person's current votes on some targets. → Map id → 1|-1 */
-async function myVotes(db, kind, targetIds, subject) {
- const t = TARGETS[kind];
- const out = new Map();
- if (!subject || !targetIds.length) return out;
- const rows = await db.prepare(`SELECT ${t.key} AS id, value FROM ${t.votes} WHERE ${t.key} = ANY(?::bigint[]) AND subject_id = ?`).all([...new Set(targetIds)], subject);
- for (const r of rows) out.set(r.id, r.value);
- return out;
-}
-
-/** Parse a request's vote value: 1, -1 or 0 (numbers or numeric strings), else null. */
-function parseVote(v) {
- const n = typeof v === 'string' && /^-?[01]$/.test(v.trim()) ? Number(v) : v;
- return n === 1 || n === -1 || n === 0 ? n : null;
-}
-
-module.exports = { applyVote, myVotes, parseVote };
deleted file mode 100644
deleted file mode 100644
@@ -1,147 +0,0 @@
@@ -1,147 +0,0 @@
-'use strict';
-/**
- * Images on forum posts (WS-J task 2): an upload goes to OpenVibe.Media's Object API v2 as a med_ object
- * the person owns (init → content → complete, Community's service token, X-OV-Subject), metadata stripped
- * and the type taken from the bytes; a thread or reply names at most 4 of the person's own unattached
- * uploads; posts show them; the no-JS forms upload and attach in one step; someone else's upload, a
- * non-image, an anonymous upload and Media being unconfigured are refused.
- */
-const assert = require('assert');
-const http = require('http');
-const crypto = require('crypto');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const { createMediaObjects } = require('../server/media/objects');
-const { sniffImage } = require('../server/forum/service');
-
-// A 1×1 PNG with a tEXt chunk (metadata that must not reach Media), with real lengths and CRCs.
-const zlib = require('zlib');
-const chunk = (type, data) => {
- const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
- const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
- const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
- return Buffer.concat([len, td, crc]);
-};
-const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;
-const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('tEXt', Buffer.from('Comment\0secret', 'latin1')),
- chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);
-
-(async () => {
- // ── A stub Media (Object API v2 for tenant space) ──
- const objects = new Map();
- const media = http.createServer((req, res) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- const body = Buffer.concat(chunks);
- const send = (status, obj) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj)); };
- if (req.url === '/oauth/token') return send(200, { access_token: 'svc-space', token_type: 'Bearer', expires_in: 300 });
- if (req.headers.authorization !== 'Bearer svc-space') return send(401, { code: 'auth.required' });
- if (req.method === 'POST' && req.url === '/api/v2/space/objects') {
- const b = JSON.parse(body);
- const id = ids.newId('media');
- objects.set(id, { ...b, owner: req.headers['x-ov-subject'], status: 'uploading' });
- return send(201, { id, object: { id } });
- }
- let m = req.url.match(/^\/api\/v2\/space\/objects\/(med_[0-9A-Z]+)\/content$/);
- if (req.method === 'PUT' && m) { const o = objects.get(m[1]); o.bytes = body; o.content_type = req.headers['content-type']; return send(200, { id: m[1], size_bytes: body.length }); }
- m = req.url.match(/^\/api\/v2\/space\/objects\/(med_[0-9A-Z]+)\/complete$/);
- if (req.method === 'POST' && m) {
- const o = objects.get(m[1]);
- const hash = crypto.createHash('sha256').update(o.bytes).digest('hex');
- if (hash !== JSON.parse(body).content_hash || o.bytes.length !== o.size_bytes) return send(400, { code: 'media.object.hash_mismatch' });
- o.status = 'ready';
- return send(200, { id: m[1], lifecycle_status: 'ready' });
- }
- send(404, {});
- });
- });
- await new Promise((r) => media.listen(0, '127.0.0.1', r));
- const mediaBase = `http://127.0.0.1:${media.address().port}`;
- const mediaObjects = createMediaObjects({ config: { oauth: { clientId: 'space', clientSecret: 'x'.repeat(40) }, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });
-
- const t = await boot({ appOpts: { mediaObjects, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const upload = (cookie, buf = PNG, name = 'cat.png') => {
- const fd = new FormData();
- fd.append('file', new Blob([buf], { type: 'image/png' }), name);
- return t.get('/api/v1/spaces/general/attachments', { method: 'POST', body: fd, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- };
- const json = (cookie, path, obj) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(obj), cookies: [`ov_token=${cookie}`] });
-
- await check('sniffing goes by the bytes', async () => {
- assert.strictEqual(sniffImage(PNG), 'image/png');
- assert.strictEqual(sniffImage(Buffer.from('GIF89a......')), 'image/gif');
- assert.strictEqual(sniffImage(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')), null);
- });
-
- let first;
- await check('upload: a med_ object owned by the person, metadata stripped, the public URL', async () => {
- const r = await upload(alexJwt);
- assert.strictEqual(r.status, 201, r.text);
- first = r.json().attachment;
- assert.match(first.media_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
- assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);
- const o = objects.get(first.media_id);
- assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'unlisted', 'reachable by link, never listed on openvibe.media'); assert.strictEqual(o.status, 'ready');
- assert.ok(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');
- assert.ok(PNG.includes(Buffer.from('secret')));
- });
-
- await check('refused: signed out, not an image, too many, someone else\'s upload', async () => {
- assert.strictEqual((await upload(null)).status, 401);
- const svg = await upload(alexJwt, Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>x</script></svg>'), 'x.svg');
- assert.strictEqual(svg.status, 415);
- let r = await json(samJwt, '/api/v1/spaces/general/threads', { title: 'Stolen', body: 'mine now', attachments: [first.media_id] });
- assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.invalid');
- r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Too many', body: 'x', attachments: [1, 2, 3, 4, 5].map(() => first.media_id) });
- assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.too_many');
- });
-
- let thread;
- await check('a thread names its images; the post shows them; an attached upload cannot be reused', async () => {
- let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'My cat', body: 'Look.', attachments: [first.media_id] });
- assert.strictEqual(r.status, 201, r.text);
- thread = r.json().thread;
- assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [first.media_id]);
- const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
- assert.strictEqual(page.posts[0].attachments[0].url, first.url);
- assert.strictEqual(page.attachments.enabled, true);
- r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
- assert.strictEqual(r.status, 400, 'already on a post');
- const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
- assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
- });
-
- await check('no-JS reply form: the images are uploaded and attached in one step', async () => {
- const fd = new FormData();
- fd.append('body', 'Two more');
- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
- fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
- const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
- assert.strictEqual(r.status, 303, r.text.slice(0, 300));
- const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
- const reply = page.posts.find((p) => p.body_markdown === 'Two more');
- assert.deepStrictEqual(reply.attachments.map((a) => a.filename), ['a.png', 'b.png']);
- assert.ok(reply.attachments.every((a) => objects.get(a.media_id).owner === sam.subject_id));
- const empty = new FormData();
- empty.append('body', '');
- empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
- const before = objects.size;
- await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
- assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
- });
-
- await check('without the service principal, attaching is refused and the forms have no file field', async () => {
- const off = createMediaObjects({ config: { oauth: {}, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });
- assert.strictEqual(off.configured, false);
- await assert.rejects(off.uploadImage({ buffer: PNG, mime: 'image/png', filename: 'x.png', owner: alex.subject_id }), /service principal/);
- });
-
- media.close();
- await done(t);
-})();
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
/**
/**
- * The real process on a fresh database (the T10 finish line): `node server/index.js` with no
- * DATABASE_URL, so migrations run on an embedded PGlite database of its own; /api/ready is 2xx and
- * says what it serves, GET /s renders the seeded board index, / sends visitors there, /llms.txt and
- * /robots.txt answer, and SIGTERM stops it cleanly (exit 0, within the manifest's 5 s).
+ * The real process on a fresh database: `node server/index.js` with no DATABASE_URL, so migrations run
+ * on an embedded PGlite database of its own; /api/ready is 2xx and names the store, GET / renders the
+ * home page, every forum URL redirects to OpenVibe.Community, /llms.txt, /robots.txt and /sitemap.xml
+ * answer, and SIGTERM stops it cleanly (exit 0, within the manifest's 5 s).
* Nothing here is mocked: this is the entry point the unit would run.
* Nothing here is mocked: this is the entry point the unit would run.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -31,9 +31,8 @@ async function check(name, fn) {
@@ -31,9 +31,8 @@ async function check(name, fn) {
...process.env,
...process.env,
PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
SPACE_PGLITE_DIR: path.join(dir, 'pglite'),
SPACE_PGLITE_DIR: path.join(dir, 'pglite'),
- DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',
+ DATABASE_URL: '', DATABASE_DIRECT_URL: '',
BASE_URL: `http://127.0.0.1:${port}`,
BASE_URL: `http://127.0.0.1:${port}`,
- EVENTS_URL: '', SPACE_EVENTS_SECRET: '',
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
});
});
@@ -64,31 +63,29 @@ async function check(name, fn) {
@@ -64,31 +63,29 @@ async function check(name, fn) {
assert.strictEqual(body.checks.db.detail.store, 'pglite');
assert.strictEqual(body.checks.db.detail.store, 'pglite');
});
});
- await check('GET /s renders the board index with the seeded spaces', async () => {
- const r = await fetch(`${base}/s`);
+ await check('GET / renders the home page: what Space will host, and the forum on Community', async () => {
+ const r = await fetch(`${base}/`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const html = await r.text();
const html = await r.text();
- assert.match(html, /<title>Spaces · OpenVibe\.Space<\/title>/);
- assert.match(html, /href="\/s\/general"/, 'a seeded space is on the board index');
- assert.match(html, /href="\/s\/showcase"/);
- assert.match(html, /OpenVibe\.Space — latest threads/, 'the threads feed is advertised');
- assert.match(html, /rel="canonical" href="http:\/\/127\.0\.0\.1:\d+\/s"/);
+ assert.match(html, /<title>OpenVibe\.Space — code and dynamic pages<\/title>/);
+ assert.match(html, /<h1>Code and dynamic pages for the OpenVibe network<\/h1>/);
+ assert.match(html, /The forum moved to OpenVibe\.Community/);
+ assert.match(html, /href="https:\/\/openvibe\.community\/s"/);
});
});
- await check('a space and its feed render; / redirects to the board index', async () => {
- const space = await fetch(`${base}/s/general`);
- assert.strictEqual(space.status, 200);
- const html = await space.text();
- assert.match(html, /<h1>General/);
- const feed = await fetch(`${base}/s/feed.xml`);
- assert.strictEqual(feed.status, 200);
- assert.match(await feed.text(), /<rss version="2\.0"/);
- const root = await fetch(`${base}/`, { redirect: 'manual' });
- assert.strictEqual(root.status, 301);
- assert.strictEqual(root.headers.get('location'), '/s');
+ await check('every forum URL is a permanent redirect to OpenVibe.Community', async () => {
+ const s = await fetch(`${base}/s`, { redirect: 'manual' });
+ assert.strictEqual(s.status, 301);
+ assert.strictEqual(s.headers.get('location'), 'https://openvibe.community/s');
+ const nested = await fetch(`${base}/s/general/t/1?x=2`, { redirect: 'manual' });
+ assert.strictEqual(nested.status, 301);
+ assert.strictEqual(nested.headers.get('location'), 'https://openvibe.community/s/general/t/1?x=2');
+ const api = await fetch(`${base}/api/v1/spaces/general/threads`, { redirect: 'manual' });
+ assert.strictEqual(api.status, 308);
+ assert.strictEqual(api.headers.get('location'), 'https://openvibe.community/api/v1/spaces/general/threads');
});
});
- await check('crawler endpoints answer: robots.txt, llms.txt, sitemap.xml', async () => {
+ await check('crawler endpoints answer: robots.txt, llms.txt, sitemap.xml (the home page alone)', async () => {
const robots = await fetch(`${base}/robots.txt`);
const robots = await fetch(`${base}/robots.txt`);
assert.strictEqual(robots.status, 200);
assert.strictEqual(robots.status, 200);
assert.match(await robots.text(), /Sitemap:/);
assert.match(await robots.text(), /Sitemap:/);
@@ -97,7 +94,9 @@ async function check(name, fn) {
@@ -97,7 +94,9 @@ async function check(name, fn) {
assert.match(await llms.text(), /OpenVibe\.Space/);
assert.match(await llms.text(), /OpenVibe\.Space/);
const map = await fetch(`${base}/sitemap.xml`);
const map = await fetch(`${base}/sitemap.xml`);
assert.strictEqual(map.status, 200);
assert.strictEqual(map.status, 200);
- assert.match(await map.text(), /<loc>http:\/\/127\.0\.0\.1:\d+\/s<\/loc>/);
+ const xml = await map.text();
+ assert.match(xml, new RegExp(`<loc>http://127\\.0\\.0\\.1:${port}/</loc>`));
+ assert.doesNotMatch(xml, /\/s</, 'no forum pages in the sitemap');
});
});
await check('SIGTERM stops it cleanly (exit 0, within the manifest’s 5 s)', async () => {
await check('SIGTERM stops it cleanly (exit 0, within the manifest’s 5 s)', async () => {
@@ -105,7 +104,7 @@ async function check(name, fn) {
@@ -105,7 +104,7 @@ async function check(name, fn) {
const r = await Promise.race([exited, sleep(7000).then(() => null)]);
const r = await Promise.race([exited, sleep(7000).then(() => null)]);
assert.ok(r, 'the process did not exit within 7 s');
assert.ok(r, 'the process did not exit within 7 s');
assert.deepStrictEqual(r, { code: 0, signal: null }, `exit ${JSON.stringify(r)}\n${out}`);
assert.deepStrictEqual(r, { code: 0, signal: null }, `exit ${JSON.stringify(r)}\n${out}`);
- assert.match(out, /\[Space\] stopped: subscriptions off, relay off, outbox off/, 'the stop is logged');
+ assert.match(out, /\[Space\] stopped/, 'the stop is logged');
});
});
} finally {
} finally {
if (child.exitCode === null) child.kill('SIGKILL');
if (child.exitCode === null) child.kill('SIGKILL');
deleted file mode 100644
deleted file mode 100644
@@ -1,138 +0,0 @@
@@ -1,138 +0,0 @@
-'use strict';
-/**
- * Categories, feature requests and the Roadmap space (WS-J tasks 1 and 8): seeded categories, filtering
- * by category and status, a request's first status and staff-only status changes, category moves by the
- * author or staff, staff-only roadmap threads, the roadmap sync from docs/roadmap/public.json (create,
- * update in place with the history kept, unchanged, bad items skipped) and the no-JS pages.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
-
-(async () => {
- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
- const call = (path, { method = 'GET', cookie, json, form } = {}) => {
- const h = {};
- let body;
- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
- if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- };
-
- await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {
- const spaces = (await call('/api/v1/spaces')).json().spaces;
- assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['help', 'discussion'], ['feedback', 'request'], ['roadmap', 'roadmap'], ['showcase', 'discussion'], ['off-topic', 'discussion']]);
- assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);
- const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;
- assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);
- });
-
- let req;
- await check('a request starts open, in its category; unknown categories are refused', async () => {
- let r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Dark mode for clips', body: 'Please.', category: 'ideas' } });
- assert.strictEqual(r.status, 201, r.text);
- req = r.json().thread;
- assert.strictEqual(req.kind, 'request'); assert.strictEqual(req.status, 'open');
- assert.deepStrictEqual([req.category.slug, req.category.name], ['ideas', 'Ideas']);
- r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Player crashes', body: 'On Safari.', category: 'bugs' } });
- assert.strictEqual(r.status, 201);
- r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Nope', body: 'x', category: 'nope' } });
- assert.strictEqual(r.status, 404); assert.strictEqual(r.json().code, 'category.not_found');
- const plain = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Just talk', body: 'hi' } })).json().thread;
- assert.strictEqual(plain.kind, 'discussion'); assert.strictEqual(plain.status, null);
- });
-
- await check('filters: by category and by status; a bad status is 400', async () => {
- let r = (await call('/api/v1/spaces/feedback/threads?category=bugs')).json();
- assert.deepStrictEqual(r.threads.map((x) => x.title), ['Player crashes']);
- assert.strictEqual(r.categories.find((c) => c.slug === 'bugs').thread_count, 1);
- r = (await call('/api/v1/spaces/feedback/threads?status=open')).json();
- assert.strictEqual(r.total, 2);
- assert.strictEqual((await call('/api/v1/spaces/feedback/threads?status=shipped')).status, 400);
- assert.strictEqual((await call('/api/v1/spaces/general/threads?status=open')).status, 400, 'discussions have no status');
- });
-
- await check('status: staff only, from the kind\'s list', async () => {
- let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: alexJwt, json: { status: 'done' } });
- assert.strictEqual(r.status, 403);
- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'paused' } });
- assert.strictEqual(r.status, 400, 'paused is a roadmap status');
- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'planned' } });
- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().thread.status, 'planned');
- assert.deepStrictEqual((await call('/api/v1/spaces/feedback/threads?status=planned')).json().threads.map((x) => x.slug), [req.slug]);
- });
-
- await check('category: the author or staff move a thread; others cannot; deleting a category keeps its threads', async () => {
- let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: samJwt, json: { category: 'bugs' } });
- assert.strictEqual(r.status, 403);
- r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: alexJwt, json: { category: 'questions' } });
- assert.strictEqual(r.json().thread.category.slug, 'questions');
- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: alexJwt, json: { name: 'Polls' } });
- assert.strictEqual(r.status, 403, 'only staff manage categories');
- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: modJwt, json: { name: 'Polls', position: 9 } });
- assert.strictEqual(r.status, 200, r.text);
- await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: modJwt, json: { category: 'polls' } });
- r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'DELETE', cookie: modJwt });
- assert.strictEqual(r.status, 200);
- const thread = (await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread;
- assert.strictEqual(thread.category, null); assert.strictEqual(thread.status, 'planned');
- });
-
- await check('roadmap: people cannot start items; staff can', async () => {
- let r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: alexJwt, json: { title: 'My idea', body: 'x' } });
- assert.strictEqual(r.status, 403); assert.strictEqual(r.json().code, 'space.staff_threads');
- r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: modJwt, json: { title: 'A staff item', body: 'x', category: 'features' } });
- assert.strictEqual(r.status, 201); assert.strictEqual(r.json().thread.status, 'planned');
- });
-
- await check('roadmap sync: creates, updates in place (history kept), leaves unchanged items alone, skips bad ones', async () => {
- const db = t.db;
- const items = [
- { key: 'chat-calls', category: 'features', status: 'planned', title: 'Calls in Chat', summary: 'Calls move to Chat.' },
- { key: 'bad key!', status: 'planned', title: 'x', summary: 'y' },
- { key: 'nostatus', title: 'No status', summary: 'y' },
- ];
- const quiet = { warn() {} };
- assert.deepStrictEqual(await syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });
- let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');
- assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');
- assert.deepStrictEqual(await syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });
- const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];
- assert.deepStrictEqual(await syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });
- const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();
- assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');
- assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');
- const file = await syncFromFile(db, { log: quiet });
- assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));
- assert.deepStrictEqual((await syncFromFile(db, { log: quiet })).updated, 0, 'the shipped file syncs to a fixed point');
- });
-
- await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
- let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });
- assert.strictEqual(r.status, 200);
- const html = r.text;
- assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));
- assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');
- r = await call('/s/roadmap', { cookie: alexJwt });
- assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));
- assert.ok(r.text.includes('badge-status status-in_progress'));
- r = await call('/s/roadmap/new', { cookie: alexJwt });
- assert.strictEqual(r.status, 403);
- r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });
- assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));
- r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
- assert.strictEqual(r.status, 303);
- assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');
- r = await call('/s/feedback/new', { cookie: alexJwt });
- assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));
- });
-
- await done(t);
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,188 +0,0 @@
@@ -1,188 +0,0 @@
-'use strict';
-/**
- * A space's chat room (roadmap WS-I task 4; server/chat-rooms.js, forum/service.js attachChatRoom):
- * the space's owner (its creator, or the creator whose members' space it is) or staff attach a room on
- * OpenVibe.Chat; Community asks Chat with the person's own token and Chat answers whether they manage the
- * room. Attaching again is a no-op, another room replaces it, detach is idempotent; others, services and
- * signed-out visitors cannot attach; Chat's refusals (not the owner, a private room, Chat down) come back
- * as problems and as notices on the page; the space page links the room. A stub Chat stands in for
- * OpenVibe.Chat's POST/DELETE /api/chat/rooms/:slug/attachments.
- */
-const assert = require('assert');
-const http = require('http');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-
-/** Stub Chat: rooms with owners (by Network subject), private rooms with members, attachments. */
-function startStubChat() {
- const rooms = new Map();
- const attachments = new Map(); // `${room}|${space}` → attached_by subject
- const calls = [];
- const state = { down: false };
- const subjectOf = (auth) => {
- const tok = String(auth || '').replace(/^Bearer /, '');
- try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()).subject_id || null; } catch { return null; }
- };
- const server = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- if (state.down) { req.socket.destroy(); return; }
- const send = (status, body) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
- const who = subjectOf(req.headers.authorization);
- let body = {}; try { body = raw ? JSON.parse(raw) : {}; } catch { /* */ }
- calls.push({ method: req.method, url: req.url, who, auth: req.headers.authorization || null, body });
- let m = /^\/api\/chat\/rooms\/([^/]+)\/attachments$/.exec(req.url);
- if (m && req.method === 'POST') {
- const room = rooms.get(m[1]);
- if (!who) return send(401, { error: 'Authentication required' });
- if (!room || (room.visibility === 'private' && !room.members.includes(who))) return send(404, { error: 'No such room', code: 'rooms.not_found' });
- if (room.owner !== who) return send(403, { error: 'Only the room\'s owner attaches it elsewhere', code: 'rooms.not_owner' });
- const key = `${room.slug}|${body.resource}`;
- const created = !attachments.has(key);
- if (created) attachments.set(key, who);
- return send(created ? 201 : 200, { attachment: { service: body.service, resource: body.resource, title: body.title }, created, room: { id: room.id, slug: room.slug, name: room.name, kind: room.kind, visibility: room.visibility } });
- }
- m = /^\/api\/chat\/rooms\/([^/]+)\/attachments\/space\/([^/]+)$/.exec(req.url);
- if (m && req.method === 'DELETE') {
- const room = rooms.get(m[1]);
- const key = `${m[1]}|${m[2]}`;
- if (!room) return send(404, { error: 'No such room' });
- if (!attachments.has(key)) return send(200, { ok: true, removed: false });
- if (room.owner !== who && attachments.get(key) !== who) return send(403, { error: 'no' });
- attachments.delete(key);
- return send(200, { ok: true, removed: true });
- }
- send(404, { error: 'Not found' });
- });
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
- url: `http://127.0.0.1:${server.address().port}`, rooms, attachments, calls, state,
- addRoom: (slug, o) => rooms.set(slug, { id: rooms.size + 1, slug, name: slug, kind: 'community', visibility: 'public', members: [], ...o }),
- close: () => new Promise((r) => server.close(r)),
- })));
-}
-
-(async () => {
- const chat = await startStubChat();
- process.env.OV_CHAT_INTERNAL_URL = chat.url;
- process.env.OV_CHAT_URL = 'https://openvibe.chat';
- const t = await boot({ });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const bossSubject = ids.newId('user');
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });
- // Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.
- await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);
- await t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);
- chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });
- chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });
- chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });
- chat.addRoom('sams-room', { name: 'Sam\'s Room', owner: sam.subject_id });
- chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });
- const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
- const form = (jwt, path, fields, origin = 'https://openvibe.space') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });
- const rows = async () => await t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();
-
- await check('only the space\'s owner or staff attach, and only as themselves', async () => {
- const before = chat.calls.length;
- assert.strictEqual((await api('PUT', '/showcase/chat-room', null, { room: 'night-owls' })).status, 401);
- let r = await api('PUT', '/showcase/chat-room', samJwt, { room: 'sams-room' });
- assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'not the space\'s owner');
- const svc = net.signService({ sub: 'svc:live', cap: ['space.forum.manage', 'space.post.write'] });
- r = await api('PUT', '/showcase/chat-room', svc, { room: 'night-owls' });
- assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.person_only'], 'a service has no person\'s token to show Chat');
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });
- assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);
- assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');
- assert.deepStrictEqual(await rows(), []);
- });
-
- await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {
- let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'https://openvibe.chat/r/night-owls' });
- assert.strictEqual(r.status, 201, r.text);
- assert.deepStrictEqual(r.json().chat_room, { slug: 'night-owls', name: 'Night Owls', kind: 'community', visibility: 'public', url: 'https://openvibe.chat/r/night-owls', attached_at: r.json().chat_room.attached_at });
- const call = chat.calls[chat.calls.length - 1];
- assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'space', resource: 'showcase', title: 'Showcase' }]);
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
- assert.deepStrictEqual([r.status, r.json().created], [200, false]);
- assert.strictEqual((await rows()).length, 1);
- assert.strictEqual((await rows())[0].attached_by, alex.subject_id);
- assert.strictEqual(chat.attachments.size, 1);
- assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');
- });
-
- await check('Chat\'s refusals: not the room\'s owner, a private room, Chat down; nothing changes', async () => {
- let r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'stage' });
- assert.deepStrictEqual([r.status, r.json().code], [403, 'chat_room.not_owner']);
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'secret' });
- assert.deepStrictEqual([r.status, r.json().code], [404, 'chat_room.not_found'], 'a private room is no more than a missing one');
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'no-such-room' });
- assert.strictEqual(r.json().code, 'chat_room.not_found');
- chat.state.down = true;
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
- assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);
- const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });
- assert.strictEqual(f.status, 303);
- assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);
- const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
- assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
- chat.state.down = false;
- assert.strictEqual((await rows())[0].room_slug, 'night-owls');
- });
-
- await check('the space page links the room; its owner gets the forms; others only the link', async () => {
- let page = await t.get('/s/showcase');
- assert.strictEqual(page.status, 200);
- assert.match(page.text, /<section class="chat-room" id="chat-room"/);
- assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);
- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');
- page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });
- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');
- page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });
- assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));
- page = await t.get('/s/general');
- assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');
- page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });
- assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');
- // Forum-style spaces show it too.
- await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });
- assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
- assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
- });
-
- await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {
- // Staff replace the Showcase room with their own; Chat is told the space let go of night-owls (best effort).
- let r = await api('PUT', '/showcase/chat-room', bossJwt, { room: 'staff-room' });
- assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);
- assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/space/showcase'));
- assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');
- assert.strictEqual((await rows()).find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);
- // The owner puts theirs back and detaches it without JavaScript: both sides go.
- r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
- assert.strictEqual(r.status, 201);
- const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
- assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
- assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));
- assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
- r = await api('DELETE', '/showcase/chat-room', alexJwt);
- assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');
- assert.strictEqual((await api('DELETE', '/general/chat-room', samJwt)).status, 403, 'only the owner or staff detach');
- const svc = net.signService({ sub: 'svc:live', cap: ['space.forum.manage'] });
- r = await api('DELETE', '/general/chat-room', svc);
- assert.deepStrictEqual([r.status, r.json().detached, r.json().chat], [200, true, 'unavailable'], 'a moderator service may detach (Chat keeps its side)');
- });
-
- await check('the creator whose members\' space it is may attach their room', async () => {
- const r = await api('PUT', '/off-topic/chat-room', samJwt, { room: 'sams-room' });
- assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.json().chat_room.name, 'Sam\'s Room');
- });
-
- await chat.close();
- await t.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,200 +0,0 @@
@@ -1,200 +0,0 @@
-'use strict';
-/**
- * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug
- * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,
- * JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const forumStore = require('../server/forum/store');
-
-const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
-const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
-const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
-
-(async () => {
- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- // These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.
- await t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
- const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
-
- await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
- const r = await t.get('/s');
- assert.strictEqual(r.status, 200);
- has(r.text, '<title>Spaces · OpenVibe.Space</title>');
- has(r.text, '<link rel="canonical" href="https://openvibe.space/s">');
- has(r.text, '<meta name="robots" content="index,follow">');
- has(r.text, 'href="/s/feed.xml"');
- for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);
- assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));
- const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);
- });
-
- await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {
- const page = await t.get('/s/general/new');
- assert.strictEqual(page.status, 200);
- has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');
- has(page.text, '<meta name="robots" content="noindex,follow">');
- const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));
- assert.strictEqual(anon.status, 303);
- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);
- const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
- assert.strictEqual(bad.status, 400);
- has(bad.text, 'Titles are 3 to 200 characters');
- has(bad.text, '<img src=x onerror=alert(1)></textarea>');
- const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
- assert.strictEqual(ok.status, 303, ok.text);
- assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');
- const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
- assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
- });
-
- const url = '/s/general/t/how-do-i-b-embed-b-comments';
-
- await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
- const r = await t.get(url);
- assert.strictEqual(r.status, 200);
- has(r.text, '<title>How do I <b>embed</b> comments? · OpenVibe.Space</title>');
- has(r.text, `<link rel="canonical" href="https://openvibe.space${url}">`);
- has(r.text, '<meta property="og:type" content="article">');
- has(r.text, '<meta property="article:published_time"');
- has(r.text, '<strong>Live</strong>');
- has(r.text, '<code class="hljs language-javascript"><span class="hljs-keyword">const</span>');
- has(r.text, '<script>alert(1)</script>');
- assert.ok(!r.text.includes('<script>alert(1)</script>'));
- const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');
- assert.ok(post, 'DiscussionForumPosting JSON-LD');
- assert.strictEqual(post.headline, 'How do I <b>embed</b> comments?');
- assert.strictEqual(post.url, `https://openvibe.space${url}`);
- assert.deepStrictEqual(post.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
- assert.ok(post.datePublished && post.text.startsWith('Asking for Live.'));
- assert.strictEqual(post.commentCount, 0);
- assert.ok(!r.text.match(/<script type="application\/ld\+json">[^<]*<\/script>/g).some((s) => /<\/(b|script)>/i.test(s.slice(35, -9))), 'no raw closing tags inside JSON-LD');
- assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList' && o.itemListElement.length === 3));
- has(r.text, `/auth/login?next=${encodeURIComponent(url)}`, 'anonymous readers get a sign-in link instead of the form');
- assert.ok(!r.text.includes(`action="${url}/reply"`));
- });
-
- await check('no-JS reply and vote: 303 back to the post; score and replies render; JSON-LD comments', async () => {
- const reply = await t.get(`${url}/reply`, as(samJwt, form({ body: 'Use **/api/v1/comments**.' })));
- assert.strictEqual(reply.status, 303, reply.text);
- assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));
- const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));
- assert.strictEqual(empty.status, 400);
- has(empty.text, 'Write something first');
- const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));
- assert.strictEqual(vote.status, 303);
- assert.strictEqual(vote.headers.get('location'), url);
- const r = await t.get(url, as(samJwt));
- has(r.text, `action="${url}/reply"`);
- has(r.text, `action="${url}/vote"`);
- has(r.text, 'class="vote-btn active" type="submit" name="value" value="0" aria-pressed="true"', 'pressing your vote again removes it');
- has(r.text, '<span class="vote-score" title="Score">1</span>');
- has(r.text, 'Use <strong>/api/v1/comments</strong>.');
- const post = ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting');
- assert.strictEqual(post.commentCount, 1);
- assert.strictEqual(post.comment[0].author.name, 'Sam');
- assert.ok(post.comment[0].url.includes('#post-'));
- const anonVote = await t.get(`${url}/vote`, form({ value: '1' }));
- assert.strictEqual(anonVote.status, 303);
- assert.match(anonVote.headers.get('location'), /^\/auth\/login/);
- });
-
- await check('moderation forms: people 403, staff pin and lock; the author deletes their thread', async () => {
- assert.strictEqual((await t.get(`${url}/state`, as(samJwt, form({ pinned: '1' })))).status, 403);
- assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ pinned: '1' })))).status, 303);
- assert.strictEqual((await t.get(`${url}/state`, as(adminJwt, form({ locked: '1' })))).status, 303);
- const r = await t.get(url, as(samJwt));
- has(r.text, 'This thread is locked.');
- assert.ok(!r.text.includes(`action="${url}/reply"`));
- const list = await t.get('/s/general');
- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');
- const admin = await t.get(url, as(adminJwt));
- has(admin.text, 'Unpin');
- has(admin.text, 'Unlock');
- const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
- const mineUrl = mine.headers.get('location');
- assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);
- const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));
- assert.strictEqual(del.status, 303);
- assert.strictEqual(del.headers.get('location'), '/s/general');
- assert.strictEqual((await t.get(mineUrl)).status, 404);
- });
-
- await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
- const space = await forumStore.getSpace(t.db, 'showcase');
- for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
- const p1 = await t.get('/s/showcase?sort=new');
- assert.strictEqual(p1.status, 200);
- has(p1.text, '<link rel="canonical" href="https://openvibe.space/s/showcase?sort=new">');
- has(p1.text, 'href="/s/showcase"');
- has(p1.text, 'href="/s/showcase?sort=top"');
- has(p1.text, 'rel="next" href="/s/showcase?sort=new&page=2"');
- has(p1.text, 'Page 1 of 2');
- assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);
- const p2 = await t.get('/s/showcase?sort=new&page=2');
- has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');
- has(p2.text, '<link rel="canonical" href="https://openvibe.space/s/showcase?sort=new&page=2">');
- has(p2.text, 'Showcase item 1<');
- assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);
- assert.strictEqual((await t.get('/s/nowhere')).status, 404);
- });
-
- await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
- const ins = await forumStore.getSpace(t.db, 'insiders');
- await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
- const anon = await t.get('/s/insiders');
- assert.strictEqual(anon.status, 303);
- assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
- const member = await t.get('/s/insiders', as(samJwt));
- assert.strictEqual(member.status, 200);
- has(member.text, '<meta name="robots" content="noindex,nofollow">');
- const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));
- has(th.text, '<meta name="robots" content="noindex,nofollow">');
- assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);
- assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);
- const feed = await t.get('/s/feed.xml');
- assert.ok(!feed.text.includes('Members only chat'));
- assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
- require('../server/discovery').resetCaches();
- const map = await t.get('/sitemap.xml');
- assert.ok(!map.text.includes('members-only-chat'));
- });
-
- await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
- const space = await forumStore.getSpace(t.db, 'feedback');
- await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
- const r = await t.get('/s/feedback/t/weekly-summary');
- has(r.text, 'OpenVibe AI');
- has(r.text, '<span class="badge badge-ai"');
- assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });
- });
-
- await check('sitemap, feeds and robots.txt include the forum', async () => {
- require('../server/discovery').resetCaches();
- const map = await t.get('/sitemap.xml');
- for (const loc of ['/s', '/s/general', url]) has(map.text, `<loc>https://openvibe.space${loc}</loc>`);
- const feed = await t.get('/s/feed.xml');
- assert.strictEqual(feed.status, 200);
- has(feed.text, '<rss version="2.0"');
- has(feed.text, `<link>https://openvibe.space${url}</link>`);
- has(feed.text, '<title>How do I <b>embed</b> comments?</title>');
- const spaceFeed = await t.get('/s/showcase/feed.xml');
- has(spaceFeed.text, 'Showcase item 27');
- assert.ok(!spaceFeed.text.includes('embed'));
- const robots = await t.get('/robots.txt');
- has(robots.text, 'Disallow: /s/*/new');
- });
-
- await t.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,130 +0,0 @@
@@ -1,130 +0,0 @@
-'use strict';
-/**
- * Forum style and the features both styles share: the board index (groups, child boards, topics, posts,
- * last post), a forum-style board (topic table, Replies/Views/Last post, sticky first, bumped by replies),
- * topics with author panels and Quote, per-space votes and ratings, Facepunch-style ratings (one per
- * person per post, toggle, replace, not your own, off per space, totals on the author panel), images on
- * posts, crossposts between a forum and a feed, space settings and new spaces (moderators only).
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-
-(async () => {
- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
- const call = (path, { method = 'GET', cookie, json, form } = {}) => {
- const h = {};
- let body;
- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
- if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- };
- let topic;
- await check('a forum topic: votes off, a topic row with replies, views and the last post', async () => {
- let r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Welcome thread', body: 'Say hi.' } });
- assert.strictEqual(r.status, 201, r.text);
- topic = r.json().thread;
- r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });
- assert.strictEqual(r.status, 201);
- assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');
- await call('/s/general/t/welcome-thread', { cookie: samJwt });
- await call('/s/general/t/welcome-thread', { cookie: samJwt });
- await call('/s/general/t/welcome-thread', { cookie: alexJwt });
- const list = (await call('/api/v1/spaces/general/threads')).json();
- assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');
- const row = list.threads[0];
- assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
- assert.strictEqual(row.last_post.author.username, 'sam');
- const page = (await call('/s/general')).text;
- assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));
- assert.ok(!page.includes('class="thread-score"'), 'no score column');
- });
-
- await check('the board index: groups in order, every space with topics, posts and the last post', async () => {
- const r = (await call('/api/v1/spaces')).json();
- const general = r.groups[0].spaces.find((s) => s.slug === 'general');
- assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);
- assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');
- const html = (await call('/s')).text;
- assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Forum') && html.includes('badge-style">Feed'));
- });
-
- await check('ratings: one per person per post, toggle, replace, not your own; totals on the author panel', async () => {
- const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0];
- const rate = (cookie, reaction) => call(`/api/v1/posts/${post.id}/reactions`, { method: 'POST', cookie, json: { reaction } });
- assert.strictEqual((await rate(alexJwt, 'winner')).json().code, 'reaction.own_post');
- let r = await rate(samJwt, 'winner');
- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().mine, 'winner');
- r = await rate(modJwt, 'winner');
- assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 2]]);
- r = await rate(samJwt, 'late');
- assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 1], ['late', 1]], 'another rating replaces yours');
- r = await rate(samJwt, 'late');
- assert.strictEqual(r.json().mine, null, 'the same one again takes it back');
- assert.strictEqual((await rate(samJwt, 'amazing')).status, 400);
- assert.strictEqual((await rate(null, 'agree')).status, 401);
- const page = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
- assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);
- assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);
- assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');
- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
- assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
- r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
- assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
- await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
- assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
- assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);
- await t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();
- });
-
- await check('quote: ?quote= fills the reply box with the quoted post', async () => {
- const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];
- const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
- assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');
- });
-
- await check('crosspost: a forum topic into a feed, both linked; members-only never', async () => {
- const r = await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'showcase' } });
- assert.strictEqual(r.status, 201, r.text);
- const cp = r.json().thread;
- assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);
- const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();
- assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');
- assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));
- const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
- assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);
- assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));
- assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);
- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
- assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));
- });
-
- await check('space settings and new spaces: moderators only; style switches the pages', async () => {
- let r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: alexJwt, json: { style: 'forum' } });
- assert.strictEqual(r.status, 403);
- r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });
- assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);
- assert.ok((await call('/s/showcase')).text.includes('topic-table'));
- r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
- assert.strictEqual(r.status, 303);
- assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));
- assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);
- r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
- assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');
- const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');
- assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);
- r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });
- assert.strictEqual(r.status, 200, r.text);
- const idx = (await call('/api/v1/spaces')).json();
- assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');
- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');
- });
-
- await done(t);
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,210 +0,0 @@
@@ -1,210 +0,0 @@
-'use strict';
-/**
- * Forum rules and /api/v1/spaces + /api/v1/posts: seeded spaces, thread creation (slugs,
- * validation, limits), replies and counters, sorting (hot formula, new, top, pinned first,
- * pagination), votes, edits with versions, deletes, locks, members/staff spaces, service
- * capabilities (space.post.write, space.forum.manage) and AI authorship.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const forumStore = require('../server/forum/store');
-
-(async () => {
- const t = await boot({ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
- const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
- const POST = 'space.post.write', MOD = 'space.forum.manage';
- const forum = t.app.locals.forum;
- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
-
- const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {
- const h = { ...headers };
- if (token) h.authorization = `Bearer ${token}`;
- let body;
- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
- return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- };
-
- let first;
-
- await check('seeded spaces: on the board index in their groups, all public', async () => {
- const r = await call('/api/v1/spaces');
- assert.strictEqual(r.status, 200);
- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility, s.style]), [['general', 'public', 'forum'], ['help', 'public', 'forum'], ['feedback', 'public', 'feed'], ['roadmap', 'public', 'feed'], ['showcase', 'public', 'feed'], ['off-topic', 'public', 'forum']]);
- assert.deepStrictEqual(r.json().groups.map((g) => [g.slug, g.spaces.map((s) => s.slug)]), [['openvibe', ['general', 'help', 'feedback', 'roadmap']], ['community', ['showcase', 'off-topic']]]);
- assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
- });
-
- await check('create thread: author from the JWT, slug from the title (deduplicated), opening post rendered safely', async () => {
- const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: ' Hello, World! ', body: '**hi** <script>alert(1)</script>', author_subject: sam.subject_id } });
- assert.strictEqual(r.status, 201, r.text);
- first = r.json().thread;
- assert.strictEqual(first.slug, 'hello-world');
- assert.strictEqual(first.title, 'Hello, World!');
- assert.strictEqual(first.url, '/s/general/t/hello-world');
- assert.strictEqual(first.author.subject, alex.subject_id);
- const op = r.json().post;
- assert.strictEqual(op.is_opening, true);
- assert.strictEqual(op.body_html, '<p><strong>hi</strong> <script>alert(1)</script></p>');
- const again = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world', body: 'dup title' } });
- assert.strictEqual(again.json().thread.slug, 'hello-world-2');
- const elsewhere = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Hello world!', body: 'other space' } });
- assert.strictEqual(elsewhere.json().thread.slug, 'hello-world', 'slugs are unique per space');
- assert.strictEqual(forumStore.slugify('Ünïcödé — ☃ !!'), 'unicode');
- assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');
- });
-
- await check('create thread: validation and who may post', async () => {
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: ' ' } })).json().code, 'post.empty');
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Fine title', body: 'x'.repeat(40_001) } })).json().code, 'post.too_long');
- const noCap = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc(['space.thread.read']), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Via service', body: 'x' } });
- assert.strictEqual(noCap.status, 403);
- const noSubject = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), json: { title: 'Via service', body: 'x' } });
- assert.strictEqual(noSubject.status, 401, 'a service names the person or says it is AI');
- const ok = await call('/api/v1/spaces/general/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'Via service', body: 'as sam' } });
- assert.strictEqual(ok.status, 201, ok.text);
- assert.strictEqual(ok.json().thread.author.username, 'sam');
- });
-
- await check('AI threads: origin ai, no author subject, labelled', async () => {
- const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', token: svc([POST]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { title: 'Stream highlights', body: 'AI summary' } });
- assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.json().thread.origin, 'ai');
- assert.strictEqual(r.json().thread.author.is_ai, true);
- assert.strictEqual(r.json().thread.author.subject, null);
- assert.strictEqual((await t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id)).author_subject, null);
- });
-
- await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
- const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });
- assert.strictEqual(r1.status, 201, r1.text);
- assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);
- await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });
- const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
- assert.strictEqual(th.thread.reply_count, 2);
- assert.deepStrictEqual(th.posts.map((p) => p.body_markdown), ['**hi** <script>alert(1)</script>', 'first reply', 'second reply']);
- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: samJwt, json: { locked: true } })).status, 403);
- const lock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', cookie: adminJwt, json: { locked: true } });
- assert.strictEqual(lock.status, 200, lock.text);
- assert.strictEqual(lock.json().thread.locked, true);
- const refused = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'let me in' } });
- assert.strictEqual(refused.status, 403);
- assert.strictEqual(refused.json().code, 'thread.locked');
- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: adminJwt, json: { body: 'mod note' } })).status, 201);
- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).status, 403);
- const svcUnlock = await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([MOD]), json: { locked: false } });
- assert.strictEqual(svcUnlock.status, 200, 'a moderating service acting as itself');
- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world/state', { method: 'PUT', token: svc([POST]), json: { locked: false } })).status, 403);
- });
-
- await check('edits keep versions; only the author or a moderator edits', async () => {
- const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
- const reply = th.posts.find((p) => p.body_markdown === 'first reply');
- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: alexJwt, json: { body: 'hijack' } })).status, 403);
- const e1 = await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'first reply (edited)' } });
- assert.strictEqual(e1.status, 200, e1.text);
- assert.strictEqual(e1.json().post.revision, 2);
- await call(`/api/v1/posts/${reply.id}`, { method: 'PUT', cookie: adminJwt, json: { body: 'moderated' } });
- const v = await call(`/api/v1/posts/${reply.id}/versions`, { cookie: samJwt });
- assert.deepStrictEqual(v.json().versions.map((x) => [x.revision, x.body_markdown]), [[1, 'first reply'], [2, 'first reply (edited)'], [3, 'moderated']]);
- assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);
- });
-
- await check('votes on threads: off in a space without them; add/change/remove, score recomputed, my_vote shown', async () => {
- const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
- const off = await vote(1, samJwt);
- assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');
- await t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();
- assert.strictEqual((await vote(1, samJwt)).json().score, 1);
- assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
- assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
- assert.strictEqual((await vote(0, samJwt)).json().score, 1);
- assert.strictEqual((await vote(1)).status, 401);
- assert.strictEqual((await vote('x', samJwt)).status, 400);
- assert.strictEqual((await call('/api/v1/spaces/general/threads/hello-world', { cookie: alexJwt })).json().thread.my_vote, 1);
- });
-
- await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {
- const space = await forumStore.getSpace(t.db, 'showcase');
- const mk = async (title, score, hoursAgo) => {
- const { thread } = await forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });
- await t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")
- .run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);
- return thread;
- };
- await t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);
- await mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144
- await mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192
- await mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75
- await mk('ancient', 3, 500);
- const now = new Date('2026-09-22T12:00:00Z');
- const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);
- assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);
- assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');
- assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);
- assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);
- const ancient = (await t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get()).id;
- await t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);
- assert.strictEqual((await titles('hot'))[0], 'ancient');
- const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });
- const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });
- assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);
- const hot = (await t.db.prepare('SELECT ov_hot(5, 2) AS h').get()).h;
- assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');
- });
-
- await check('deletes: author or moderator; opening post deletes the thread; tombstones keep numbering', async () => {
- const th = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Delete me later', body: 'op' } })).json().thread;
- const reply = (await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'a reply' } })).json().post;
- await call(`/api/v1/spaces/general/threads/${th.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'another' } });
- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
- assert.strictEqual((await call(`/api/v1/posts/${reply.id}`, { method: 'DELETE', cookie: alexJwt })).status, 200);
- const after = (await call(`/api/v1/spaces/general/threads/${th.slug}`)).json();
- assert.strictEqual(after.thread.reply_count, 1);
- assert.deepStrictEqual(after.posts.map((p) => p.deleted), [false, true, false]);
- assert.strictEqual(after.posts[1].body_markdown, null);
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`, { method: 'DELETE', cookie: alexJwt })).status, 403);
- const op = after.posts[0];
- assert.strictEqual((await call(`/api/v1/posts/${op.id}`, { method: 'DELETE', cookie: samJwt })).json().deleted, 'thread');
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.slug}`)).status, 404);
- const other = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Moderated away', body: 'x' } })).json().thread;
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${other.slug}`, { method: 'DELETE', cookie: adminJwt })).status, 200);
- });
-
- await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {
- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
- assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);
- assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);
- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);
- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: adminJwt })).status, 200);
- const listed = (await call('/api/v1/spaces', { cookie: samJwt })).json().spaces.map((s) => s.slug);
- assert.ok(listed.includes('insiders') && !listed.includes('mods'));
- assert.ok(!(await call('/api/v1/spaces')).json().spaces.some((s) => s.slug === 'insiders'));
- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: samJwt, json: { title: 'Sneaky', body: 'x' } })).status, 404);
- assert.strictEqual((await call('/api/v1/spaces/mods/threads', { method: 'POST', cookie: adminJwt, json: { title: 'Mod talk', body: 'x' } })).status, 201);
- });
-
- await check('person limits: thread cooldown and daily cap, reply duplicates', async () => {
- const { createForumService } = require('../server/forum/service');
- const strict = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 30 } } });
- const who = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
- await strict.createThread(who, 'general', { title: 'One thread', body: 'x' });
- await assert.rejects(strict.createThread(who, 'general', { title: 'Two thread', body: 'y' }), (e) => e.status === 429 && e.code === 'request.rate_limited');
- const daily = createForumService({ db: t.db, limits: { threadsPerDay: 2, threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } });
- await daily.createThread(who, 'general', { title: 'Second thread', body: 'x' });
- await assert.rejects(daily.createThread(who, 'general', { title: 'Third thread', body: 'x' }), (e) => e.status === 429 && /Daily/.test(e.message));
- await daily.reply(who, 'general', 'second-thread', { body: 'same words' });
- await assert.rejects(daily.reply(who, 'general', 'second-thread', { body: 'same words' }), (e) => e.code === 'request.duplicate');
- });
-
- await t.close();
- done();
-})();
@@ -1,23 +1,20 @@
@@ -1,23 +1,20 @@
'use strict';
'use strict';
-/** Boots the mocks, points the app at them, and returns a tiny HTTP client that keeps cookies. */
+/** Boots the Network mock, points the app at it, and returns a tiny HTTP client that keeps cookies. */
const http = require('http');
const http = require('http');
const mockNetwork = require('./mock-network');
const mockNetwork = require('./mock-network');
-const mockMedia = require('./mock-media');
/**
/**
- * Boots the Network and Media mocks, points the app at them, and returns a tiny HTTP client.
- * 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.appOpts passed through to createApp (forumLimits, vipOptions, relayOptions, …).
+ * Boots the Network mock (JWKS + the token endpoints sign-in needs), points the app at it, and
+ * returns a tiny HTTP client. Every boot gets a migrated database of its own (./db.js: PGlite, or
+ * the PostgreSQL containers under npm run test:pg).
+ * opts.appOpts passed through to createApp.
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
* is also the one the Network mock accepts).
* is also the one the Network mock accepts).
*/
*/
async function boot(opts = {}) {
async function boot(opts = {}) {
const netSrv = await mockNetwork.start({ clientSecret: (opts.env && opts.env.OV_OAUTH_CLIENT_SECRET) || 'shh' });
const netSrv = await mockNetwork.start({ clientSecret: (opts.env && opts.env.OV_OAUTH_CLIENT_SECRET) || 'shh' });
- const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.BASE_URL = 'https://openvibe.space';
process.env.BASE_URL = 'https://openvibe.space';
- process.env.OV_MEDIA_URL = 'https://openvibe.media';
process.env.OV_NETWORK_URL = netSrv.url;
process.env.OV_NETWORK_URL = netSrv.url;
process.env.OV_NETWORK_INTERNAL_URL = netSrv.url;
process.env.OV_NETWORK_INTERNAL_URL = netSrv.url;
process.env.OV_OAUTH_CLIENT_ID = 'space';
process.env.OV_OAUTH_CLIENT_ID = 'space';
@@ -25,12 +22,6 @@ async function boot(opts = {}) {
@@ -25,12 +22,6 @@ async function boot(opts = {}) {
process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.space/auth/callback';
process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.space/auth/callback';
process.env.COOKIE_SECURE = 'true';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = '1';
process.env.TRUST_PROXY = '1';
- process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
- // test:pg: the Valkey container too, under a prefix of its own.
- if (process.env.SPACE_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {
- process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;
- process.env.VALKEY_PREFIX = `ov:space-test:${process.pid}:${Math.random().toString(36).slice(2, 10)}:`;
- }
Object.assign(process.env, opts.env || {});
Object.assign(process.env, opts.env || {});
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
@@ -56,10 +47,9 @@ async function boot(opts = {}) {
@@ -56,10 +47,9 @@ async function boot(opts = {}) {
return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
}
}
return {
return {
- app, base, get, jar, network: netSrv, media: mediaSrv, db: appOpts.db || null,
+ app, base, get, jar, network: netSrv, db: appOpts.db || null,
close: async () => {
close: async () => {
- await new Promise((r) => server.close(r)); await netSrv.close(); await mediaSrv.close();
- if (app.locals.valkey) await app.locals.valkey.close().catch(() => {});
+ await new Promise((r) => server.close(r)); await netSrv.close();
await appOpts.db.close().catch(() => {});
await appOpts.db.close().catch(() => {});
},
},
};
};
deleted file mode 100644
deleted file mode 100644
@@ -1,97 +0,0 @@
@@ -1,97 +0,0 @@
-'use strict';
-/**
- * Stand-ins for Discord in the relay tests.
- *
- * startWebhooks(): an HTTP server speaking the webhook API the relay uses:
- * POST /api/webhooks/:id/:token?wait=true[&thread_id=] → 200 the message { id, channel_id, webhook_id } (204 without wait)
- * PATCH /api/webhooks/:id/:token/messages/:mid[?thread_id] → 200 the message
- * DELETE /api/webhooks/:id/:token/messages/:mid[?thread_id] → 204
- * an unknown or deleted message → 404 { code: 10008, message: 'Unknown Message' }
- * Message ids count up from 1100000000000000000; a webhook's channel is 2000000000000000000 + its id,
- * or the thread_id it was sent to. `plan` queues canned answers ({ status, body, headers } or
- * { hang: true }) that take precedence; `hits` records every request.
- *
- * createGateway(): a fake WebSocket class (gw.WebSocket) the gateway client is given, with the
- * server side scripted by the test: gw.sockets, gw.push(sock, payload), gw.drop(sock, code), and
- * gw.onOpen / gw.onSend hooks. Sockets open on the next tick.
- */
-const http = require('http');
-
-function startWebhooks() {
- const hits = [];
- const plan = [];
- const messages = new Map();
- let next = 1100000000000000000n;
- const server = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- const u = new URL(req.url, 'http://discord.test');
- const body = raw ? JSON.parse(raw) : null;
- hits.push({ method: req.method, path: u.pathname + u.search, pathname: u.pathname, query: Object.fromEntries(u.searchParams), body });
- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(obj === undefined ? '' : JSON.stringify(obj)); };
- const canned = plan.shift();
- if (canned) {
- if (canned.hang) return undefined;
- res.writeHead(canned.status, { 'Content-Type': 'application/json', ...(canned.headers || {}) });
- return res.end(canned.body ? JSON.stringify(canned.body) : '');
- }
- const m = u.pathname.match(/^\/api\/webhooks\/(\d+)\/([^/]+)(?:\/messages\/(\d+))?$/);
- if (!m) return json(404, { message: 'Unknown Webhook', code: 10015 });
- const [, hookId, , mid] = m;
- if (req.method === 'POST' && !mid) {
- const id = String(next++);
- const channelId = u.searchParams.get('thread_id') || String(2000000000000000000n + BigInt(hookId));
- messages.set(id, { id, channel_id: channelId, webhook_id: hookId, body, deleted: false });
- if (u.searchParams.get('wait') !== 'true') { res.writeHead(204); return res.end(); }
- return json(200, { id, channel_id: channelId, webhook_id: hookId, content: body && body.content });
- }
- const msg = mid ? messages.get(mid) : null;
- if (!msg || msg.deleted) return json(404, { message: 'Unknown Message', code: 10008 });
- if (req.method === 'PATCH') { msg.body = body; return json(200, { id: msg.id, channel_id: msg.channel_id, webhook_id: msg.webhook_id, content: body && body.content }); }
- if (req.method === 'DELETE') { msg.deleted = true; res.writeHead(204); return res.end(); }
- return json(405, { message: '405: Method Not Allowed', code: 0 });
- });
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
- url: `http://127.0.0.1:${server.address().port}`, hits, plan, messages,
- close: () => new Promise((r) => server.close(r)),
- })));
-}
-
-function createGateway() {
- const gw = { sockets: [], onOpen: null, onSend: null };
- gw.WebSocket = class FakeWebSocket extends EventTarget {
- constructor(url) {
- super();
- this.url = url;
- this.readyState = 0;
- this.sent = [];
- this.closedWith = null;
- gw.sockets.push(this);
- setImmediate(() => {
- if (this.readyState !== 0) return;
- this.readyState = 1;
- this.dispatchEvent(new Event('open'));
- if (gw.onOpen) gw.onOpen(this);
- });
- }
- send(data) {
- const p = JSON.parse(data);
- this.sent.push(p);
- if (gw.onSend) gw.onSend(this, p);
- }
- close(code = 1000, reason = '') {
- if (this.readyState === 3) return;
- this.readyState = 3;
- this.closedWith = code;
- setImmediate(() => this.dispatchEvent(Object.assign(new Event('close'), { code, reason })));
- }
- };
- gw.push = (sock, payload) => sock.dispatchEvent(Object.assign(new Event('message'), { data: JSON.stringify(payload) }));
- gw.drop = (sock, code, reason = '') => { sock.readyState = 3; sock.dispatchEvent(Object.assign(new Event('close'), { code, reason })); };
- gw.last = () => gw.sockets[gw.sockets.length - 1];
- return gw;
-}
-
-module.exports = { startWebhooks, createGateway };
deleted file mode 100644
deleted file mode 100644
@@ -1,74 +0,0 @@
@@ -1,74 +0,0 @@
-'use strict';
-/**
- * A stand-in for OpenVibe.Events in the relay tests: the publish route Community's outbox uses and
- * the pull route the relay's Events worker reads.
- *
- * POST /api/v1/events one envelope or { events: [...] } → { event_id, seq, duplicate } | { results }
- * GET /api/v1/events?topic=a.*,b.*&after_seq=&limit= → { events: [{ seq, event }], next_after_seq, latest_seq, gap? }
- *
- * Topic patterns follow Events (`*` is one or more whole segments). Every call needs a Bearer token.
- * `failNext` answers that many calls with 503; `prunedThrough` makes seq ≤ it look pruned (a gap).
- */
-const http = require('http');
-
-function matcher(pattern) {
- return new RegExp(`^${pattern.split('.').map((s) => (s === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : s)).join('\\.')}$`);
-}
-
-function startEvents() {
- const log = []; // { seq, event }
- const calls = [];
- const state = { failNext: 0, prunedThrough: 0 };
- const server = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- const u = new URL(req.url, 'http://events.test');
- calls.push({ method: req.method, path: u.pathname + u.search, auth: req.headers.authorization || null });
- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
- if (!/^Bearer \S+/.test(req.headers.authorization || '')) return json(401, { type: 'about:blank', code: 'auth.required', status: 401 });
- if (state.failNext > 0) { state.failNext--; return json(503, { type: 'about:blank', code: 'events.unavailable', status: 503, detail: 'down for a test' }); }
- if (u.pathname === '/api/v1/events' && req.method === 'POST') {
- const body = JSON.parse(raw || '{}');
- const batch = Array.isArray(body.events);
- const results = (batch ? body.events : [body]).map((env) => {
- const known = log.find((x) => x.event.event_id === env.event_id);
- if (known) return { event_id: env.event_id, seq: known.seq, duplicate: true };
- const seq = log.length + 1;
- log.push({ seq, event: env });
- return { event_id: env.event_id, seq, duplicate: false };
- });
- return json(201, batch ? { results } : results[0]);
- }
- if (u.pathname === '/api/v1/events' && req.method === 'GET') {
- const patterns = String(u.searchParams.get('topic') || '*').split(',').map(matcher);
- const after = Number(u.searchParams.get('after_seq') || 0);
- const limit = Number(u.searchParams.get('limit') || 100);
- const out = {};
- let from = after;
- if (state.prunedThrough && after < state.prunedThrough) { out.gap = { from_seq: after + 1, to_seq: state.prunedThrough }; from = state.prunedThrough; }
- const rows = [];
- let cursor = from;
- for (const item of log) {
- if (item.seq <= from) continue;
- cursor = item.seq;
- if (patterns.some((re) => re.test(item.event.event_type))) rows.push(item);
- if (rows.length >= limit) break;
- }
- out.events = rows;
- out.next_after_seq = cursor;
- out.latest_seq = log.length;
- return json(200, out);
- }
- return json(404, { type: 'about:blank', code: 'route.not_found', status: 404 });
- });
- });
- /** Put an event straight into the log (as if some producer had published it). → seq */
- function add(event) { log.push({ seq: log.length + 1, event }); return log.length; }
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
- url: `http://127.0.0.1:${server.address().port}`, log, calls, state, add,
- close: () => new Promise((r) => server.close(r)),
- })));
-}
-
-module.exports = { startEvents };
deleted file mode 100644
deleted file mode 100644
@@ -1,59 +0,0 @@
@@ -1,59 +0,0 @@
-'use strict';
-/**
- * A stand-in for OpenVibe.Media: the Object API v2 upload (POST /api/v2/space/objects →
- * PUT /:id/content → POST /:id/complete), with a Network service token for openvibe.media holding
- * media.object.upload. Keeps each finished upload's raw bytes (and its kind, visibility and owner)
- * so tests can check what left Space.
- */
-const http = require('http');
-const crypto = require('crypto');
-const { serviceAuth, ids } = require('openvibe-contracts');
-
-function start({ publicPem, issuer }) {
- const uploads = [];
- const pending = new Map(); // objects between init and complete
- let failNext = false;
- const server = http.createServer((req, res) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
- if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });
- const v2 = req.url.match(/^\/api\/v2\/space\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete))?$/);
- if (!v2) return json(404, { error: 'not found' });
- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
- if (!v.ok) return json(401, { code: v.code, error: v.reason });
- if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });
- const body = Buffer.concat(chunks);
- if (v2) {
- if (req.method === 'POST' && !v2[1]) {
- if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
- const b = JSON.parse(body.toString() || '{}');
- const id = ids.newId('media');
- pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });
- return json(201, { id, object: { id } });
- }
- const o = pending.get(v2[1]);
- if (!o) return json(404, { code: 'media.object.not_found' });
- if (req.method === 'PUT' && v2[2] === 'content') { o.bytes = body; o.mime = String(req.headers['content-type'] || ''); return json(200, { id: v2[1], size_bytes: body.length }); }
- if (req.method === 'POST' && v2[2] === 'complete') {
- const hash = crypto.createHash('sha256').update(o.bytes || Buffer.alloc(0)).digest('hex');
- if (hash !== o.content_hash) return json(400, { code: 'media.object.hash_mismatch' });
- pending.delete(v2[1]);
- uploads.push({ key: v2[1], name: o.filename || 'upload.bin', mime: o.mime, bytes: o.bytes, claims: v.claims, kind: o.kind, visibility: o.visibility, owner: o.owner, v2: true });
- return json(200, { id: v2[1], lifecycle_status: 'ready' });
- }
- return json(404, { error: 'not found' });
- }
- return json(404, { error: 'not found' });
- });
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
- url: `http://127.0.0.1:${server.address().port}`,
- uploads,
- failNext: () => { failNext = true; },
- close: () => new Promise((r) => server.close(r)),
- })));
-}
-
-module.exports = { start };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * What Space says about itself while it is rebuilt: the home page is honest (code and dynamic pages,
+ * and "spaces" apps; nothing published yet) and links the forum on OpenVibe.Community; the discovery
+ * files describe the same site and the sitemap holds the home page alone; health and readiness answer.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+
+ await check('the home page says what Space will host and links the forum on OpenVibe.Community', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<h1>Code and dynamic pages for the OpenVibe network<\/h1>/);
+ assert.match(r.text, /<strong>code<\/strong> and <strong>dynamic pages<\/strong>/);
+ assert.match(r.text, /<strong>spaces<\/strong>/);
+ assert.match(r.text, /The forum moved to OpenVibe\.Community/);
+ assert.match(r.text, /href="https:\/\/openvibe\.community\/s"/, 'the forum link points at Community');
+ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);
+ assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');
+ });
+
+ await check('robots.txt points at the sitemap and keeps the private paths out', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Sitemap: https:\/\/openvibe\.space\/sitemap\.xml/);
+ assert.match(r.text, /Disallow: \/api\//);
+ assert.match(r.text, /Disallow: \/auth\//);
+ });
+
+ await check('llms.txt describes Space and sends the reader to the forum', async () => {
+ const r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /OpenVibe\.Space/);
+ assert.match(r.text, /https:\/\/openvibe\.community\/s/);
+ assert.doesNotMatch(r.text, /\/s\/feed\.xml/, 'no forum feed is advertised');
+ });
+
+ await check('the sitemap holds the home page alone', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.deepStrictEqual(locs, ['https://openvibe.space/']);
+ });
+
+ await check('health and readiness answer, and readiness names the store', async () => {
+ const health = await t.get('/api/health');
+ assert.strictEqual(health.status, 200);
+ assert.strictEqual(health.json().service, 'openvibe-space');
+ const ready = await t.get('/api/ready');
+ assert.ok(ready.status >= 200 && ready.status < 300, `ready answered ${ready.status}`);
+ const body = ready.json();
+ assert.strictEqual(body.service, 'space');
+ assert.strictEqual(body.checks.db.status, 'ok');
+ // PGlite locally, PostgreSQL in CI (the containers): either is a real store.
+ assert.ok(['pglite', 'postgresql'].includes(body.checks.db.detail.store), `store ${body.checks.db.detail.store}`);
+ });
+
+ await check('sign-in is mounted: /auth/me answers a guest, /auth/login sends the browser to the Network', async () => {
+ const me = await t.get('/auth/me');
+ assert.deepStrictEqual([me.status, me.json().user], [200, null]);
+ const login = await t.get('/auth/login?next=/');
+ assert.strictEqual(login.status, 302);
+ assert.match(login.headers.get('location'), /^https?:\/\/127\.0\.0\.1:\d+\/oauth\/authorize/);
+ });
+
+ await check('there is no forum here: an unknown path is an honest 404 page', async () => {
+ const r = await t.get('/nope');
+ assert.strictEqual(r.status, 404);
+ assert.match(r.text, /Page not found/);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exitCode = 1; });
deleted file mode 100644
deleted file mode 100644
@@ -1,82 +0,0 @@
@@ -1,82 +0,0 @@
-'use strict';
-/**
- * The forum's Markdown renderer: the supported subset renders, and nothing a post contains can
- * become markup of its own — raw HTML, dangerous link schemes, attribute breakouts, entity tricks.
- */
-const assert = require('assert');
-const { renderMarkdown: md, markdownToText } = require('../server/render/markdown');
-const { check, done } = require('./helpers/app');
-
-(async () => {
- await check('the supported subset renders', () => {
- assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x < y</code></p>');
- assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');
- assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');
- assert.strictEqual(md('> quoted\n> more'), '<blockquote><p>quoted<br>\nmore</p></blockquote>');
- assert.strictEqual(md('- a\n- b\n continued'), '<ul><li>a</li><li>b<br>\ncontinued</li></ul>');
- assert.strictEqual(md('3. c\n4. d'), '<ol start="3"><li>c</li><li>d</li></ol>');
- assert.strictEqual(md('---'), '<hr>');
- assert.strictEqual(md('```\n<b>raw</b>\n```'), '<pre class="md-code"><code class="hljs language-plaintext"><b>raw</b></code></pre>');
- assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));
- assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');
- assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');
- assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
- assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');
- assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
- });
-
- await check('raw HTML is text, never markup', () => {
- for (const evil of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '<a href="javascript:alert(1)">x</a>', '<iframe src="//evil">', '<svg onload=alert(1)>', '<!-- x -->']) {
- const out = md(evil);
- assert.ok(!/<(script|img|iframe|svg|a )|<!--/i.test(out.replace(/<a href="[^"]*" rel="nofollow ugc noopener">/g, '')), `${evil} → ${out}`);
- assert.ok(out.includes('<'), 'escaped');
- }
- });
-
- await check('links: only http(s), mailto, same-site paths and fragments; no attribute breakouts', () => {
- for (const bad of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', '//evil.example/x', '/\\evil.example', 'javascript:alert(1)']) {
- const out = md(`[click](${bad})`);
- assert.ok(!out.includes('<a '), `${bad} must not become a link: ${out}`);
- }
- const quote = md('[x](https://a.b/"onmouseover="alert(1))');
- assert.ok(!/\sonmouseover\s*=/i.test(quote) && !/href="[^"]*"[^ >]/.test(quote), `quotes stay inside the attribute: ${quote}`);
- const bare = md('https://a.b/"><script>alert(1)</script>');
- assert.ok(!bare.includes('<script>'), bare);
- assert.ok(!/href="[^"]*"[^ >]/.test(bare), bare);
- const nul = md('a\u0000b');
- assert.strictEqual(nul, '<p>ab</p>');
- });
-
- await check('pathological posts render in linear time (no ReDoS)', () => {
- let codeRuns = '';
- for (let k = 1; codeRuns.length < 39000; k++) codeRuns += `a${'`'.repeat(k)}`;
- const cases = {
- heading: `# a${' '.repeat(6000)}x`,
- fence: `\`\`\`${' '.repeat(39000)}!`,
- strong: '**x '.repeat(10000),
- underscore: ' __x'.repeat(10000),
- strike: '~~x '.repeat(10000),
- codeRuns,
- };
- for (const [name, src] of Object.entries(cases)) {
- const started = Date.now();
- md(src);
- const ms = Date.now() - started;
- assert.ok(ms < 1000, `renderMarkdown ${name} took ${ms}ms`);
- }
- const started = Date.now();
- markdownToText('['.repeat(40000));
- assert.ok(Date.now() - started < 1000, `markdownToText took ${Date.now() - started}ms`);
- assert.strictEqual(md('# Title ## '), '<h3>Title</h3>');
- assert.strictEqual(md('``a`b`` and `c` ``d`'), '<p><code>a`b</code> and <code>c</code> `<code>d</code></p>');
- assert.strictEqual(md('**a** **b **c** ~~d~~ x__y__ __z__'), '<p><strong>a</strong> <strong>b **c</strong> <del>d</del> x__y__ <strong>z</strong></p>');
- });
-
- await check('markdownToText strips the syntax for descriptions and feeds', () => {
- assert.strictEqual(markdownToText('# Hi\n**b** [l](http://x) `c`\n\n- item'), 'Hi b l c item');
- assert.strictEqual(markdownToText('x'.repeat(50), 10), `${'x'.repeat(9)}…`);
- assert.strictEqual(markdownToText('<script>'), '<script>', 'plain text: the caller escapes it');
- });
-
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,253 +0,0 @@
@@ -1,253 +0,0 @@
-'use strict';
-/**
- * Members-only discussion (OpenVibe.VIP): a forum space or a single thread gated to one creator's
- * VIP members. Reading and posting need VIP's yes (POST /api/v1/policies/evaluate with the owner
- * Space stored, through the product cache); the owner and moderators pass; signed-out, strangers,
- * VIP down and no client secret are refused (fail closed); nothing gated reaches Pulse, the sitemap,
- * feeds, JSON-LD or the SSR page of someone without access. Convergence: once VIP stops granting,
- * Space stops within the cache TTL (30 s), and at once on vip.membership.changed.
- */
-const http = require('http');
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-
-/** A stand-in for VIP's evaluate: answers from `allow` (member subject → true), records calls. */
-function startVip() {
- const state = { allow: new Set(), down: false, calls: [] };
- const server = http.createServer((req, res) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- if (state.down) { req.socket.destroy(); return; }
- let body = {};
- try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }
- state.calls.push({ path: req.url, auth: req.headers.authorization || '', body });
- if (req.method !== 'POST' || req.url !== '/api/v1/policies/evaluate') { res.writeHead(404); return res.end('{}'); }
- const yes = state.allow.has(`${body.subject}|${body.owner}`);
- res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify(yes
- ? { allow: true, reason: 'member', rule: null, fallback: true, entitlement: { status: 'active', active: true, expires_at: '2100-01-01T00:00:00.000Z', valid_until: '2100-01-01T00:00:00.000Z' } }
- : { allow: false, reason: 'not_a_member', rule: null, fallback: true, entitlement: { status: 'inactive', active: false } }));
- });
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ ...state, state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
-}
-
-(async () => {
- const vip = await startVip();
- process.env.OV_VIP_INTERNAL_URL = vip.url;
- process.env.OV_VIP_URL = 'https://openvibe.vip';
- const clock = { t: Date.now() };
- const t = await boot({
-
- appOpts: {
- vipOptions: { now: () => clock.t },
- forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },
- },
- });
- const net = t.network;
- const mk = (id, username, role = 'user') => {
- const u = net.addUser({ network_user_id: id, username, display_name: username });
- return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };
- };
- const cora = mk(21, 'cora'); // the creator
- const mia = mk(22, 'mia'); // her member
- const stan = mk(23, 'stan'); // not a member
- const newbie = mk(24, 'newbie'); // a member VIP cannot confirm while down
- const conv = mk(25, 'conv'); // convergence
- const boss = mk(26, 'boss', 'global_mod');
- vip.state.allow.add(`${mia.subject_id}|${cora.subject_id}`);
- vip.state.allow.add(`${newbie.subject_id}|${cora.subject_id}`);
-
- const call = (path, { method = 'GET', who, json, headers = {} } = {}) => {
- const h = { ...headers };
- let body;
- if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
- return t.get(path, { method, headers: h, body, cookies: who ? [`ov_token=${who.jwt}`] : [] });
- };
- const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: who ? [`ov_token=${who.jwt}`] : [] });
- const evaluateCalls = () => vip.state.calls.filter((c) => c.path === '/api/v1/policies/evaluate').length;
- let secret; // a gated thread in a public space
- let showcaseThread; // a thread in the gated space
-
- await check('marking: moderators gate a space for a creator; others cannot; the owner must be a usr_ subject', async () => {
- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: cora, json: { owner: cora.subject_id } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: 'cora' } })).status, 400);
- const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: cora.subject_id } });
- assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(r.json().space.members_only, { owner: cora.subject_id, owner_username: 'cora', join_url: 'https://openvibe.vip/cora' });
- const list = (await call('/api/v1/spaces', { who: stan })).json().spaces;
- assert.strictEqual(list.find((s) => s.slug === 'showcase').members_only.join_url, 'https://openvibe.vip/cora', 'the space is listed with its join link');
- assert.strictEqual(list.find((s) => s.slug === 'general').members_only, null);
- });
-
- await check('a gated space: the member, the owner and moderators read and post; VIP is asked with the owner and the default gate', async () => {
- const before = vip.state.calls.length;
- const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: mia, json: { title: 'Members lounge', body: 'members-only body text' } });
- assert.strictEqual(r.status, 201, r.text);
- showcaseThread = r.json().thread;
- const asked = vip.state.calls.slice(before).find((c) => c.path === '/api/v1/policies/evaluate');
- assert.deepStrictEqual(asked.body.resource, { service: 'space', type: 'space', id: 'showcase' });
- assert.strictEqual(asked.body.owner, cora.subject_id);
- assert.strictEqual(asked.body.subject, mia.subject_id);
- assert.deepStrictEqual(asked.body.fallback, { requirement: 'member', binding: 'space:members_only' });
- assert.ok(/^Bearer /.test(asked.auth), 'a service token for openvibe.vip');
- assert.ok(net.grants.some((g) => g.audience === 'openvibe.vip' && g.scope === 'vip.resource.policy.evaluate'));
- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: mia })).status, 200);
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: mia })).json().posts[0].body_markdown, 'members-only body text');
- const n = evaluateCalls();
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: cora })).status, 200, 'the owner');
- assert.strictEqual(evaluateCalls(), n, 'the owner passes without asking VIP');
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: boss, json: { body: 'mod note' } })).status, 201, 'moderators');
- });
-
- await check('a gated space refuses strangers and signed-out visitors with 403 vip.members_only and the join link', async () => {
- for (const [who, reason] of [[stan, 'not_a_member'], [null, 'not_signed_in']]) {
- const r = await call('/api/v1/spaces/showcase/threads', { who });
- assert.strictEqual(r.status, 403, r.text);
- const p = r.json();
- assert.strictEqual(p.code, 'vip.members_only');
- assert.strictEqual(p.reason, reason);
- assert.strictEqual(p.members_only.join_url, 'https://openvibe.vip/cora');
- assert.ok(!r.text.includes('members-only body text'));
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who })).status, 403);
- }
- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: stan, json: { title: 'Let me in', body: 'x' } })).status, 403);
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/votes`, { method: 'POST', who: stan, json: { value: 1 } })).status, 403);
- });
-
- await check('VIP down: a member VIP cannot confirm is refused (fail closed)', async () => {
- vip.state.down = true;
- try {
- const r = await call('/api/v1/spaces/showcase/threads', { who: newbie });
- assert.strictEqual(r.status, 403);
- assert.strictEqual(r.json().reason, 'vip_unavailable');
- } finally { vip.state.down = false; }
- clock.t += 2_001; // failures are cached 2 s
- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: newbie })).status, 200);
- });
-
- await check('no client secret: nobody but the owner gets in, and VIP is never called', async () => {
- const { createVipGate } = require('../server/vip');
- const config = require('../server/config');
- const gate = createVipGate({ config: { ...config, oauth: { clientId: 'space', clientSecret: '' } } });
- const n = vip.state.calls.length;
- const d = await gate.check({ subject: mia.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id });
- assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
- assert.strictEqual((await gate.check({ subject: cora.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id })).allow, true);
- assert.strictEqual(vip.state.calls.length, n);
- });
-
- await check('a gated thread in a public space: the author gates it to their members; listed by title only', async () => {
- const r = await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Backstage with Cora', body: 'secret backstage body', members_only: true } });
- assert.strictEqual(r.status, 201, r.text);
- secret = r.json().thread;
- assert.strictEqual(secret.members_only.owner, cora.subject_id);
- const listed = (await call('/api/v1/spaces/general/threads?sort=new', { who: stan })).json();
- const row = listed.threads.find((x) => x.id === secret.id);
- assert.strictEqual(row.title, 'Backstage with Cora');
- assert.strictEqual(row.members_only.join_url, 'https://openvibe.vip/cora');
- assert.ok(!JSON.stringify(listed).includes('secret backstage body'));
- const refused = await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: stan });
- assert.strictEqual(refused.status, 403);
- assert.strictEqual(refused.json().gate, 'thread');
- assert.ok(!refused.text.includes('secret backstage body'));
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: mia })).json().posts[0].body_markdown, 'secret backstage body');
- const reply = await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: mia, json: { body: 'member reply' } });
- assert.strictEqual(reply.status, 201);
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);
- // Edits need access too: stan cannot edit (not his), and an author who lost access cannot either.
- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', who: stan, json: { body: 'y' } })).status, 403);
- });
-
- await check('thread gating permissions: only the author (own members) or moderators; others cannot claim someone else\'s members', async () => {
- const open = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Stan open thread', body: 'open body' } })).json().thread;
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: mia, json: { owner: mia.subject_id } })).status, 403, 'not the author');
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: cora.subject_id } })).status, 403, 'the author gates to their own members only');
- const own = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: true } });
- assert.strictEqual(own.status, 200, own.text);
- assert.strictEqual(own.json().thread.members_only.owner, stan.subject_id);
- const cleared = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } });
- assert.strictEqual(cleared.json().thread.members_only, null);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Claim', body: 'x', members_only: { owner: cora.subject_id } } })).status, 403);
- const mod = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: cora.subject_id } });
- assert.strictEqual(mod.json().thread.members_only.owner, cora.subject_id, 'moderators name any creator');
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } })).status, 403, 'the author cannot open what a moderator gated to another creator');
- await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: null } });
- });
-
- await check('nothing gated leaks: the sitemap, the feeds, a space feed, JSON-LD', async () => {
- const sitemap = (await call('/sitemap.xml')).text;
- assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
- const feed = (await call('/s/feed.xml')).text;
- assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));
- assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);
- const general = (await call('/s/general/feed.xml')).text;
- assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
- });
-
- await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
- const anon = await call(`/s/general/t/${secret.slug}`);
- assert.strictEqual(anon.status, 403);
- assert.ok(anon.text.includes('https://openvibe.vip/cora'));
- assert.ok(anon.text.includes('Backstage with Cora'));
- assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));
- assert.ok(/noindex/.test(anon.text));
- assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');
- const space = await call('/s/showcase', { who: stan });
- assert.strictEqual(space.status, 403);
- assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));
- assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
- const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });
- assert.strictEqual(reply.status, 403);
- const member = await call(`/s/general/t/${secret.slug}`, { who: mia });
- assert.strictEqual(member.status, 200);
- assert.ok(member.text.includes('secret backstage body'));
- assert.ok(/noindex/.test(member.text));
- assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');
- const listing = await call('/s/general', { who: stan });
- assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));
- assert.ok(!listing.text.includes('secret backstage body'));
- // The author's no-JS toggle.
- const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;
- const page = await call(`/s/general/t/${own.slug}`, { who: cora });
- assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));
- const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });
- assert.strictEqual(on.status, 303);
- assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);
- });
-
- await check('convergence: VIP stops granting → Space refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {
- vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);
- const path = `/api/v1/spaces/general/threads/${secret.slug}`;
- assert.strictEqual((await call(path, { who: conv })).status, 200);
- vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`); // VIP applied the change
- clock.t += 29_999;
- assert.strictEqual((await call(path, { who: conv })).status, 200, 'inside the bound the cached yes may still answer');
- clock.t += 2;
- assert.strictEqual((await call(path, { who: conv })).status, 403, 'past 30 s VIP is asked again and says no');
- // With the event: at once.
- vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);
- clock.t += 10_001; // the cached "no" expires
- assert.strictEqual((await call(path, { who: conv })).status, 200);
- vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`);
- const understood = t.app.locals.vip.cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: conv.subject_id }, creator: { type: 'user', id: cora.subject_id }, active: false } });
- assert.strictEqual(understood, true);
- assert.strictEqual((await call(path, { who: conv })).status, 403, 'no clock movement needed');
- assert.strictEqual(t.app.locals.vip.bounds.grantMs, 30_000);
- });
-
- await check('a moderator opens a space again; guest subjects and ids are never owners', async () => {
- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: ids.newId('guest') } })).status, 400);
- const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });
- assert.strictEqual(r.json().space.members_only, null);
- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);
- assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));
- });
-
- await t.close();
- await vip.close();
- done();
-})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * The forum is OpenVibe.Community's again (owner decision, 2026-10-08), so every forum URL here is a
+ * permanent redirect to the same path and query on openvibe.community: /s and /s/* answer 301 for
+ * GET and HEAD (the method is kept) and 308 for anything else, and the forum APIs always answer 308.
+ * Everything that is not a forum path stays on Space.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+const COMMUNITY = 'https://openvibe.community';
+
+(async () => {
+ const t = await boot();
+
+ await check('GET and HEAD of /s and its nested paths answer 301 to Community, path and query intact', async () => {
+ for (const path of ['/s', '/s?sort=new', '/s/general', '/s/general/t/123?x=1&y=two', '/s/feed.xml', '/s/general/feed.xml']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 301, path);
+ assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${path}`, path);
+ }
+ const head = await t.get('/s?sort=top', { method: 'HEAD' });
+ assert.strictEqual(head.status, 301);
+ assert.strictEqual(head.headers.get('location'), `${COMMUNITY}/s?sort=top`);
+ });
+
+ await check('other methods of /s and /s/* answer 308 (the method and body are preserved)', async () => {
+ const post = await t.get('/s/general/threads?q=1', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+ assert.strictEqual(post.status, 308);
+ assert.strictEqual(post.headers.get('location'), `${COMMUNITY}/s/general/threads?q=1`);
+ const del = await t.get('/s/general/t/123/posts/9', { method: 'DELETE' });
+ assert.strictEqual(del.status, 308);
+ assert.strictEqual(del.headers.get('location'), `${COMMUNITY}/s/general/t/123/posts/9`);
+ });
+
+ await check('the forum APIs answer 308 to Community, path and query intact', async () => {
+ for (const path of ['/api/v1/spaces', '/api/v1/spaces/general/threads?sort=top', '/api/v1/posts', '/api/v1/posts/42', '/api/v1/space-groups', '/api/v1/space-groups/2', '/api/v1/relay', '/api/v1/relay/status']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 308, path);
+ assert.strictEqual(r.headers.get('location'), `${COMMUNITY}${path}`, path);
+ }
+ const post = await t.get('/api/v1/spaces/general/threads', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(post.status, 308, 'a write is never replayed as a GET');
+ });
+
+ await check('only the forum paths redirect: the home page, the sitemap, /shared and the session stay here', async () => {
+ assert.strictEqual((await t.get('/')).status, 200);
+ assert.strictEqual((await t.get('/sitemap.xml')).status, 200);
+ assert.strictEqual((await t.get('/robots.txt')).status, 200);
+ assert.strictEqual((await t.get('/auth/me')).status, 200);
+ assert.strictEqual((await t.get('/api/health')).status, 200);
+ const shared = await t.get('/shared/navbar.js');
+ assert.notStrictEqual(shared.status, 301, '/shared is not a forum path');
+ assert.notStrictEqual(shared.status, 308);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exitCode = 1; });
deleted file mode 100644
deleted file mode 100644
@@ -1,207 +0,0 @@
@@ -1,207 +0,0 @@
-'use strict';
-/**
- * The Discord relay as an Events worker (relay/events-worker.js): creates come from Space's own
- * space.thread.created / space.post.created read back from OpenVibe.Events' pull API with a
- * stored cursor. First start begins at the head (history is never announced); new events queue
- * deliveries (source 'events', the event id) that are sent once; a replay queues nothing twice; the
- * cursor survives a restart; members-only, Discord-origin, redacted and foreign events are ignored;
- * Events being down or a retention gap shows in the status; while the worker runs the forum's own
- * calls queue no creates. Last, the whole path in the app: an API write → Space's outbox → a fake
- * Events → the worker → a fake Discord webhook.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { createClient } = require('openvibe-sdk/core');
-const { createEventsClient } = require('openvibe-sdk/events');
-const { boot, check, done } = require('./helpers/app');
-const { startWebhooks } = require('./helpers/fake-discord');
-const { startEvents } = require('./helpers/fake-events');
-const { testDb } = require('./helpers/db');
-const forumStore = require('../server/forum/store');
-const { createDiscordRelay } = require('../server/relay/discord');
-const { createRelayEventsWorker, CURSOR } = require('../server/relay/events-worker');
-
-let evn = 0;
-function envelope(type, payload, extra = {}) {
- evn++;
- return { event_id: `evt_TEST${String(evn).padStart(22, '0')}`, event_type: type, source: 'space', version: 1, timestamp: new Date().toISOString(), payload, ...extra };
-}
-const threadEvent = (t, visibility = 'public') => envelope('space.thread.created', { thread_id: t.id, space: 'general', visibility, author: null, url: null });
-const postEvent = (p, t, visibility = 'public') => envelope('space.post.created', { post_id: p.id, thread_id: t.id, space: 'general', visibility, author: null, url: null });
-
-(async () => {
- const hook = await startWebhooks();
- const events = await startEvents();
- const db = await testDb();
- const general = await forumStore.getSpace(db, 'general');
- const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` };
- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env, enabled: true, baseMs: 1000, maxAttempts: 3 });
- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
- const client = createEventsClient(createClient({ baseUrls: { events: events.url }, getToken: async () => 'test-token', retries: 0 }), { source: 'space' });
- const worker = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000, pageLimit: 2 });
- relay.attach({ worker });
- const alex = ids.newId('user');
- const newThread = async (title, extra = {}) => (await forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra })).thread;
- const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
- const settle = () => new Promise((r) => setImmediate(r));
-
- await check('off without Events or the client secret, or with DISCORD_RELAY_EVENTS=off; the forum queues creates then', async () => {
- const a = createRelayEventsWorker({ db, relay, eventsUrl: null, clientSecret: 'x', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
- assert.deepStrictEqual([a.enabled, (await a.status()).reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);
- const b = createRelayEventsWorker({ db, relay, eventsUrl: events.url, clientSecret: '', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
- assert.strictEqual(b.enabled, false);
- const c = createRelayEventsWorker({ db, relay, events: client, enabled: false });
- assert.deepStrictEqual([c.enabled, (await c.status()).reason], [false, 'DISCORD_RELAY_EVENTS=off']);
- const d = createRelayEventsWorker({ db, relay: createDiscordRelay({ db, env }), events: client });
- assert.match((await d.status()).reason, /relay is off/);
- c.start();
- assert.strictEqual((await relay.status()).creates_from, 'forum');
- assert.deepStrictEqual(await c.tick(), { queued: 0 });
- });
-
- let before;
- await check('first start begins at the head: threads from before are never announced', async () => {
- const old1 = await newThread('Older one');
- const old2 = await newThread('Older two');
- events.add(threadEvent(old1));
- events.add(threadEvent(old2));
- events.add(envelope('live.stream.started', { paste_id: 'x' }));
- relay.start(); // starts the worker too (its interval is an hour here: ticks are driven by the test)
- await worker.tick();
- assert.strictEqual((await relay.status()).creates_from, 'events');
- const st = await worker.status();
- assert.deepStrictEqual([st.enabled, st.cursor, st.latest_seq, st.started_at_seq, st.lag], [true, 3, 3, 3, 0]);
- assert.strictEqual((await deliveries()).length, 0);
- assert.deepStrictEqual(await db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);
- before = events.calls.length;
- });
-
- let t1;
- let p1;
- await check('new events queue creates (source events, the event id) that are sent once; the forum\'s own calls queue none', async () => {
- t1 = await newThread('From the stream');
- assert.strictEqual(await relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');
- p1 = await forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });
- assert.strictEqual(await relay.enqueuePost(p1, t1, general), 0);
- const e1 = threadEvent(t1);
- const e2 = postEvent(p1, t1);
- events.add(e1);
- events.add(envelope('space.comment.created', { comment_id: 1 })); // not on the relay's topics: never returned
- events.add(e2);
- assert.deepStrictEqual(await worker.tick(), { queued: 2 });
- const rows = await deliveries();
- assert.deepStrictEqual(rows.map((d) => [d.action, d.source, d.event_id, d.post_id]), [['create', 'events', e1.event_id, null], ['create', 'events', e2.event_id, p1.id]]);
- assert.ok(events.calls.slice(before).every((c) => /topic=space\.thread\.\*%2Cspace\.post\.\*/.test(c.path) && c.auth === 'Bearer test-token'), JSON.stringify(events.calls.slice(before)));
- await settle(); await relay.drain(); await relay.drain();
- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
- assert.match(hook.hits[1].body.content, /replied to \*\*From the stream\*\*/);
- assert.deepStrictEqual([(await worker.status()).cursor, (await worker.status()).lag], [6, 0]);
- });
-
- await check('a replay (cursor moved back) queues nothing twice and posts nothing twice', async () => {
- await db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);
- assert.deepStrictEqual(await worker.tick(), { queued: 0 });
- await relay.drain();
- assert.strictEqual(hook.hits.length, 2);
- assert.strictEqual((await worker.status()).cursor, 6);
- });
-
- await check('the cursor survives a restart: a new worker carries on from it (no second "start at head")', async () => {
- const t2 = await newThread('While restarting');
- events.add(threadEvent(t2));
- const again = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000 });
- assert.deepStrictEqual(await again.tick(), { queued: 1 });
- assert.strictEqual((await again.status()).started_at_seq, null);
- assert.strictEqual((await deliveries()).pop().thread_id, t2.id);
- });
-
- await check('members-only, Discord-origin, redacted and foreign events queue nothing', async () => {
- const n = (await deliveries()).length;
- const gated = await newThread('Gated', { members_only_owner: alex });
- const fromDiscord = await forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });
- const t3 = await newThread('Looks public');
- events.add(threadEvent(gated, 'members'));
- events.add(threadEvent(gated)); // even a (wrongly) public-looking event: the row decides
- events.add(postEvent(fromDiscord, t1));
- events.add({ ...threadEvent(t3), source: 'live' });
- events.add({ ...threadEvent(t3), payload: { redacted: true } });
- assert.deepStrictEqual(await worker.tick(), { queued: 0 });
- assert.strictEqual((await deliveries()).length, n);
- });
-
- await check('Events down: the error shows, the cursor stays, and it catches up once Events answers', async () => {
- const t4 = await newThread('After the outage');
- events.add(threadEvent(t4));
- const cursor = (await worker.status()).cursor;
- events.state.failNext = 1;
- assert.deepStrictEqual(await worker.tick(), { queued: 0 });
- const st = await worker.status();
- assert.ok(st.last_error, 'the error is shown');
- assert.strictEqual(st.cursor, cursor);
- assert.strictEqual(st.lag, st.latest_seq - cursor);
- assert.deepStrictEqual(await worker.tick(), { queued: 1 });
- assert.strictEqual((await worker.status()).last_error, null);
- });
-
- await check('a retention gap is logged and shown to staff; reading carries on after it', async () => {
- await db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);
- events.state.prunedThrough = 5;
- await worker.tick();
- assert.deepStrictEqual([(await worker.status()).last_gap.from_seq, (await worker.status()).last_gap.to_seq], [2, 5]);
- assert.strictEqual((await worker.status()).cursor, events.log.length);
- events.state.prunedThrough = 0;
- assert.strictEqual((await relay.status()).events_worker.last_gap.to_seq, 5);
- });
-
- await check('the whole path in the app: API write → outbox → Events → worker → Discord, and back out on edit and delete', async () => {
- const hook2 = await startWebhooks();
- const events2 = await startEvents();
- process.env.EVENTS_URL = events2.url;
- process.env.DISCORD_RELAY_EVENTS_POLL_MS = '40';
- try {
- const t = await boot({
-
- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook2.url}/api/webhooks/7/app` } }, forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } },
- });
- const outbox = require('../server/events').init(t.db, { eventsUrl: events2.url, intervalMs: 40 });
- assert.ok(outbox, 'the outbox publishes to the fake Events');
- const net = t.network;
- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
- const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (await fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };
- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 201);
- await waitFor(async () => (await t.app.locals.relay.status()).events_worker.cursor != null, 'the worker\'s first read');
- const st = (await call('/api/v1/relay/status', { cookie: adminJwt })).json();
- assert.deepStrictEqual([st.creates_from, st.events_worker.enabled], ['events', true]);
- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Round trip', body: 'through Events' } });
- assert.strictEqual(th.status, 201, th.text);
- await waitFor(() => hook2.hits.length >= 1, 'the thread on Discord');
- assert.strictEqual(hook2.hits[0].path, '/api/webhooks/7/app?wait=true');
- assert.ok(events2.log.some((x) => x.event.event_type === 'space.thread.created'), 'it went through Events');
- const d = await t.db.prepare('SELECT * FROM relay_deliveries').get();
- assert.deepStrictEqual([d.source, /^evt_/.test(d.event_id)], ['events', true]);
- const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'and a reply' } });
- await waitFor(() => hook2.hits.length >= 2, 'the reply on Discord');
- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'and a reply, edited' } })).status, 200);
- await waitFor(() => hook2.hits.length >= 3, 'the edit on Discord');
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}`, { method: 'DELETE', cookie: samJwt })).status, 200);
- await waitFor(() => hook2.hits.length >= 5, 'the deletes on Discord');
- assert.deepStrictEqual(hook2.hits.map((h) => h.method), ['POST', 'POST', 'PATCH', 'DELETE', 'DELETE']);
- await new Promise((r) => setTimeout(r, 200));
- assert.strictEqual(hook2.hits.length, 5, 'nothing was sent twice');
- await t.app.locals.relay.stop();
- await require('../server/events').stop();
- await t.close();
- } finally {
- delete process.env.EVENTS_URL; delete process.env.DISCORD_RELAY_EVENTS_POLL_MS;
- await hook2.close(); await events2.close();
- }
- });
-
- await relay.stop();
- await hook.close();
- await events.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,346 +0,0 @@
@@ -1,346 +0,0 @@
-'use strict';
-/**
- * Discord relay, inbound (relay/discord-gateway.js + relay/inbound.js), against a fake gateway:
- * HELLO → IDENTIFY (token, intents GUILD_MESSAGES | MESSAGE_CONTENT), heartbeats with the last seq,
- * RECONNECT and zombied connections resume, INVALID_SESSION identifies again, fatal close codes stop
- * it with a reason, a missing HELLO reconnects, stop() closes. Then what becomes a post: replies to
- * relayed messages (or messages in a Discord thread started from one) in mapped channels only, with
- * origin 'discord', no author subject and the Discord name; mentions defused; size and rate limits;
- * edits and deletes through the map; webhook/bot/own/system messages, unmapped channels and
- * non-replies ignored; closed threads and empty messages recorded as failures; nothing from Discord
- * ever relayed back out. Last, the app: status and failures at /api/v1/relay (staff, no token), and
- * the post on the thread page with its Discord badge.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const { startWebhooks, createGateway } = require('./helpers/fake-discord');
-const { testDb } = require('./helpers/db');
-const forumStore = require('../server/forum/store');
-const { createDiscordRelay } = require('../server/relay/discord');
-const { createDiscordGateway, DEFAULT_URL, INTENTS } = require('../server/relay/discord-gateway');
-const { createDiscordInbound, toMarkdown } = require('../server/relay/inbound');
-const { createForumService } = require('../server/forum/service');
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const BOT = '4000000000000000001';
-const KIM = '4000000000000000002';
-const LEE = '4000000000000000003';
-let snow = 5000000000000000000n;
-const nextId = () => String(snow++);
-
-/** A scripted gateway: HELLO on open, READY on IDENTIFY, RESUMED on RESUME, ACK on heartbeat (unless paused). */
-function scriptedGateway() {
- const gw = createGateway();
- gw.ack = true;
- gw.seq = 0;
- gw.onOpen = (sock) => gw.push(sock, { op: 10, d: { heartbeat_interval: 30 } });
- gw.onSend = (sock, p) => {
- if (p.op === 2) gw.push(sock, { op: 0, t: 'READY', s: ++gw.seq, d: { session_id: `sess-${gw.sockets.length}`, resume_gateway_url: 'wss://resume.discord.test', user: { id: BOT, bot: true } } });
- if (p.op === 6) gw.push(sock, { op: 0, t: 'RESUMED', s: ++gw.seq, d: {} });
- if (p.op === 1 && gw.ack) gw.push(sock, { op: 11 });
- };
- gw.dispatch = (t, d) => gw.push(gw.last(), { op: 0, t, s: ++gw.seq, d });
- return gw;
-}
-const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };
-const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op === op);
-
-(async () => {
- // ── the gateway client ───────────────────────────────────
- const opts = { token: 'test-bot-token', minBackoffMs: 5, maxBackoffMs: 40, helloTimeoutMs: 150, invalidSessionMs: 5, jitter: () => 0 };
-
- await check('off without a token or a WebSocket client, and says why', async () => {
- const a = createDiscordGateway({ token: '' });
- assert.deepStrictEqual([a.enabled, a.status()], [false, { enabled: false, reason: 'DISCORD_BOT_TOKEN is not set' }]);
- const b = createDiscordGateway({ token: 'x', WebSocketImpl: null });
- assert.match(b.status().reason, /no built-in WebSocket/);
- b.start();
- assert.strictEqual(typeof globalThis.WebSocket, 'function', 'Node 22 has the client the gateway uses');
- });
-
- await check('HELLO → IDENTIFY with the token and intents; heartbeats carry the last seq and are ACKed; READY is ready', async () => {
- const gw = scriptedGateway();
- const seen = [];
- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket, onDispatch: (t, d) => { seen.push([t, d.id]); } });
- g.start();
- await waitFor(() => g.status().state === 'ready', 'READY');
- assert.strictEqual(gw.sockets[0].url, DEFAULT_URL);
- const [identify] = sent(gw, 2);
- assert.deepStrictEqual([identify.d.token, identify.d.intents], ['test-bot-token', INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT]);
- assert.strictEqual(INTENTS.GUILD_MESSAGES | INTENTS.MESSAGE_CONTENT, 33280);
- await waitFor(() => sent(gw, 1).length >= 2, 'two heartbeats');
- assert.strictEqual(sent(gw, 1).pop().d, 1, 'the last seq');
- gw.dispatch('MESSAGE_CREATE', { id: '1' });
- gw.dispatch('GUILD_CREATE', { id: '2' }); // not a message event: dropped here
- gw.dispatch('MESSAGE_DELETE', { id: '3' });
- await new Promise((r) => setImmediate(r)); // handling is async: events queue in order
- assert.deepStrictEqual(seen, [['MESSAGE_CREATE', '1'], ['MESSAGE_DELETE', '3']]);
- const st = g.status();
- assert.deepStrictEqual([st.state, st.session, st.bot_user_id, st.events_handled], ['ready', true, BOT, 2]);
- assert.ok(!JSON.stringify(st).includes('test-bot-token'), 'the token never shows');
- await g.stop();
- assert.strictEqual(gw.sockets[0].closedWith, 1000);
- assert.strictEqual(g.status().state, 'stopped');
- await sleep(60);
- assert.strictEqual(gw.sockets.length, 1, 'no reconnect after stop()');
- });
-
- await check('RECONNECT, a zombied connection and an abnormal close all RESUME on the resume URL with the session and seq', async () => {
- const gw = scriptedGateway();
- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });
- g.start();
- await waitFor(() => g.status().state === 'ready', 'READY');
- gw.dispatch('MESSAGE_CREATE', { id: '10' });
- gw.push(gw.sockets[0], { op: 7, d: null });
- await waitFor(() => gw.sockets.length === 2 && sent(gw, 6).length === 1, 'a resume');
- assert.strictEqual(gw.sockets[0].closedWith, 4000, 'closed with a non-1000 code, so the session stays resumable');
- assert.strictEqual(gw.sockets[1].url, 'wss://resume.discord.test/?v=10&encoding=json');
- assert.deepStrictEqual(sent(gw, 6)[0].d, { token: 'test-bot-token', session_id: 'sess-1', seq: 2 });
- await waitFor(() => g.status().state === 'ready', 'RESUMED');
- // No ACK: the next heartbeat finds the last one unanswered and reconnects.
- gw.ack = false;
- await waitFor(() => gw.sockets.length === 3, 'a reconnect after a missing ACK', 1000);
- gw.ack = true;
- await waitFor(() => sent(gw, 6).length === 2, 'a second resume');
- assert.match(g.status().last_reconnect.reason, /no heartbeat ACK \(zombied connection\)/);
- // An abnormal close (1006) resumes too.
- await waitFor(() => g.status().state === 'ready', 'ready again');
- gw.drop(gw.last(), 1006);
- await waitFor(() => sent(gw, 6).length === 3, 'a third resume');
- assert.strictEqual(sent(gw, 2).length, 1, 'identified only once');
- await g.stop();
- });
-
- await check('INVALID_SESSION (not resumable) and close codes 4007/4009 identify afresh; no HELLO reconnects', async () => {
- const gw = scriptedGateway();
- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });
- g.start();
- await waitFor(() => g.status().state === 'ready', 'READY');
- gw.push(gw.last(), { op: 9, d: false });
- await waitFor(() => sent(gw, 2).length === 2, 'a second IDENTIFY');
- assert.strictEqual(gw.last().url, DEFAULT_URL, 'a fresh session starts on the main URL');
- await waitFor(() => g.status().state === 'ready', 'READY again');
- gw.drop(gw.last(), 4009);
- await waitFor(() => sent(gw, 2).length === 3, 'IDENTIFY after 4009');
- await waitFor(() => g.status().state === 'ready', 'READY after 4009');
- // A gateway that never says HELLO.
- gw.onOpen = null;
- gw.drop(gw.last(), 1001);
- await waitFor(() => gw.sockets.length >= 5, 'a reconnect after the HELLO timeout', 1500);
- assert.match(g.status().last_error, /no HELLO/);
- await g.stop();
- });
-
- await check('fatal close codes stop it with the reason (4004 a wrong token, 4014 the MESSAGE CONTENT intent)', async () => {
- for (const [code, re] of [[4004, /DISCORD_BOT_TOKEN is wrong/], [4014, /MESSAGE CONTENT intent/]]) {
- const gw = scriptedGateway();
- const g = createDiscordGateway({ ...opts, WebSocketImpl: gw.WebSocket });
- g.start();
- await waitFor(() => g.status().state === 'ready', 'READY');
- gw.drop(gw.last(), code);
- await sleep(80);
- assert.strictEqual(gw.sockets.length, 1, `no reconnect after ${code}`);
- assert.strictEqual(g.status().state, 'failed');
- assert.match(g.status().last_error, re);
- await g.stop();
- }
- });
-
- // ── inbound: what becomes a post ────────────────────────
- const hook = await startWebhooks();
- const db = await testDb();
- const general = await forumStore.getSpace(db, 'general');
- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` }, enabled: true });
- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });
- const CHANNEL = '2000000000000000001'; // the fake webhook 1's channel (learned on the first send)
- const inbound = createDiscordInbound({ db, perMinute: 3, maxChars: 200 });
- const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
- const sam = ids.newId('user');
- const samV = { kind: 'user', subject: sam, staff: false };
- const settle = async () => { await new Promise((r) => setImmediate(r)); await relay.drain(); await relay.drain(); };
- const { thread } = await forum.createThread(samV, 'general', { title: 'Talk to Discord', body: 'Say hi over there' });
- await settle();
- const threadMsg = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id)).external_message_id;
- const failures = async () => await db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();
- const msg = (extra = {}) => ({ id: nextId(), channel_id: CHANNEL, guild_id: '6000000000000000001', type: 19, content: 'hello from Discord', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: threadMsg, channel_id: CHANNEL }, mentions: [], attachments: [], ...extra });
- const postsOf = async (tid) => await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);
-
- await check('a Discord reply to a relayed message becomes a post: origin discord, nobody on the site, the Discord name, mapped', async () => {
- const m = msg({ content: 'Hi <@4000000000000000003> and <@&4000000000000000009> in <#4000000000000000010> @everyone <:wave:4000000000000000011> <t:1790000000:R>', mentions: [{ id: LEE, username: 'lee', global_name: 'Lee' }], member: { nick: 'Kim (mod)' } });
- const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
- assert.strictEqual(r.status, 'applied', JSON.stringify(r));
- const p = await forumStore.getPost(db, r.post_id);
- assert.deepStrictEqual([p.thread_id, p.origin, p.author_subject, p.relay_author], [thread.id, 'discord', null, 'Kim (mod)']);
- assert.strictEqual(p.body_markdown, 'Hi @Lee and @role in #channel @\u200beveryone :wave: 2026-09-21 14:13 UTC');
- const row = await db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);
- assert.deepStrictEqual([row.direction, row.local_type, row.local_id, row.thread_id, row.external_channel_id], ['in', 'post', p.id, thread.id, CHANNEL]);
- const shown = (await forum.getThread(samV, 'general', thread.slug, {})).posts.find((x) => x.id === p.id);
- assert.deepStrictEqual([shown.author.display_name, shown.author.is_relay, shown.author.subject, shown.origin], ['Kim (mod)', true, null, 'discord']);
- // The same message again (a gateway replay) is not taken twice.
- assert.deepStrictEqual(await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });
- });
-
- await check('a message in a Discord thread started from a relayed message becomes a post in that thread', async () => {
- const r = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));
- assert.strictEqual(r.status, 'applied');
- assert.strictEqual((await forumStore.getPost(db, r.post_id)).thread_id, thread.id);
- // A reply to that Discord-origin post (mapped 'in') lands in the same thread too.
- const inId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id)).external_message_id;
- const r2 = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));
- assert.strictEqual((await forumStore.getPost(db, r2.post_id)).relay_author, 'lee');
- });
-
- await check('everything else is ignored: webhooks (the relay\'s own), bots, the bot itself, system messages, non-replies, other channels, inbound off', async () => {
- const n = (await postsOf(thread.id)).length;
- const cases = [
- [msg({ webhook_id: '1' }), /webhook message/],
- [msg({ author: { id: LEE, username: 'lee', bot: true } }), /bot or system/],
- [msg({ author: { id: BOT, username: 'relaybot' } }), /bot or system/],
- [msg({ type: 7 }), /not a plain message/],
- [msg({ message_reference: undefined, type: 0 }), /not a reply to a relayed message/],
- [msg({ message_reference: { message_id: '9999999999999999999' } }), /not a reply to a relayed message/],
- [msg({ channel_id: '7000000000000000001' }), /not a reply to a relayed message/],
- [msg({ type: 0, content: '', message_reference: { type: 1, message_id: threadMsg, channel_id: CHANNEL } }), /not a reply to a relayed message/], // a forward
- ];
- for (const [m, re] of cases) {
- const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
- assert.strictEqual(r.status, 'ignored', JSON.stringify(m));
- assert.match(r.reason, re);
- }
- await relay.updateMapping(1, { inbound: false });
- assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /inbound is off/);
- await relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });
- assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /not a mapped channel/);
- await relay.updateMapping(1, { discord_channel_id: CHANNEL });
- assert.strictEqual((await postsOf(thread.id)).length, n);
- assert.strictEqual((await failures()).length, 0, 'ignoring is not a failure');
- });
-
- await check('limits: a long message is cut, a fourth message a minute is refused, an empty one explains the intent; attachments are noted', async () => {
- const long = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));
- const body = (await forumStore.getPost(db, long.post_id)).body_markdown;
- assert.strictEqual(body.length, 200);
- assert.ok(body.endsWith('…'));
- const talker = { id: '4000000000000000021', username: 'talker' };
- for (let i = 0; i < 3; i++) assert.strictEqual((await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` }))).status, 'applied');
- const r = await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));
- assert.deepStrictEqual([r.status, r.error], ['failed', 'rate limited']);
- const empty = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));
- assert.match(empty.error, /MESSAGE CONTENT intent/);
- const pic = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));
- assert.strictEqual((await forumStore.getPost(db, pic.post_id)).body_markdown, '_(2 attachments on Discord)_');
- const f = await failures();
- assert.deepStrictEqual(f.map((x) => x.error.slice(0, 12)), ['rate limited', 'the message ']);
- assert.deepStrictEqual([f[0].event, f[0].external_author_id, f[0].mapping_id], ['MESSAGE_CREATE', talker.id, 1]);
- assert.ok(!JSON.stringify(f).includes('one too many'), 'failures never keep the text');
- assert.strictEqual(toMarkdown({ content: ' <@123456789012345678> ' }), '@someone');
- });
-
- await check('closed threads refuse replies from Discord and say why: locked, members-only, deleted', async () => {
- const t = (await forum.createThread(samV, 'general', { title: 'Soon closed', body: 'x' })).thread;
- await settle();
- const anchor = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id)).external_message_id;
- const to = (extra = {}) => msg({ author: { id: '4000000000000000030', username: 'late' }, message_reference: { message_id: anchor }, ...extra });
- await forumStore.setThreadFlags(db, t.id, { locked: true });
- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is locked');
- await forumStore.setThreadFlags(db, t.id, { locked: false });
- await forumStore.setThreadMembersOnly(db, t.id, sam);
- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is not public');
- await forumStore.setThreadMembersOnly(db, t.id, null);
- await forumStore.softDeleteThread(db, t.id);
- assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread was deleted');
- assert.strictEqual((await postsOf(t.id)).length, 1, 'only the opening post');
- });
-
- await check('edits and deletes on Discord follow through the map; a delete of the relay\'s own message leaves the thread', async () => {
- const m = msg({ author: { id: '4000000000000000040', username: 'editor' }, content: 'first words' });
- const { post_id: pid } = await inbound.handle('MESSAGE_CREATE', m);
- const r = await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });
- assert.strictEqual(r.status, 'applied');
- const p = await forumStore.getPost(db, pid);
- assert.deepStrictEqual([p.body_markdown, p.revision], ['second words @lee', 2]);
- assert.deepStrictEqual((await forumStore.listPostVersions(db, pid)).map((v) => v.edited_by), [null, 'discord']);
- assert.match((await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] })).reason, /no text change/);
- assert.match((await inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' })).reason, /relay's own/);
- const other = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));
- const otherId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id)).external_message_id;
- assert.strictEqual((await inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL })).status, 'applied');
- assert.ok((await forumStore.getPost(db, pid)).deleted_at, 'the post is deleted');
- const bulk = await inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });
- assert.deepStrictEqual(bulk.map((x) => x.status), ['applied', 'ignored']);
- assert.ok((await forumStore.getPost(db, other.post_id)).deleted_at);
- // A moderator on Discord removed the relay's announcement: the map knows, the thread stays, no edit goes there again.
- assert.match((await inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL })).note, /thread stays/);
- assert.ok(await forumStore.getThread(db, thread.id), 'the thread is still here');
- assert.ok((await db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg)).external_deleted_at);
- assert.strictEqual(await relay.enqueueEdit(await forumStore.getPost(db, (await db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id)).id)), 0);
- });
-
- await check('loop prevention: nothing from Discord is ever relayed back out', async () => {
- await settle();
- const before = hook.hits.length;
- const fromDiscord = (await postsOf(thread.id)).filter((p) => p.origin === 'discord');
- assert.ok(fromDiscord.length >= 5);
- for (const p of fromDiscord) assert.strictEqual(await relay.enqueuePost(p, await forumStore.getThread(db, thread.id), general), 0);
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get()).n, 0);
- await settle();
- assert.strictEqual(hook.hits.length, before, 'no webhook call for anything that came from Discord');
- });
-
- await check('the app: the gateway starts with the relay; status and failures for staff only, without the token; the post shows with a Discord badge', async () => {
- const gw = scriptedGateway();
- process.env.DISCORD_RELAY_INBOUND = 'on';
- process.env.DISCORD_BOT_TOKEN = 'app-bot-token-not-real';
- let t;
- try {
- t = await boot({
-
- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/5/app` } }, gatewayOptions: { WebSocketImpl: gw.WebSocket, minBackoffMs: 5, jitter: () => 0 }, forumLimits: { threads: { cooldownSec: 0 } } },
- });
- } finally { delete process.env.DISCORD_RELAY_INBOUND; delete process.env.DISCORD_BOT_TOKEN; }
- const net = t.network;
- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
- const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- await waitFor(async () => (await t.app.locals.relay.status()).inbound.state === 'ready', 'the app\'s gateway READY');
- assert.strictEqual(sent(gw, 2)[0].d.token, 'app-bot-token-not-real');
- const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true } });
- assert.strictEqual(m.json().mapping.inbound, true);
- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Ask Discord', body: 'what do you think?' } });
- await waitFor(async () => await t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');
- const anchor = await t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();
- gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
- const locked = nextId();
- await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');
- const page = await call(`/s/general/t/${th.json().thread.slug}`);
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
- await t.db.prepare('UPDATE threads SET locked = 1').run();
- gw.dispatch('MESSAGE_CREATE', { id: locked, channel_id: anchor.external_channel_id, type: 19, content: 'too late', author: { id: KIM, username: 'kim' }, message_reference: { message_id: anchor.external_message_id } });
- assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: samJwt })).status, 403);
- // Handling is async (the gateway queues dispatches), so poll the API for the failure itself — the exact
- // condition asserted below — instead of reading once and hoping the record is already there.
- let inb;
- await waitFor(async () => {
- inb = await call('/api/v1/relay/inbound', { cookie: adminJwt });
- const f = inb.json().failures;
- return f.length === 1 && f[0].error === 'the thread is locked' && f[0].discord.message_id === locked && f[0].mapping.space === 'general';
- }, 'the locked reply listed at /api/v1/relay/inbound');
- assert.strictEqual(inb.status, 200);
- assert.deepStrictEqual(inb.json().failures.map((f) => [f.error, f.discord.message_id, f.mapping.space]), [['the thread is locked', locked, 'general']]);
- assert.strictEqual((await call(`/api/v1/relay/inbound/${inb.json().failures[0].id}/dismiss`, { method: 'POST', cookie: adminJwt })).status, 200);
- assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: adminJwt })).json().failures.length, 0);
- assert.strictEqual((await call('/api/v1/relay/inbound?all=1', { cookie: adminJwt })).json().failures.length, 1);
- const st = await call('/api/v1/relay/status', { cookie: adminJwt });
- assert.deepStrictEqual([st.json().inbound.enabled, st.json().inbound.state, st.json().inbound.handled.applied, st.json().inbound_failures], [true, 'ready', 1, 0]);
- assert.ok(!(st.text + inb.text + (await call('/api/ready')).text).includes('app-bot-token-not-real'), 'the token never shows');
- await t.app.locals.relay.stop();
- assert.strictEqual(gw.last().closedWith, 1000);
- await t.close();
- });
-
- await hook.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,509 +0,0 @@
@@ -1,509 +0,0 @@
-'use strict';
-/**
- * Discord relay, outbound (relay/discord.js): off unless DISCORD_RELAY_ENABLED; one delivery per
- * dedupe key; the webhook URL comes from the environment variable a mapping names; creates are sent
- * with ?wait=true and the answer's message id goes into the external message map (unique both ways);
- * replies follow their thread (into the mapping's Discord thread when it names one); edits PATCH and
- * deletes DELETE through the map; retries with backoff on 5xx/429/network errors, then the dead letter
- * ('failed') that staff retry or drop; loop prevention for Discord-origin threads and posts; members,
- * staff and VIP members-only content never relayed (and deleted when gated later); the staff API;
- * the one-time rebuild of an older relay_deliveries table. A fake Discord stands in for the webhooks;
- * the relay's clock is injected so backoff is tested without waiting. The forum queues creates
- * directly here (no Events worker: see relay-events.test.js).
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-const { startWebhooks } = require('./helpers/fake-discord');
-const { testDb } = require('./helpers/db');
-const forumStore = require('../server/forum/store');
-const { createDiscordRelay } = require('../server/relay/discord');
-const { createForumService } = require('../server/forum/service');
-const { sqlTime } = require('../server/http/v1');
-
-(async () => {
- const hook = await startWebhooks();
- const db = await testDb();
- const general = await forumStore.getSpace(db, 'general');
- const feedback = await forumStore.getSpace(db, 'feedback');
- const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };
- let clock = Date.parse('2026-09-22T12:00:00Z');
- const now = () => clock;
- const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.space' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });
- const alex = ids.newId('user');
- await require('../server/identity/projection').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });
- await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
- await relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });
- const newThread = async (space, title, extra = {}) => (await forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra })).thread;
- const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
- const mapOf = async (type, id) => await db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);
- const settle = () => new Promise((r) => setImmediate(r));
- const EMPTY = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
-
- await check('off by default: nothing is queued or sent', async () => {
- const off = createDiscordRelay({ db, env, now });
- assert.strictEqual(off.enabled, false);
- const th = await newThread(general, 'Quiet thread');
- assert.strictEqual(await off.enqueueThread(th, general), 0);
- assert.strictEqual(await off.enqueuePost(await forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);
- assert.deepStrictEqual(await off.drain(), EMPTY);
- assert.strictEqual((await deliveries()).length, 0);
- const st = await off.status();
- assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled], [false, false, false]);
- });
-
- await check('a new thread is posted once with ?wait=true, attribution, a link and no mentions; its message id is mapped', async () => {
- const th = await newThread(general, 'Relay me <please>');
- assert.strictEqual(await relay.enqueueThread(th, general), 1);
- assert.strictEqual(await relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');
- await settle();
- await relay.drain();
- assert.strictEqual(hook.hits.length, 1);
- const msg = hook.hits[0];
- assert.strictEqual(msg.method, 'POST');
- assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');
- assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });
- assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.space\/s\/general\/t\/relay-me-please>$/);
- assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');
- assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.space/s/general/t/relay-me-please');
- assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
- assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Space · s/general');
- const d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
- const [row] = await mapOf('thread', th.id);
- assert.deepStrictEqual([row.platform, row.direction, row.mapping_id, row.thread_id, row.external_channel_id, row.external_thread_id, row.external_webhook_id],
- ['discord', 'out', 1, th.id, '2000000000000000001', null, '1']);
- assert.ok(hook.messages.has(row.external_message_id));
- assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get()).discord_channel_id, '2000000000000000001', 'the mapping learned its channel');
- await relay.drain();
- assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');
- // Even a create queued again under another key is not sent twice: the map already has it.
- await db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));
- await relay.drain();
- assert.strictEqual(hook.hits.length, 1);
- assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['delivered', 'already on Discord']);
- });
-
- await check('the external message map is unique both ways', async () => {
- const th = await newThread(general, 'Unique map');
- const put = async (mapping, type, local, external) => await db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
- VALUES (?, 'out', ?, ?, ?, '2000000000000000001', ?)`).run(mapping, type, local, th.id, external);
- await put(1, 'thread', th.id, '1900000000000000001');
- await assert.rejects(async () => await put(2, 'post', 999, '1900000000000000001'), /duplicate key/, 'one Discord message is one local object');
- await assert.rejects(async () => await put(1, 'thread', th.id, '1900000000000000002'), /duplicate key/, 'one local object is one message per mapping');
- await put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine
- await db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);
- });
-
- await check('retries with exponential backoff on 5xx, then delivers', async () => {
- hook.hits.length = 0;
- hook.plan.push({ status: 502, body: { message: 'bad gateway' } });
- const th = await newThread(feedback, 'Flaky Discord');
- await relay.enqueueThread(th, feedback);
- await settle(); await relay.drain();
- let d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);
- assert.match(d.last_error, /Discord answered 502: bad gateway/);
- assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');
- await relay.drain();
- assert.strictEqual(hook.hits.length, 1, 'not due yet');
- clock += 60_000;
- hook.plan.push({ status: 500 });
- await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');
- clock += 120_000;
- await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);
- assert.strictEqual(hook.hits.length, 3);
- });
-
- await check('429 honours retry_after and holds that webhook for the pass; used-up attempts end as failed; other 4xx fail at once', async () => {
- const th = await newThread(general, 'Rate limited');
- const th2 = await newThread(general, 'Behind it');
- hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });
- await relay.enqueueThread(th, general);
- await relay.enqueueThread(th2, general);
- const before = hook.hits.length;
- await settle(); await relay.drain();
- assert.strictEqual(hook.hits.length, before + 1, 'the next delivery to the same webhook waits for the next pass');
- let d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.strictEqual(d.status, 'pending');
- assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);
- await relay.drain();
- assert.strictEqual((await deliveries()).find((x) => x.thread_id === th2.id).status, 'delivered');
- clock += 300_000;
- hook.plan.push({ status: 503 }, { status: 503 });
- await relay.drain();
- clock += 10 * 60_000;
- await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);
-
- const gone = await newThread(general, 'Webhook deleted');
- hook.plan.push({ status: 404, body: { message: 'Unknown Webhook', code: 10015 } });
- await relay.enqueueThread(gone, general);
- await settle(); await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === gone.id);
- assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);
- assert.match(d.last_error, /404: Unknown Webhook/);
- });
-
- await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {
- const showcase = await forumStore.getSpace(db, 'showcase');
- await relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });
- const th = await newThread(showcase, 'Env later');
- await relay.enqueueThread(th, showcase);
- await settle(); await relay.drain();
- let d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);
- env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';
- clock += 60_000;
- await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');
- assert.ok(d.last_error);
- env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;
- clock += 120_000;
- await relay.drain();
- d = (await deliveries()).find((x) => x.thread_id === th.id);
- assert.strictEqual(d.status, 'delivered');
- const everything = JSON.stringify(await db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(await deliveries()) + JSON.stringify(await db.prepare('SELECT * FROM relay_message_map').all());
- assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');
- });
-
- await check('loop prevention: Discord-origin threads and posts are never relayed; members/staff spaces never leave the site', async () => {
- const before = hook.hits.length;
- const fromDiscord = await newThread(general, 'Came from Discord', { origin: 'discord' });
- assert.strictEqual(await relay.enqueueThread(fromDiscord, general), 0);
- // Even a delivery row that got queued somehow is refused at send time.
- await db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));
- await relay.drain();
- const d = (await deliveries()).find((x) => x.thread_id === fromDiscord.id);
- assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);
- // A reply that came from Discord, in a thread that is on Discord, stays here too.
- const th = await newThread(general, 'Mixed replies');
- await relay.enqueueThread(th, general);
- await settle(); await relay.drain();
- const inbound = await forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });
- assert.strictEqual(await relay.enqueuePost(inbound, th, general), 0);
- await db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));
- await relay.drain();
- assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['failed', 'loop prevention: post came from Discord']);
- await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
- const ins = await forumStore.getSpace(db, 'insiders');
- await relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
- assert.strictEqual(await relay.enqueueThread(await newThread(ins, 'Members only'), ins), 0);
- assert.strictEqual(hook.hits.length, before + 1, 'only the public thread went out');
- });
-
- await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget; drop() keeps it as dropped', async () => {
- await relay.setMappingEnabled(1, false);
- assert.strictEqual(await relay.enqueueThread(await newThread(general, 'Mapping off'), general), 0);
- await relay.setMappingEnabled(1, true);
- const failed = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 404);
- assert.strictEqual(await relay.retry(failed.id), 1);
- await settle(); await relay.drain();
- assert.strictEqual((await deliveries()).find((x) => x.id === failed.id).status, 'delivered');
- assert.strictEqual(await relay.retry(failed.id), 0, 'delivered ones stay delivered');
- const dead = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 503);
- assert.strictEqual(await relay.drop(dead.id), 1);
- assert.strictEqual((await deliveries()).find((x) => x.id === dead.id).status, 'dropped');
- assert.strictEqual(await relay.drop(dead.id), 0);
- assert.strictEqual((await relay.status()).deliveries.dropped, 1);
- });
-
- // ── replies, edits and deletes through the forum service ─────────────────────────
- const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
- const sam = ids.newId('user');
- await require('../server/identity/projection').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });
- const samV = { kind: 'user', subject: sam, staff: false };
- const alexV = { kind: 'user', subject: alex, staff: false };
- const modV = { kind: 'user', subject: ids.newId('user'), staff: true };
- const drainAll = async () => { await settle(); for (let i = 0; i < 5; i++) await relay.drain(); };
-
- await check('replies follow their thread: one message per reply, linked to the post, mapped; a reply waits for its thread\'s message', async () => {
- hook.hits.length = 0;
- const { thread } = await forum.createThread(samV, 'general', { title: 'Replies go too', body: 'Opening words' });
- const { post } = await forum.reply(alexV, 'general', thread.slug, { body: 'First **reply**' });
- await drainAll();
- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
- const r = hook.hits[1];
- assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');
- assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.space\/s\/general\/t\/replies-go-too#post-\d+>$/);
- assert.strictEqual(r.body.embeds[0].description, 'First reply');
- assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
- const [row] = await mapOf('post', post.id);
- assert.deepStrictEqual([row.direction, row.thread_id], ['out', thread.id]);
- // The thread's own message failing for now: the reply waits, without using an attempt.
- hook.hits.length = 0;
- hook.plan.push({ status: 500 });
- const t2 = await forum.createThread(samV, 'general', { title: 'Thread first', body: 'x' });
- const p2 = (await forum.reply(alexV, 'general', t2.thread.slug, { body: 'waits' })).post;
- await drainAll();
- const reply = (await deliveries()).find((x) => x.post_id === p2.id);
- assert.deepStrictEqual([reply.status, reply.attempts, reply.last_error], ['pending', 0, "waiting for the thread's own message"]);
- assert.strictEqual(hook.hits.length, 1, 'only the thread was tried');
- clock += 60_000;
- await drainAll();
- assert.deepStrictEqual(hook.hits.map((h) => h.body.content.startsWith('New thread') ? 'thread' : 'reply'), ['thread', 'thread', 'reply'], 'the thread, then its reply');
- assert.strictEqual((await deliveries()).find((x) => x.post_id === p2.id).status, 'delivered');
- // A thread that was never relayed (made before the mapping): its replies are not either.
- const old = await newThread(general, 'Before the mapping');
- assert.strictEqual(await relay.enqueuePost(await forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);
- });
-
- await check('a mapping that names a Discord thread posts into it (thread_id) and edits and deletes there', async () => {
- hook.hits.length = 0;
- await relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });
- const { thread } = await forum.createThread(samV, 'feedback', { title: 'Into a Discord thread', body: 'hi' });
- const { post } = await forum.reply(samV, 'feedback', thread.slug, { body: 'reply in thread' });
- await drainAll();
- assert.deepStrictEqual(hook.hits.map((h) => h.path), ['/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002', '/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002'], JSON.stringify(hook.hits.map((h) => h.path)));
- const [row] = await mapOf('post', post.id);
- assert.deepStrictEqual([row.external_channel_id, row.external_thread_id], ['3000000000000000002', '3000000000000000002']);
- await forum.editPost(samV, post.id, { body: 'reply in thread, edited' });
- await drainAll();
- await forum.deletePost(samV, post.id);
- await drainAll();
- assert.deepStrictEqual(hook.hits.slice(2).map((h) => `${h.method} ${h.path}`), [
- `PATCH /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
- `DELETE /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
- ]);
- assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get()).discord_channel_id, '2000000000000000002', 'the channel learned before stays');
- await relay.updateMapping(2, { discord_thread_id: null });
- });
-
- await check('edits follow through the map: a reply\'s message and, for the opening post, the thread\'s message are PATCHed', async () => {
- hook.hits.length = 0;
- const { thread, post: opening } = await forum.createThread(samV, 'general', { title: 'Edit me', body: 'Version one' });
- const { post } = await forum.reply(samV, 'general', thread.slug, { body: 'Reply one' });
- await drainAll();
- const threadMsg = (await mapOf('thread', thread.id))[0].external_message_id;
- const postMsg = (await mapOf('post', post.id))[0].external_message_id;
- await forum.editPost(samV, post.id, { body: 'Reply **two**' });
- await forum.editPost(samV, opening.id, { body: 'Version two' });
- await forum.editPost(samV, opening.id, { body: 'Version two' }); // no change: no revision, nothing queued
- await drainAll();
- const edits = hook.hits.filter((h) => h.method === 'PATCH');
- assert.deepStrictEqual(edits.map((h) => h.pathname), [`/api/webhooks/1/general/messages/${postMsg}`, `/api/webhooks/1/general/messages/${threadMsg}`]);
- assert.strictEqual(edits[0].body.embeds[0].description, 'Reply two');
- assert.strictEqual(edits[1].body.embeds[0].description, 'Version two');
- assert.deepStrictEqual(edits[1].body.allowed_mentions, { parse: [] });
- assert.strictEqual(hook.messages.get(threadMsg).body.embeds[0].description, 'Version two');
- const keys = (await deliveries()).filter((d) => d.action === 'edit').map((d) => d.dedupe_key);
- assert.ok(keys.includes(`edit:post:${post.id}:mapping:1:r2`) && keys.includes(`edit:thread:${thread.id}:mapping:1:r2`), keys.join());
- // Someone deleted the reply's message on Discord: the next edit finds nothing and says so.
- hook.messages.get(postMsg).deleted = true;
- await forum.editPost(samV, post.id, { body: 'Reply three' });
- await drainAll();
- const last = (await deliveries()).pop();
- assert.deepStrictEqual([last.action, last.status, last.last_error], ['edit', 'skipped', 'the message is gone on Discord']);
- assert.ok((await mapOf('post', post.id))[0].external_deleted_at, 'the map knows it is gone');
- const queued = (await deliveries()).length;
- await forum.editPost(samV, post.id, { body: 'Reply four' });
- assert.strictEqual((await deliveries()).length, queued, 'nothing more is queued for a message that is gone');
- // An edit right before a delete is skipped: only the delete goes out.
- const { post: p2 } = await forum.reply(samV, 'general', thread.slug, { body: 'short-lived' });
- await drainAll();
- hook.hits.length = 0;
- await forum.editPost(samV, p2.id, { body: 'short-lived, edited' });
- await forum.deletePost(samV, p2.id);
- await drainAll();
- assert.deepStrictEqual(hook.hits.map((h) => h.method), ['DELETE']);
- assert.strictEqual((await deliveries()).find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');
- });
-
- await check('deletes follow through the map: a reply, and a thread with every reply of it; what was not sent yet is skipped', async () => {
- hook.hits.length = 0;
- const { thread } = await forum.createThread(samV, 'general', { title: 'Delete me', body: 'soon gone' });
- const a = (await forum.reply(samV, 'general', thread.slug, { body: 'a' })).post;
- const b = (await forum.reply(alexV, 'general', thread.slug, { body: 'b' })).post;
- await drainAll();
- const msgs = { thread: (await mapOf('thread', thread.id))[0].external_message_id, a: (await mapOf('post', a.id))[0].external_message_id, b: (await mapOf('post', b.id))[0].external_message_id };
- await forum.deletePost(modV, a.id); // moderated away
- await drainAll();
- assert.deepStrictEqual(hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname), [`/api/webhooks/1/general/messages/${msgs.a}`]);
- const c = (await forum.reply(samV, 'general', thread.slug, { body: 'c, never sent' })).post; // queued, not sent yet
- await forum.deleteThread(samV, 'general', thread.slug);
- await drainAll();
- const deleted = hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname).sort();
- assert.deepStrictEqual(deleted, [msgs.thread, msgs.a, msgs.b].map((m) => `/api/webhooks/1/general/messages/${m}`).sort());
- assert.ok(!hook.hits.some((h) => h.method === 'POST' && /c, never sent/.test(JSON.stringify(h.body))), 'the reply queued before the delete never went out');
- assert.deepStrictEqual([(await deliveries()).find((d) => d.post_id === c.id).status], ['skipped']);
- assert.ok((await mapOf('thread', thread.id)).every((r) => r.external_deleted_at));
- // A delete whose message is already gone on Discord counts as done.
- const t3 = (await forum.createThread(samV, 'general', { title: 'Gone already', body: 'x' })).thread;
- await drainAll();
- hook.messages.get((await mapOf('thread', t3.id))[0].external_message_id).deleted = true;
- await forum.deleteThread(samV, 'general', t3.slug);
- await drainAll();
- const del = (await deliveries()).pop();
- assert.deepStrictEqual([del.action, del.status, del.last_status, del.last_error], ['delete', 'delivered', 404, 'the message was already gone on Discord']);
- });
-
- await check('members-only (VIP): gated threads and spaces never go out, and gating later deletes what went out', async () => {
- hook.hits.length = 0;
- const { thread } = await forum.createThread(samV, 'general', { title: 'Public for now', body: 'x' });
- await forum.reply(alexV, 'general', thread.slug, { body: 'a public reply' });
- await drainAll();
- const posted = hook.hits.length;
- assert.strictEqual(posted, 2);
- await forum.setThreadMembersOnly(samV, 'general', thread.slug, { members_only: sam });
- await drainAll();
- assert.strictEqual(hook.hits.filter((h) => h.method === 'DELETE').length, 2, 'the thread\'s and the reply\'s messages were deleted');
- // Replies in the gated thread (the creator may still post) never go out.
- const gatedThread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);
- const reply = await forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });
- assert.strictEqual(await relay.enqueuePost(reply, gatedThread, general), 0);
- // A members-only thread from the start is never queued, nor is anything in a VIP space.
- const gated = await newThread(general, 'VIP from the start', { members_only_owner: sam });
- assert.strictEqual(await relay.enqueueThread(gated, general), 0);
- await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);
- const vipSpace = await forumStore.getSpace(db, 'feedback');
- assert.strictEqual(await relay.enqueueThread(await newThread(vipSpace, 'In a VIP space'), vipSpace), 0);
- await db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);
- await drainAll();
- assert.ok(!hook.hits.slice(posted).some((h) => h.method !== 'DELETE'), 'nothing but the two deletes');
- });
-
- await check('app wiring: threads, replies, edits and deletes through the API are relayed; the admin endpoints are staff-only and URL-free', async () => {
- const t = await boot({
-
- appOpts: { relayOptions: { enabled: true, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/9/app` } }, forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } },
- });
- const net = t.network;
- const samU = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
- const call = (p, { method = 'GET', cookie, token, json, headers = {} } = {}) => {
- const h = { ...headers };
- if (token) h.authorization = `Bearer ${token}`;
- if (json !== undefined) h['content-type'] = 'application/json';
- return t.get(p, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- };
- const r = t.app.locals.relay;
- assert.strictEqual((await r.status()).creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');
- assert.deepStrictEqual([(await r.status()).events_worker.enabled, (await r.status()).inbound.enabled], [false, false]);
- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);
- const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });
- assert.strictEqual(bad.status, 400);
- assert.strictEqual(bad.json().code, 'relay.invalid_ref');
- assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', discord_channel_id: 'nope' } })).json().code, 'relay.invalid_discord_id');
- const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } });
- assert.strictEqual(m.status, 201, m.text);
- assert.deepStrictEqual([m.json().mapping.space, m.json().mapping.webhook_configured, m.json().mapping.enabled, m.json().mapping.inbound, m.json().mapping.discord_channel_id], ['general', true, true, false, null]);
- const put = await call(`/api/v1/relay/mappings/${m.json().mapping.id}`, { method: 'PUT', cookie: adminJwt, json: { inbound: true, discord_channel_id: '2000000000000000009' } });
- assert.deepStrictEqual([put.json().mapping.inbound, put.json().mapping.discord_channel_id, put.json().mapping.enabled], [true, '2000000000000000009', true]);
- const before = hook.hits.length;
- const waitHits = async (n) => { for (let i = 0; i < 100 && hook.hits.length < n; i++) await new Promise((res) => setTimeout(res, 10)); };
- const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Through the app', body: 'hi' } });
- assert.strictEqual(th.status, 201);
- await waitHits(before + 1);
- assert.strictEqual(hook.hits[hook.hits.length - 1].path, '/api/webhooks/9/app?wait=true');
- const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'a reply' } });
- assert.strictEqual(reply.status, 201, reply.text);
- await waitHits(before + 2);
- assert.match(hook.hits[hook.hits.length - 1].body.content, /replied to \*\*Through the app\*\*/);
- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', cookie: samJwt, json: { body: 'a reply, edited' } })).status, 200);
- await waitHits(before + 3);
- assert.strictEqual(hook.hits[hook.hits.length - 1].method, 'PATCH');
- assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);
- await waitHits(before + 4);
- assert.strictEqual(hook.hits[hook.hits.length - 1].method, 'DELETE');
- hook.plan.push({ status: 400, body: { message: 'Invalid Form Body' } });
- await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'This one fails', body: 'hi' } });
- await waitHits(before + 5);
- await r.drain();
- assert.strictEqual((await call('/api/v1/relay/deliveries')).status, 401);
- assert.strictEqual((await call('/api/v1/relay/deliveries', { cookie: samJwt })).status, 403);
- assert.strictEqual((await call('/api/v1/relay/status', { cookie: samJwt })).status, 403);
- const failed = await call('/api/v1/relay/deliveries?status=failed', { cookie: adminJwt });
- assert.strictEqual(failed.status, 200, failed.text);
- assert.strictEqual(failed.json().enabled, true);
- assert.strictEqual(failed.json().deliveries.length, 1);
- const dl = failed.json().deliveries[0];
- assert.match(dl.last_error, /400: Invalid Form Body/);
- assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/s/general/t/this-one-fails', 'create', 'direct']);
- const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });
- assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);
- const st = await call('/api/v1/relay/status', { cookie: adminJwt });
- assert.strictEqual(st.status, 200);
- assert.deepStrictEqual([st.json().dead_letters, st.json().deliveries.failed, st.json().creates_from], [1, 1, 'forum']);
- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/drop`, { method: 'POST', cookie: adminJwt })).status, 200);
- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/drop`, { method: 'POST', cookie: adminJwt })).status, 404, 'dropped already');
- assert.strictEqual((await call(`/api/v1/relay/deliveries/${dl.id}/retry`, { method: 'POST', cookie: adminJwt })).status, 200, 'a dropped one can be retried');
- await waitHits(before + 6);
- await r.drain();
- assert.strictEqual((await call('/api/v1/relay/deliveries?status=failed', { cookie: adminJwt })).json().deliveries.length, 0);
- const ready = await call('/api/ready');
- assert.deepStrictEqual([ready.json().discord_relay.enabled, ready.json().discord_relay.events_worker.enabled, ready.json().discord_relay.deliveries.delivered > 0], [true, false, true]);
- const svcAdmin = await call('/api/v1/relay/deliveries', { token: net.signService({ cap: ['space.forum.manage'] }) });
- assert.strictEqual(svcAdmin.status, 200);
- assert.ok(!(failed.text + svcAdmin.text + st.text + (await call('/api/v1/relay/mappings', { cookie: adminJwt })).text).includes('/api/webhooks/'), 'responses never carry a webhook URL');
- await t.close();
- });
-
- await check('staff can map only allow-listed webhook variables, never the URL in some other env var', async () => {
- const secretHits = [];
- const internal = { url: 'http://127.0.0.1:9/internal', hits: secretHits };
- const env2 = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/ok`, OV_MEDIA_INTERNAL_URL: internal.url, NETWORK_INTERNAL_URL: internal.url, DISCORD_WEBHOOKS_X: internal.url };
- const fetchSpy = (url, o) => { if (String(url).startsWith(internal.url)) secretHits.push(url); return fetch(url, o); };
- const t = await boot({ appOpts: { relayOptions: { enabled: true, env: env2, fetchImpl: fetchSpy }, forumLimits: { threads: { cooldownSec: 0 } } } });
- const net = t.network;
- const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
- const samJwt = net.sign({ id: 9, subject_id: net.addUser({ network_user_id: 9, username: 'sam' }).subject_id, username: 'sam', role: 'user' });
- const call = (p, { method = 'GET', cookie, token, json } = {}) => t.get(p, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) }, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- const map = (ref, who = { cookie: adminJwt }) => call('/api/v1/relay/mappings', { method: 'POST', json: { space: 'general', webhook_url_ref: ref }, ...who });
- for (const ref of ['OV_MEDIA_INTERNAL_URL', 'NETWORK_INTERNAL_URL', 'DISCORD_WEBHOOKS_X', 'PATH', 'DISCORD_WEBHOOK_']) {
- const r = await map(ref);
- assert.strictEqual(r.status, 400, `${ref}: ${r.text}`);
- assert.strictEqual(r.json().code, 'relay.ref_not_allowed');
- const svc = await map(ref, { token: net.signService({ cap: ['space.forum.manage'] }) });
- assert.strictEqual(svc.status, 400, `${ref} as a service`);
- }
- assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get()).n, 0);
- await assert.rejects(async () => await t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);
- // A mapping that predates the allow-list (straight into the table) is never sent to, and
- // does not reveal whether that variable is set.
- const gen = await t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();
- await t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);
- const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((x) => x.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');
- assert.strictEqual(listed.webhook_configured, false);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);
- await t.app.locals.relay.drain();
- assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');
- const d = await t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();
- assert.strictEqual(d.status, 'failed');
- assert.match(d.last_error, /not an allowed webhook variable/);
- assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');
- await t.close();
- // DISCORD_RELAY_WEBHOOK_VARS narrows it to exact names.
- const strict = createDiscordRelay({ db: t.db, env: env2, webhookVars: ['DISCORD_WEBHOOK_GENERAL'] });
- assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_GENERAL'), true);
- assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_OTHER'), false);
- assert.strictEqual(createDiscordRelay({ db: t.db, env: env2 }).refAllowed('DISCORD_WEBHOOK_OTHER'), true);
- });
-
- await check('disabled by default in the app: no relay, no Events worker, no gateway, whatever else is set', async () => {
- process.env.DISCORD_BOT_TOKEN = 'not-a-real-token';
- process.env.DISCORD_RELAY_INBOUND = 'on';
- process.env.EVENTS_URL = 'http://127.0.0.1:9';
- try {
- const t = await boot({ });
- const st = await t.app.locals.relay.status();
- assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled, t.app.locals.relayInbound], [false, false, false, null]);
- assert.match(st.events_worker.reason, /relay is off/);
- await t.close();
- } finally {
- delete process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_RELAY_INBOUND; delete process.env.EVENTS_URL;
- }
- });
-
- await hook.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,194 +0,0 @@
@@ -1,194 +0,0 @@
-'use strict';
-/**
- * Per-space moderators (space_moderators, migrations/0001_initial.sql): a person listed for a space moderates
- * that space — thread state, deletes, settings, categories, its moderators — and nothing anywhere else; discussion
- * staff still moderate everywhere; creating spaces and the board index stay with staff. Adding is refused for a
- * non-moderator and across a block; account deletion erases the row and a subject merge moves it.
- */
-const assert = require('assert');
-const { boot, check, done } = require('./helpers/app');
-
-(async () => {
- const t = await boot({
- appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } },
- });
- const net = t.network;
- const mk = (id, username, role = 'user') => {
- const u = net.addUser({ network_user_id: id, username, display_name: username });
- return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };
- };
- const alex = mk(31, 'alex'); // moderates general
- const sam = mk(32, 'sam'); // nobody in particular
- const bob = mk(33, 'bob'); // writes the threads
- const carol = mk(34, 'carol'); // added by alex
- const dave = mk(35, 'dave'); // blocked alex
- const boss = mk(36, 'boss', 'global_mod');
- const call = (path, { method = 'GET', who, json } = {}) => t.get(path, {
- method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: who ? [`ov_token=${who.jwt}`] : [],
- });
- const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: [`ov_token=${who.jwt}`] });
- const thread = async (space) => {
- const r = await call(`/api/v1/spaces/${space}/threads`, { method: 'POST', who: bob, json: { title: `A thread in ${space}`, body: 'hello there' } });
- assert.strictEqual(r.status, 201, r.text);
- return r.json().thread.slug;
- };
- const mods = async (space) => (await call(`/api/v1/spaces/${space}/moderators`)).json().moderators.map((m) => m.subject);
-
- await check('(d) adding a moderator: refused for a non-moderator, works for staff and then for the space\'s moderator', async () => {
- let r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: sam });
- assert.strictEqual(r.status, 403, r.text);
- assert.strictEqual(r.json().code, 'capability.denied');
- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: sam })).status, 403, 'nobody adds themselves');
- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT' })).status, 403, 'signed out');
- r = await call(`/api/v1/spaces/general/moderators/${alex.subject_id}`, { method: 'PUT', who: boss });
- assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(r.json().moderators.map((m) => [m.subject, m.username, m.added_by]), [[alex.subject_id, 'alex', boss.subject_id]]);
- r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: alex })).status, 200, 'idempotent');
- assert.deepStrictEqual(await mods('general'), [alex.subject_id, carol.subject_id]);
- assert.strictEqual((await call('/api/v1/spaces/general/moderators/not-a-subject', { method: 'PUT', who: alex })).status, 404, 'a name nobody holds');
- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${encodeURIComponent('not a name!')}`, { method: 'PUT', who: alex })).status, 400, 'neither a subject nor a name');
- assert.strictEqual((await call(`/api/v1/spaces/no-such-space/moderators/${carol.subject_id}`, { method: 'PUT', who: boss })).status, 404);
- assert.strictEqual((await call(`/api/v1/spaces/showcase/moderators/${sam.subject_id}`, { method: 'PUT', who: alex })).status, 403, 'not in another space');
- assert.deepStrictEqual(await mods('showcase'), []);
- });
-
- await check('a person who blocked the one adding them (or was blocked by them) cannot be added', async () => {
- await t.db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, ?)').run(dave.subject_id, alex.subject_id, Date.now());
- const r = await call(`/api/v1/spaces/general/moderators/${dave.subject_id}`, { method: 'PUT', who: alex });
- assert.strictEqual(r.status, 403, r.text);
- assert.strictEqual(r.json().code, 'space.blocked');
- assert.ok(!(await mods('general')).includes(dave.subject_id));
- });
-
- await check('(a) a moderator of general locks, sets the state of and deletes a thread there; can_moderate shows it', async () => {
- const slug = await thread('general');
- let r = await call(`/api/v1/spaces/general/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true, pinned: true } });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.json().thread.locked, true);
- const page = (await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).json();
- assert.strictEqual(page.viewer.can_moderate, true);
- assert.strictEqual(page.viewer.can_delete, true);
- assert.strictEqual(page.posts[0].can_edit, true);
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: alex, json: { body: 'moderators reply in a locked thread' } })).status, 201);
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}/posts`, { method: 'POST', who: sam, json: { body: 'others do not' } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, true);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: sam })).json().viewer.can_moderate, false);
- r = await call(`/api/v1/spaces/general/threads/${slug}`, { method: 'DELETE', who: alex });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual((await call(`/api/v1/spaces/general/threads/${slug}`, { who: alex })).status, 404);
- });
-
- await check('(b) the same moderator gets 403 in showcase', async () => {
- const slug = await thread('showcase');
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}/state`, { method: 'PUT', who: alex, json: { locked: true } })).status, 403);
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { method: 'DELETE', who: alex })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/showcase/settings', { method: 'PUT', who: alex, json: { name: 'Mine now' } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/showcase/categories/clips', { method: 'PUT', who: alex, json: { name: 'Clips' } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: alex, json: { owner: alex.subject_id } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: alex })).json().viewer.can_moderate, false);
- const page = (await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: alex })).json();
- assert.strictEqual(page.viewer.can_moderate, false);
- assert.strictEqual(page.viewer.can_delete, false);
- assert.strictEqual(page.posts[0].can_edit, false);
- assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
- assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);
- });
-
- await check('(c) a global moderator still passes in both spaces', async () => {
- for (const space of ['general', 'showcase']) {
- const slug = await thread(space);
- assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}/state`, { method: 'PUT', who: boss, json: { locked: true } })).status, 200, space);
- assert.strictEqual((await call(`/api/v1/spaces/${space}/threads/${slug}`, { method: 'DELETE', who: boss })).status, 200, space);
- }
- });
-
- await check('settings and categories of their own space; never its place on the board index', async () => {
- let r = await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { description: 'Run by its moderators' } });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.json().space.description, 'Run by its moderators');
- assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { group: null } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/general/settings', { method: 'PUT', who: alex, json: { parent: 'showcase' } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);
- assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);
- const before = (await call('/api/v1/spaces/general')).json().space.group;
- r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
- assert.strictEqual(r.status, 303, r.text);
- assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');
- });
-
- await check('(e) a space moderator cannot create a space or change the board index', async () => {
- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);
- assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
- assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);
- assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);
- });
-
- await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {
- let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });
- assert.strictEqual(r.status, 303, r.text);
- assert.ok((await mods('general')).includes(sam.subject_id));
- assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
- r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });
- assert.strictEqual(r.status, 303, r.text);
- assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');
- r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });
- assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
- });
-
- await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {
- let r = await form('/s/general/moderators', alex, { subject: '@Sam' });
- assert.strictEqual(r.status, 303, r.text);
- assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);
- assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');
- r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });
- assert.strictEqual(r.status, 303, r.text);
- assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
- assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
- assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');
- for (const style of ['forum', 'feed']) {
- assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
- const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
- assert.strictEqual(page.status, 200, page.text);
- assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);
- assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);
- assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);
- assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);
- assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);
- assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);
- for (const who of [bob, undefined]) {
- const other = await call('/s/general', { who });
- assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);
- }
- }
- r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });
- assert.strictEqual(r.status, 303, r.text);
- assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
- });
-
- await check('(f) account deletion erases the row; a subject merge moves it', async () => {
- const accountData = require('../server/identity/account-data');
- const merge = require('../server/identity/subject-merge');
- const ids = async (subject) => (await t.db.prepare('SELECT space_id FROM space_moderators WHERE subject_id = ? ORDER BY space_id').all(subject)).map((r) => Number(r.space_id));
- await call(`/api/v1/spaces/showcase/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });
- await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'PUT', who: boss });
- await call(`/api/v1/spaces/showcase/moderators/${bob.subject_id}`, { method: 'PUT', who: boss });
- // carol folds into bob: general moves over, showcase (both had it) stays bob's alone.
- await merge.apply(t.db, { from: carol.subject_id, into: bob.subject_id, merge_id: 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4' }, { log: { log() {} } });
- assert.deepStrictEqual(await ids(carol.subject_id), []);
- assert.deepStrictEqual(await ids(bob.subject_id), [1, 3]);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: bob })).json().viewer.can_moderate, true);
- // alex's account is deleted: the row goes, and the moderators alex added stay without them.
- await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'PUT', who: alex });
- const out = await accountData.erase(t.db, [alex.subject_id]);
- assert.strictEqual(out.erased.space_moderators, 1);
- assert.deepStrictEqual(await ids(alex.subject_id), []);
- assert.strictEqual((await t.db.prepare('SELECT added_by FROM space_moderators WHERE subject_id = ? AND space_id = 1').get(sam.subject_id)).added_by, null);
- assert.strictEqual((await call('/api/v1/spaces/general/threads', { who: alex })).json().viewer.can_moderate, false);
- });
-
- await t.close();
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,73 +0,0 @@
@@ -1,73 +0,0 @@
-'use strict';
-/**
- * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same transaction, after locking
- * the target row (server/votes.js), so however writes interleave the stored score is the sum of the vote rows.
- * Checked on a real database: interleaved writes step by step, a stale reader, then 1000 votes with 32 in flight at a
- * time (under npm run test:pg they run on separate PostgreSQL connections through PgBouncer).
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { testDb } = require('./helpers/db');
-const { applyVote } = require('../server/votes');
-const forumStore = require('../server/forum/store');
-const { check, done } = require('./helpers/app');
-
-let db, thread, subjects;
-
-async function invariant(label) {
- const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);
- const tv = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);
- assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);
- const dup = (await db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM thread_votes WHERE thread_id = ? GROUP BY subject_id HAVING COUNT(*) > 1) d').get(thread.id)).n;
- assert.strictEqual(dup, 0, `${label}: one vote per person`);
-}
-
-(async () => {
- db = await testDb({ max: 8 });
- const space = await forumStore.getSpace(db, 'general');
- ({ thread } = await forumStore.createThread(db, { space_id: space.id, title: 'Race thread', body_markdown: 'go' }));
- subjects = Array.from({ length: 10 }, () => ids.newId('user'));
-
- await check('interleaved votes: add, flip, remove — the score never drifts', async () => {
- const [s1, s2, s3] = subjects;
- const steps = [[s1, 1], [s1, -1], [s2, 1], [s2, 1], [s1, -1], [s3, -1], [s3, 0], [s1, 1], [s2, 0], [s2, -1], [s3, 1], [s3, 1]];
- for (const [s, v] of steps) {
- const out = await applyVote(db, 'thread', thread.id, s, v);
- await invariant('after step');
- assert.strictEqual(out.my_vote, v);
- }
- // Final state is the last write per person: s1 +1, s2 −1, s3 +1.
- const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);
- assert.deepStrictEqual({ ...t }, { score: 1 });
- });
-
- await check('a stale reader cannot overwrite a newer score: the recount reads the rows under the row lock', async () => {
- const staleScore = (await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id)).score;
- await applyVote(db, 'thread', thread.id, subjects[4], 1);
- const out = await applyVote(db, 'thread', thread.id, subjects[5], 1);
- assert.strictEqual(out.score, staleScore + 2);
- await invariant('stale reader');
- });
-
- await check('1000 votes, 32 in flight at a time, on one thread: invariants hold, none fails', async () => {
- let seed = 7919;
- const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
- const ops = Array.from({ length: 1000 }, () => [subjects[rand(subjects.length)], [1, -1, 0][rand(3)]]);
- // 32 in flight at a time: more than the pool's connections, so votes queue and interleave on the row locks.
- const failed = [];
- let next = 0;
- await Promise.all(Array.from({ length: 32 }, async () => {
- while (next < ops.length) {
- const [s, v] = ops[next++];
- try { await applyVote(db, 'thread', thread.id, s, v); } catch (err) { failed.push(err); }
- }
- }));
- assert.strictEqual(failed.length, 0, failed.length ? failed[0].message : '');
- await invariant('after the storm');
- const rows = (await db.prepare('SELECT COUNT(*) AS n FROM thread_votes WHERE thread_id = ?').get(thread.id)).n;
- assert.ok(rows <= subjects.length);
- });
-
- await db.close();
- done();
-})();