openvibers/openvibe.space Commits

OpenVibe.Space: the forum moves off OpenVibe.Community (plan T10 step 3) (#1)

OP OpenVibers committed 7 Oct 2026 · e8c564a67f · 90 files, +14106 −0

OpenVibe.Space is built and green in the worktree — nothing committed or pushed.

## What was built

A complete forum service in the new repo (`/tmp/.../scratchpad/sp1-space`, worktree of `OpenVibers/OpenVibe.Space`), moved off OpenVibe.Community's `origin/main` (`099b0bb`, which already carried per-space moderators): Express + one PostgreSQL database, server-rendered pages, its own OAuth client `space`, port **4940**, pins **contracts v0.108.0 / sdk v0.34.0 / shared v2.11.0**, lockfile via `npm install --package-lock-only` (through `node child_process` — the shell refused it) and deps linked with `ds-deps.js`.

## File list (91 files)

**Scaffold** — `package.json`, `package-lock.json`, `README.md`, `STATUS.json`, `LICENSE` (AGPL-3.0), `SECURITY.md`, `.env.example`, `.gitignore`, `.gitleaks.toml`, `.github/workflows/ci.yml` (`contracts-service: space`, `syntax-dirs: server public/js test`), `deploy/systemd/openvibe-space.service`, `deploy/nginx/openvibe.space.conf`, `docs/discord-relay.md`, `docs/roadmap/public.json`.

**Schema** — `migrations/0001_initial.sql`: the whole forum (space_groups, spaces, categories, threads, posts, post_versions, thread_votes, attachments, post_reactions, space_chat_rooms, `space_moderators` folded in from Community's 0005), relay tables, `network_blocks`, `account_data_events`, `subject_projection`/`legacy_id_map`, `space_event_inbox`, `token_revocations`, `event_outbox`, plus Community's `ov_*` helpers and the seeded board.

**Server** — `index.js`, `app.js`, `config.js`, `db.js`, `events.js` (outbox), `events-consumer.js` (blocks/cutoffs/account/merge/VIP), `observability.js`, `limits.js`, `actor-limits.js`, `votes.js`, `chat-rooms.js`, `discovery.js`, `auth/routes.js`, `http/v1.js`, `identity/{capabilities,authors,blocks,network,viewer,account-data,subject-merge}.js` + new `identity/projection.js` (the subject_projection/legacy_id_map helpers that lived in Community's pastes/stores), `forum/{store,service,api,routes,reactions,roadmap}.js`, `relay/{discord,discord-gateway,inbound,events-worker,api}.js`, `render/{layout,pages,forum,board,markdown,highlight,jsonld}.js`, `media/{objects,strip-metadata}.js`, `vip/{index,vip-client}.js`.

**Public** — `css/space.css`, `js/space.js`, `manifest.webmanifest`, `og-default.png` and the five app icons (generated for Space with `openvibe-shared/app-icon` + sharp).

**Tests** — the 11 ported suites, plus `space-moderators.test.js` (the feature landed in Community after the dossier; the code moved, so its test did too) and `markdown.test.js`, plus the new `boot.test.js`; helpers `app.js`, `db.js`, `mock-network.js`, `mock-media.js` (Space tenant), `fake-discord.js`, `fake-events.js`, `routes.js` (kept; currently unused), `test/run.js`.

## Capability renames applied

Released ids win, and they match the dossier: `community.post.create` → **`space.post.write`**, `community.comment.moderate` → **`space.forum.manage`**, `community.space.read/manage` → **`space.forum.read`/`space.forum.manage`**, `community.thread.read` → **`space.thread.read`** (`space.thread.write`, `space.pulse.read` exist but stay unbuilt/planned). Also renamed: every per-actor limit bucket (`space.forum.read`, `space.space.configure`, `space.thread.*`, `space.post.*`, `space.relay.*`), error codes (`space.blocked`, `space.internal_only`, `space.bad_signature`, …), events (`space.thread.created`, `space.post.created`, `space.moderation.action`), service strings (`space` in Chat attachments, VIP `reso

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..0289303
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,97 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.space — Environment Configuration
++# Copy to .env and fill in values.
++# In production this lives at /etc/openvibe/space.env (0600).
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4940
++HOST=127.0.0.1
++NODE_ENV=development
++BASE_URL=http://localhost:4940
++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
++TRUST_PROXY=2
++
++# Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++
++# OAuth2 client credentials (client `space` registered in the Network DB,
++# redirect https://openvibe.space/auth/callback)
++OV_OAUTH_CLIENT_ID=space
++OV_OAUTH_CLIENT_SECRET=change-me-to-the-registered-space-secret
++# Production: https://openvibe.space/auth/callback
++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).
++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=
++
++# 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.
++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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..f5aa0d3
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,18 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
++ # scripts/test-services.sh); npm test runs every file on PGlite, test:pg runs them all through PgBouncer.
++ test:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
++ with:
++ syntax-dirs: server public/js test
++ contracts-service: space
++ 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).
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@aa570e4815063dc65624a6a486038d83ff6f6ec0
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
nnew file mode 100644
iindex 0000000..c49b875
---- /dev/null
++++ b/.gitleaks.toml
@@@ -0,0 +1,5 @@
++# gitleaks configuration, used by the shared security workflow
++# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every
++# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
++[extend]
++useDefault = true
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
++ Everyone is permitted to copy and distribute verbatim copies
++ of this license document, but changing it is not allowed.
++
++ Preamble
++
++ The GNU Affero General Public License is a free, copyleft license for
++software and other kinds of works, specifically designed to ensure
++cooperation with the community in the case of network server software.
++
++ The licenses for most software and other practical works are designed
++to take away your freedom to share and change the works. By contrast,
++our General Public Licenses are intended to guarantee your freedom to
++share and change all versions of a program--to make sure it remains free
++software for all its users.
++
++ When we speak of free software, we are referring to freedom, not
++price. Our General Public Licenses are designed to make sure that you
++have the freedom to distribute copies of free software (and charge for
++them if you wish), that you receive source code or can get it if you
++want it, that you can change the software or use pieces of it in new
++free programs, and that you know you can do these things.
++
++ Developers that use our General Public Licenses protect your rights
++with two steps: (1) assert copyright on the software, and (2) offer
++you this License which gives you legal permission to copy, distribute
++and/or modify the software.
++
++ A secondary benefit of defending all users' freedom is that
++improvements made in alternate versions of the program, if they
++receive widespread use, become available for other developers to
++incorporate. Many developers of free software are heartened and
++encouraged by the resulting cooperation. However, in the case of
++software used on network servers, this result may fail to come about.
++The GNU General Public License permits making a modified version and
++letting the public access it on a server without ever releasing its
++source code to the public.
++
++ The GNU Affero General Public License is designed specifically to
++ensure that, in such cases, the modified source code becomes available
++to the community. It requires the operator of a network server to
++provide the source code of the modified version running there to the
++users of that server. Therefore, public use of a modified version, on
++a publicly accessible server, gives the public access to the source
++code of the modified version.
++
++ An older license, called the Affero General Public License and
++published by Affero, was designed to accomplish similar goals. This is
++a different license, not a version of the Affero GPL, but Affero has
++released a new version of the Affero GPL which permits relicensing under
++this license.
++
++ The precise terms and conditions for copying, distribution and
++modification follow.
++
++ TERMS AND CONDITIONS
++
++ 0. Definitions.
++
++ "This License" refers to version 3 of the GNU Affero General Public License.
++
++ "Copyright" also means copyright-like laws that apply to other kinds of
++works, such as semiconductor masks.
++
++ "The Program" refers to any copyrightable work licensed under this
++License. Each licensee is addressed as "you". "Licensees" and
++"recipients" may be individuals or organizations.
++
++ To "modify" a work means to copy from or adapt all or part of the work
++in a fashion requiring copyright permission, other than the making of an
++exact copy. The resulting work is called a "modified version" of the
++earlier work or a work "based on" the earlier work.
++
++ A "covered work" means either the unmodified Program or a work based
++on the Program.
++
++ To "propagate" a work means to do anything with it that, without
++permission, would make you directly or secondarily liable for
++infringement under applicable copyright law, except executing it on a
++computer or modifying a private copy. Propagation includes copying,
++distribution (with or without modification), making available to the
++public, and in some countries other activities as well.
++
++ To "convey" a work means any kind of propagation that enables other
++parties to make or receive copies. Mere interaction with a user through
++a computer network, with no transfer of a copy, is not conveying.
++
++ An interactive user interface displays "Appropriate Legal Notices"
++to the extent that it includes a convenient and prominently visible
++feature that (1) displays an appropriate copyright notice, and (2)
++tells the user that there is no warranty for the work (except to the
++extent that warranties are provided), that licensees may convey the
++work under this License, and how to view a copy of this License. If
++the interface presents a list of user commands or options, such as a
++menu, a prominent item in the list meets this criterion.
++
++ 1. Source Code.
++
++ The "source code" for a work means the preferred form of the work
++for making modifications to it. "Object code" means any non-source
++form of a work.
++
++ A "Standard Interface" means an interface that either is an official
++standard defined by a recognized standards body, or, in the case of
++interfaces specified for a particular programming language, one that
++is widely used among developers working in that language.
++
++ The "System Libraries" of an executable work include anything, other
++than the work as a whole, that (a) is included in the normal form of
++packaging a Major Component, but which is not part of that Major
++Component, and (b) serves only to enable use of the work with that
++Major Component, or to implement a Standard Interface for which an
++implementation is available to the public in source code form. A
++"Major Component", in this context, means a major essential component
++(kernel, window system, and so on) of the specific operating system
++(if any) on which the executable work runs, or a compiler used to
++produce the work, or an object code interpreter used to run it.
++
++ The "Corresponding Source" for a work in object code form means all
++the source code needed to generate, install, and (for an executable
++work) run the object code and to modify the work, including scripts to
++control those activities. However, it does not include the work's
++System Libraries, or general-purpose tools or generally available free
++programs which are used unmodified in performing those activities but
++which are not part of the work. For example, Corresponding Source
++includes interface definition files associated with source files for
++the work, and the source code for shared libraries and dynamically
++linked subprograms that the work is specifically designed to require,
++such as by intimate data communication or control flow between those
++subprograms and other parts of the work.
++
++ The Corresponding Source need not include anything that users
++can regenerate automatically from other parts of the Corresponding
++Source.
++
++ The Corresponding Source for a work in source code form is that
++same work.
++
++ 2. Basic Permissions.
++
++ All rights granted under this License are granted for the term of
++copyright on the Program, and are irrevocable provided the stated
++conditions are met. This License explicitly affirms your unlimited
++permission to run the unmodified Program. The output from running a
++covered work is covered by this License only if the output, given its
++content, constitutes a covered work. This License acknowledges your
++rights of fair use or other equivalent, as provided by copyright law.
++
++ You may make, run and propagate covered works that you do not
++convey, without conditions so long as your license otherwise remains
++in force. You may convey covered works to others for the sole purpose
++of having them make modifications exclusively for you, or provide you
++with facilities for running those works, provided that you comply with
++the terms of this License in conveying all material for which you do
++not control copyright. Those thus making or running the covered works
++for you must do so exclusively on your behalf, under your direction
++and control, on terms that prohibit them from making any copies of
++your copyrighted material outside their relationship with you.
++
++ Conveying under any other circumstances is permitted solely under
++the conditions stated below. Sublicensing is not allowed; section 10
++makes it unnecessary.
++
++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
++
++ No covered work shall be deemed part of an effective technological
++measure under any applicable law fulfilling obligations under article
++11 of the WIPO copyright treaty adopted on 20 December 1996, or
++similar laws prohibiting or restricting circumvention of such
++measures.
++
++ When you convey a covered work, you waive any legal power to forbid
++circumvention of technological measures to the extent such circumvention
++is effected by exercising rights under this License with respect to
++the covered work, and you disclaim any intention to limit operation or
++modification of the work as a means of enforcing, against the work's
++users, your or third parties' legal rights to forbid circumvention of
++technological measures.
++
++ 4. Conveying Verbatim Copies.
++
++ You may convey verbatim copies of the Program's source code as you
++receive it, in any medium, provided that you conspicuously and
++appropriately publish on each copy an appropriate copyright notice;
++keep intact all notices stating that this License and any
++non-permissive terms added in accord with section 7 apply to the code;
++keep intact all notices of the absence of any warranty; and give all
++recipients a copy of this License along with the Program.
++
++ You may charge any price or no price for each copy that you convey,
++and you may offer support or warranty protection for a fee.
++
++ 5. Conveying Modified Source Versions.
++
++ You may convey a work based on the Program, or the modifications to
++produce it from the Program, in the form of source code under the
++terms of section 4, provided that you also meet all of these conditions:
++
++ a) The work must carry prominent notices stating that you modified
++ it, and giving a relevant date.
++
++ b) The work must carry prominent notices stating that it is
++ released under this License and any conditions added under section
++ 7. This requirement modifies the requirement in section 4 to
++ "keep intact all notices".
++
++ c) You must license the entire work, as a whole, under this
++ License to anyone who comes into possession of a copy. This
++ License will therefore apply, along with any applicable section 7
++ additional terms, to the whole of the work, and all its parts,
++ regardless of how they are packaged. This License gives no
++ permission to license the work in any other way, but it does not
++ invalidate such permission if you have separately received it.
++
++ d) If the work has interactive user interfaces, each must display
++ Appropriate Legal Notices; however, if the Program has interactive
++ interfaces that do not display Appropriate Legal Notices, your
++ work need not make them do so.
++
++ A compilation of a covered work with other separate and independent
++works, which are not by their nature extensions of the covered work,
++and which are not combined with it such as to form a larger program,
++in or on a volume of a storage or distribution medium, is called an
++"aggregate" if the compilation and its resulting copyright are not
++used to limit the access or legal rights of the compilation's users
++beyond what the individual works permit. Inclusion of a covered work
++in an aggregate does not cause this License to apply to the other
++parts of the aggregate.
++
++ 6. Conveying Non-Source Forms.
++
++ You may convey a covered work in object code form under the terms
++of sections 4 and 5, provided that you also convey the
++machine-readable Corresponding Source under the terms of this License,
++in one of these ways:
++
++ a) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by the
++ Corresponding Source fixed on a durable physical medium
++ customarily used for software interchange.
++
++ b) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by a
++ written offer, valid for at least three years and valid for as
++ long as you offer spare parts or customer support for that product
++ model, to give anyone who possesses the object code either (1) a
++ copy of the Corresponding Source for all the software in the
++ product that is covered by this License, on a durable physical
++ medium customarily used for software interchange, for a price no
++ more than your reasonable cost of physically performing this
++ conveying of source, or (2) access to copy the
++ Corresponding Source from a network server at no charge.
++
++ c) Convey individual copies of the object code with a copy of the
++ written offer to provide the Corresponding Source. This
++ alternative is allowed only occasionally and noncommercially, and
++ only if you received the object code with such an offer, in accord
++ with subsection 6b.
++
++ d) Convey the object code by offering access from a designated
++ place (gratis or for a charge), and offer equivalent access to the
++ Corresponding Source in the same way through the same place at no
++ further charge. You need not require recipients to copy the
++ Corresponding Source along with the object code. If the place to
++ copy the object code is a network server, the Corresponding Source
++ may be on a different server (operated by you or a third party)
++ that supports equivalent copying facilities, provided you maintain
++ clear directions next to the object code saying where to find the
++ Corresponding Source. Regardless of what server hosts the
++ Corresponding Source, you remain obligated to ensure that it is
++ available for as long as needed to satisfy these requirements.
++
++ e) Convey the object code using peer-to-peer transmission, provided
++ you inform other peers where the object code and Corresponding
++ Source of the work are being offered to the general public at no
++ charge under subsection 6d.
++
++ A separable portion of the object code, whose source code is excluded
++from the Corresponding Source as a System Library, need not be
++included in conveying the object code work.
++
++ A "User Product" is either (1) a "consumer product", which means any
++tangible personal property which is normally used for personal, family,
++or household purposes, or (2) anything designed or sold for incorporation
++into a dwelling. In determining whether a product is a consumer product,
++doubtful cases shall be resolved in favor of coverage. For a particular
++product received by a particular user, "normally used" refers to a
++typical or common use of that class of product, regardless of the status
++of the particular user or of the way in which the particular user
++actually uses, or expects or is expected to use, the product. A product
++is a consumer product regardless of whether the product has substantial
++commercial, industrial or non-consumer uses, unless such uses represent
++the only significant mode of use of the product.
++
++ "Installation Information" for a User Product means any methods,
++procedures, authorization keys, or other information required to install
++and execute modified versions of a covered work in that User Product from
++a modified version of its Corresponding Source. The information must
++suffice to ensure that the continued functioning of the modified object
++code is in no case prevented or interfered with solely because
++modification has been made.
++
++ If you convey an object code work under this section in, or with, or
++specifically for use in, a User Product, and the conveying occurs as
++part of a transaction in which the right of possession and use of the
++User Product is transferred to the recipient in perpetuity or for a
++fixed term (regardless of how the transaction is characterized), the
++Corresponding Source conveyed under this section must be accompanied
++by the Installation Information. But this requirement does not apply
++if neither you nor any third party retains the ability to install
++modified object code on the User Product (for example, the work has
++been installed in ROM).
++
++ The requirement to provide Installation Information does not include a
++requirement to continue to provide support service, warranty, or updates
++for a work that has been modified or installed by the recipient, or for
++the User Product in which it has been modified or installed. Access to a
++network may be denied when the modification itself materially and
++adversely affects the operation of the network or violates the rules and
++protocols for communication across the network.
++
++ Corresponding Source conveyed, and Installation Information provided,
++in accord with this section must be in a format that is publicly
++documented (and with an implementation available to the public in
++source code form), and must require no special password or key for
++unpacking, reading or copying.
++
++ 7. Additional Terms.
++
++ "Additional permissions" are terms that supplement the terms of this
++License by making exceptions from one or more of its conditions.
++Additional permissions that are applicable to the entire Program shall
++be treated as though they were included in this License, to the extent
++that they are valid under applicable law. If additional permissions
++apply only to part of the Program, that part may be used separately
++under those permissions, but the entire Program remains governed by
++this License without regard to the additional permissions.
++
++ When you convey a copy of a covered work, you may at your option
++remove any additional permissions from that copy, or from any part of
++it. (Additional permissions may be written to require their own
++removal in certain cases when you modify the work.) You may place
++additional permissions on material, added by you to a covered work,
++for which you have or can give appropriate copyright permission.
++
++ Notwithstanding any other provision of this License, for material you
++add to a covered work, you may (if authorized by the copyright holders of
++that material) supplement the terms of this License with terms:
++
++ a) Disclaiming warranty or limiting liability differently from the
++ terms of sections 15 and 16 of this License; or
++
++ b) Requiring preservation of specified reasonable legal notices or
++ author attributions in that material or in the Appropriate Legal
++ Notices displayed by works containing it; or
++
++ c) Prohibiting misrepresentation of the origin of that material, or
++ requiring that modified versions of such material be marked in
++ reasonable ways as different from the original version; or
++
++ d) Limiting the use for publicity purposes of names of licensors or
++ authors of the material; or
++
++ e) Declining to grant rights under trademark law for use of some
++ trade names, trademarks, or service marks; or
++
++ f) Requiring indemnification of licensors and authors of that
++ material by anyone who conveys the material (or modified versions of
++ it) with contractual assumptions of liability to the recipient, for
++ any liability that these contractual assumptions directly impose on
++ those licensors and authors.
++
++ All other non-permissive additional terms are considered "further
++restrictions" within the meaning of section 10. If the Program as you
++received it, or any part of it, contains a notice stating that it is
++governed by this License along with a term that is a further
++restriction, you may remove that term. If a license document contains
++a further restriction but permits relicensing or conveying under this
++License, you may add to a covered work material governed by the terms
++of that license document, provided that the further restriction does
++not survive such relicensing or conveying.
++
++ If you add terms to a covered work in accord with this section, you
++must place, in the relevant source files, a statement of the
++additional terms that apply to those files, or a notice indicating
++where to find the applicable terms.
++
++ Additional terms, permissive or non-permissive, may be stated in the
++form of a separately written license, or stated as exceptions;
++the above requirements apply either way.
++
++ 8. Termination.
++
++ You may not propagate or modify a covered work except as expressly
++provided under this License. Any attempt otherwise to propagate or
++modify it is void, and will automatically terminate your rights under
++this License (including any patent licenses granted under the third
++paragraph of section 11).
++
++ However, if you cease all violation of this License, then your
++license from a particular copyright holder is reinstated (a)
++provisionally, unless and until the copyright holder explicitly and
++finally terminates your license, and (b) permanently, if the copyright
++holder fails to notify you of the violation by some reasonable means
++prior to 60 days after the cessation.
++
++ Moreover, your license from a particular copyright holder is
++reinstated permanently if the copyright holder notifies you of the
++violation by some reasonable means, this is the first time you have
++received notice of violation of this License (for any work) from that
++copyright holder, and you cure the violation prior to 30 days after
++your receipt of the notice.
++
++ Termination of your rights under this section does not terminate the
++licenses of parties who have received copies or rights from you under
++this License. If your rights have been terminated and not permanently
++reinstated, you do not qualify to receive new licenses for the same
++material under section 10.
++
++ 9. Acceptance Not Required for Having Copies.
++
++ You are not required to accept this License in order to receive or
++run a copy of the Program. Ancillary propagation of a covered work
++occurring solely as a consequence of using peer-to-peer transmission
++to receive a copy likewise does not require acceptance. However,
++nothing other than this License grants you permission to propagate or
++modify any covered work. These actions infringe copyright if you do
++not accept this License. Therefore, by modifying or propagating a
++covered work, you indicate your acceptance of this License to do so.
++
++ 10. Automatic Licensing of Downstream Recipients.
++
++ Each time you convey a covered work, the recipient automatically
++receives a license from the original licensors, to run, modify and
++propagate that work, subject to this License. You are not responsible
++for enforcing compliance by third parties with this License.
++
++ An "entity transaction" is a transaction transferring control of an
++organization, or substantially all assets of one, or subdividing an
++organization, or merging organizations. If propagation of a covered
++work results from an entity transaction, each party to that
++transaction who receives a copy of the work also receives whatever
++licenses to the work the party's predecessor in interest had or could
++give under the previous paragraph, plus a right to possession of the
++Corresponding Source of the work from the predecessor in interest, if
++the predecessor has it or can get it with reasonable efforts.
++
++ You may not impose any further restrictions on the exercise of the
++rights granted or affirmed under this License. For example, you may
++not impose a license fee, royalty, or other charge for exercise of
++rights granted under this License, and you may not initiate litigation
++(including a cross-claim or counterclaim in a lawsuit) alleging that
++any patent claim is infringed by making, using, selling, offering for
++sale, or importing the Program or any portion of it.
++
++ 11. Patents.
++
++ A "contributor" is a copyright holder who authorizes use under this
++License of the Program or a work on which the Program is based. The
++work thus licensed is called the contributor's "contributor version".
++
++ A contributor's "essential patent claims" are all patent claims
++owned or controlled by the contributor, whether already acquired or
++hereafter acquired, that would be infringed by some manner, permitted
++by this License, of making, using, or selling its contributor version,
++but do not include claims that would be infringed only as a
++consequence of further modification of the contributor version. For
++purposes of this definition, "control" includes the right to grant
++patent sublicenses in a manner consistent with the requirements of
++this License.
++
++ Each contributor grants you a non-exclusive, worldwide, royalty-free
++patent license under the contributor's essential patent claims, to
++make, use, sell, offer for sale, import and otherwise run, modify and
++propagate the contents of its contributor version.
++
++ In the following three paragraphs, a "patent license" is any express
++agreement or commitment, however denominated, not to enforce a patent
++(such as an express permission to practice a patent or covenant not to
++sue for patent infringement). To "grant" such a patent license to a
++party means to make such an agreement or commitment not to enforce a
++patent against the party.
++
++ If you convey a covered work, knowingly relying on a patent license,
++and the Corresponding Source of the work is not available for anyone
++to copy, free of charge and under the terms of this License, through a
++publicly available network server or other readily accessible means,
++then you must either (1) cause the Corresponding Source to be so
++available, or (2) arrange to deprive yourself of the benefit of the
++patent license for this particular work, or (3) arrange, in a manner
++consistent with the requirements of this License, to extend the patent
++license to downstream recipients. "Knowingly relying" means you have
++actual knowledge that, but for the patent license, your conveying the
++covered work in a country, or your recipient's use of the covered work
++in a country, would infringe one or more identifiable patents in that
++country that you have reason to believe are valid.
++
++ If, pursuant to or in connection with a single transaction or
++arrangement, you convey, or propagate by procuring conveyance of, a
++covered work, and grant a patent license to some of the parties
++receiving the covered work authorizing them to use, propagate, modify
++or convey a specific copy of the covered work, then the patent license
++you grant is automatically extended to all recipients of the covered
++work and works based on it.
++
++ A patent license is "discriminatory" if it does not include within
++the scope of its coverage, prohibits the exercise of, or is
++conditioned on the non-exercise of one or more of the rights that are
++specifically granted under this License. You may not convey a covered
++work if you are a party to an arrangement with a third party that is
++in the business of distributing software, under which you make payment
++to the third party based on the extent of your activity of conveying
++the work, and under which the third party grants, to any of the
++parties who would receive the covered work from you, a discriminatory
++patent license (a) in connection with copies of the covered work
++conveyed by you (or copies made from those copies), or (b) primarily
++for and in connection with specific products or compilations that
++contain the covered work, unless you entered into that arrangement,
++or that patent license was granted, prior to 28 March 2007.
++
++ Nothing in this License shall be construed as excluding or limiting
++any implied license or other defenses to infringement that may
++otherwise be available to you under applicable patent law.
++
++ 12. No Surrender of Others' Freedom.
++
++ If conditions are imposed on you (whether by court order, agreement or
++otherwise) that contradict the conditions of this License, they do not
++excuse you from the conditions of this License. If you cannot convey a
++covered work so as to satisfy simultaneously your obligations under this
++License and any other pertinent obligations, then as a consequence you may
++not convey it at all. For example, if you agree to terms that obligate you
++to collect a royalty for further conveying from those to whom you convey
++the Program, the only way you could satisfy both those terms and this
++License would be to refrain entirely from conveying the Program.
++
++ 13. Remote Network Interaction; Use with the GNU General Public License.
++
++ Notwithstanding any other provision of this License, if you modify the
++Program, your modified version must prominently offer all users
++interacting with it remotely through a computer network (if your version
++supports such interaction) an opportunity to receive the Corresponding
++Source of your version by providing access to the Corresponding Source
++from a network server at no charge, through some standard or customary
++means of facilitating copying of software. This Corresponding Source
++shall include the Corresponding Source for any work covered by version 3
++of the GNU General Public License that is incorporated pursuant to the
++following paragraph.
++
++ Notwithstanding any other provision of this License, you have
++permission to link or combine any covered work with a work licensed
++under version 3 of the GNU General Public License into a single
++combined work, and to convey the resulting work. The terms of this
++License will continue to apply to the part which is the covered work,
++but the work with which it is combined will remain governed by version
++3 of the GNU General Public License.
++
++ 14. Revised Versions of this License.
++
++ The Free Software Foundation may publish revised and/or new versions of
++the GNU Affero General Public License from time to time. Such new versions
++will be similar in spirit to the present version, but may differ in detail to
++address new problems or concerns.
++
++ Each version is given a distinguishing version number. If the
++Program specifies that a certain numbered version of the GNU Affero General
++Public License "or any later version" applies to it, you have the
++option of following the terms and conditions either of that numbered
++version or of any later version published by the Free Software
++Foundation. If the Program does not specify a version number of the
++GNU Affero General Public License, you may choose any version ever published
++by the Free Software Foundation.
++
++ If the Program specifies that a proxy can decide which future
++versions of the GNU Affero General Public License can be used, that proxy's
++public statement of acceptance of a version permanently authorizes you
++to choose that version for the Program.
++
++ Later license versions may give you additional or different
++permissions. However, no additional obligations are imposed on any
++author or copyright holder as a result of your choosing to follow a
++later version.
++
++ 15. Disclaimer of Warranty.
++
++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
++
++ 16. Limitation of Liability.
++
++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
++SUCH DAMAGES.
++
++ 17. Interpretation of Sections 15 and 16.
++
++ If the disclaimer of warranty and limitation of liability provided
++above cannot be given local legal effect according to their terms,
++reviewing courts shall apply local law that most closely approximates
++an absolute waiver of all civil liability in connection with the
++Program, unless a warranty or assumption of liability accompanies a
++copy of the Program in return for a fee.
++
++ END OF TERMS AND CONDITIONS
++
++ How to Apply These Terms to Your New Programs
++
++ If you develop a new program, and you want it to be of the greatest
++possible use to the public, the best way to achieve this is to make it
++free software which everyone can redistribute and change under these terms.
++
++ To do so, attach the following notices to the program. It is safest
++to attach them to the start of each source file to most effectively
++state the exclusion of warranty; and each file should have at least
++the "copyright" line and a pointer to where the full notice is found.
++
++ <one line to give the program's name and a brief idea of what it does.>
++ Copyright (C) <year> <name of author>
++
++ This program is free software: you can redistribute it and/or modify
++ it under the terms of the GNU Affero General Public License as published
++ by the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ This program is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU Affero General Public License for more details.
++
++ You should have received a copy of the GNU Affero General Public License
++ along with this program. If not, see <https://www.gnu.org/licenses/>.
++
++Also add information on how to contact you by electronic and paper mail.
++
++ If your software can interact with users remotely through a computer
++network, you should also make sure that it provides a way for users to
++get its source. For example, if your program is a web application, its
++interface could display a "Source" link that leads users to an archive
++of the code. There are many ways you could offer source, and different
++solutions will be better for different programs; see section 13 for the
++specific requirements.
++
++ You should also get your employer (if you work as a programmer) or school,
++if any, to sign a "copyright disclaimer" for the program, if necessary.
++For more information on this, and how to apply and follow the GNU AGPL, see
++<https://www.gnu.org/licenses/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..bd2565d
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,295 @@
++# OpenVibe.Space
++
++**https://openvibe.space — the forums of OpenVibe.**
++
++## 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
++
++## 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.
++
++## 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.108.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0, pinned by release tarball
++
++## How it fits the network
++
++| 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`):
++
++| Capability | What it covers |
++| --- | --- |
++| `space.forum.read` | list the spaces the caller can open, one space's metadata, `GET /s` |
++| `space.forum.manage` | staff administration of spaces (create, rename, visibility, members-only) |
++| `space.thread.read` | a space's threads and one thread with its posts, the feeds |
++| `space.thread.write` | thread lifecycle (planned: today `space.post.write` carries it) |
++| `space.post.write` | create threads and replies, vote, edit or delete one's own posts |
++| `space.pulse.read` | the Pulse activity Space shows beside its forums (planned; Community serves the feed today) |
++
++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.
++
++## 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`
++
++## 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)
++```
++
++## 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/`.
++
++## 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.
++
++## 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`.
++
++## 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)
++```
++
++## 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**: OpenVibe.Contracts still marks `space.forum.manage`, `space.thread.write` and
++ `space.pulse.read` planned, and has no `space.thread.created` / `space.post.created` /
++ `space.moderation.action` event payloads — Space already emits those types (the outbox is off
++ without `EVENTS_URL`), so Events will refuse them until the contracts land.
++- **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).
++
++## 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://events.openvibe.network) — the events fabric
++
++## License
++
++AGPL-3.0-only. See [LICENSE](LICENSE).
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..49b94e7
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,21 @@
++# Security policy
++
++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
++
++## Reporting a vulnerability
++
++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
++a vulnerability, and do not test against other people's accounts, data or streams.
++
++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
++
++## Scope
++
++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,
++openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable
++contact details are at `/.well-known/security.txt` on each site.
++
++## Supported versions
++
++Only the current `main` branch, which is what runs in production, receives fixes.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..4573902
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,46 @@
++{
++ "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",
++ "port": 4940,
++ "domain": "openvibe.space",
++ "contracts": "openvibe-contracts v0.108.0",
++ "sdk": "openvibe-sdk v0.34.0",
++ "shared": "openvibe-shared v2.11.0",
++ "capabilities": [
++ "space.forum.read",
++ "space.forum.manage",
++ "space.thread.read",
++ "space.thread.write",
++ "space.post.write",
++ "space.pulse.read"
++ ],
++ "capabilitiesRegistered": true,
++ "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.",
++ "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/write, 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 process boots on a fresh database and stops cleanly (test/boot.test.js)"
++ ],
++ "knownGaps": [
++ "space.forum.manage, space.thread.write and space.pulse.read are still 'planned' in openvibe-contracts; Space uses space.forum.manage for moderation and space.post.write for threads today",
++ "OpenVibe.Contracts has no space.thread.created / space.post.created / space.moderation.action event payloads; the outbox stays off without EVENTS_URL",
++ "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)"
++ ],
++ "updated": "2026-10-07"
++}
ddiff --git a/deploy/nginx/openvibe.space.conf b/deploy/nginx/openvibe.space.conf
nnew file mode 100644
iindex 0000000..5f6c0a6
---- /dev/null
++++ b/deploy/nginx/openvibe.space.conf
@@@ -0,0 +1,156 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.space — Nginx Configuration (REFERENCE)
++##
++## 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.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovspace_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovspace_auth:10m rate=10r/m;
++
++# ── HTTP → HTTPS redirect ──────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.space www.openvibe.space;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.space$request_uri; }
++}
++
++# ── www → apex ─────────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.space;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.space/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.space/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.space$request_uri;
++}
++
++# ── HTTPS server ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.space;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.space/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.space/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ 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;
++ add_header X-Frame-Options SAMEORIGIN always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ # Compress text for the hop to Cloudflare as well (the global config only compresses
++ # text/html and never proxied responses).
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml application/rss+xml;
++
++ # ── OAuth session endpoints (strict rate limit) ─────────
++ # The session probe: the shared navbar asks it on every page view, so it cannot share the sign-in
++ # budget below (10 a minute): a visitor reading quickly would be answered 503 and look signed out.
++ location = /auth/me {
++ limit_req zone=ovspace_api burst=30 nodelay;
++ limit_req_status 429;
++ 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;
++ }
++
++ location /auth/ {
++ limit_req zone=ovspace_auth burst=10 nodelay;
++ limit_req_status 429;
++ 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;
++ }
++
++ # ── 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) ───────────────────────────
++ location /api/ {
++ limit_req zone=ovspace_api burst=30 nodelay;
++ 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;
++ }
++
++ # ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
++ location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
++ 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;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4940/metrics). Node also
++ # refuses any request carrying X-Forwarded-For, which every location above sets. ──
++ location = /metrics { return 404; }
++
++ # ── Everything else (server-rendered pages) ─────────────
++ location / {
++ 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;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-space.service b/deploy/systemd/openvibe-space.service
nnew file mode 100644
iindex 0000000..b8073de
---- /dev/null
++++ b/deploy/systemd/openvibe-space.service
@@@ -0,0 +1,28 @@
++[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
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.space
++EnvironmentFile=/etc/openvibe/space.env
++Environment=NODE_ENV=production
++Environment=PORT=4940
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.space/server/index.js
++Restart=always
++RestartSec=5
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# The code tree stays read-only. Space's database is PostgreSQL (ov_space, ADR-035).
++StateDirectory=openvibe-space
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/discord-relay.md b/docs/discord-relay.md
nnew file mode 100644
iindex 0000000..6aa2ea2
---- /dev/null
++++ b/docs/discord-relay.md
@@@ -0,0 +1,164 @@
++# 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.
ddiff --git a/docs/roadmap/public.json b/docs/roadmap/public.json
nnew file mode 100644
iindex 0000000..172b82d
---- /dev/null
++++ b/docs/roadmap/public.json
@@@ -0,0 +1,41 @@
++{
++ "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." }
++ ]
++}
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..1dde9f5
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,357 @@
++-- phase: expand
++-- OpenVibe.Space on PostgreSQL (ADR-035): the forum engine moved off OpenVibe.Community (plan T10 step 3), its
++-- tables as Community's migrations/0001_initial.sql + 0005_space_moderators.sql defined them — text COLLATE "C"
++-- compares like SQLite, integers are bigint, identities keep their ids. Never edited after it runs.
++--
++-- Space owns what a forum owns: space groups, spaces, categories, threads, their posts and versions, thread
++-- votes, attachments (Media objects), post reactions, a space's chat room, per-space moderators, the Discord
++-- relay's mappings/deliveries/message map/cursors/failures, the block projection and the account-data
++-- bookkeeping, plus the openvibe-sdk inbox and outbox.
++--
++-- Not here, because Space is not their authority: pastes (Community), typed comment threads (Community),
++-- Pulse (Community), submissions (Community), search documents (OpenVibe.Search) and game progress (Games).
++-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).
++CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$
++BEGIN
++ IF t IS NULL THEN RETURN NULL; END IF;
++ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;
++ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;
++ RETURN t::timestamp;
++EXCEPTION WHEN others THEN RETURN NULL;
++END $$;
++CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;
++CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
++CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
++DECLARE ts timestamp := ov_ts(t);
++BEGIN
++ IF ts IS NULL THEN RETURN NULL; END IF;
++ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;
++ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');
++EXCEPTION WHEN others THEN RETURN NULL;
++END $$;
++CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;
++-- The forum's hot rank (server/forum/store.js): (score + 1) / (age in hours + 2)^1.5, the Hacker News gravity.
++CREATE FUNCTION ov_hot(score double precision, age_hours double precision) RETURNS double precision LANGUAGE sql IMMUTABLE AS $$ SELECT (COALESCE(score, 0) + 1) / power(GREATEST(COALESCE(age_hours, 0), 0) + 2, 1.5) $$;
++
++-- Display cache of what OpenVibe.Network says about a subject (server/identity/network.js). Never authority.
++CREATE TABLE subject_projection (
++ subject_id text COLLATE "C" PRIMARY KEY,
++ username text COLLATE "C",
++ display_name text COLLATE "C",
++ avatar_url text COLLATE "C",
++ profile_color text COLLATE "C",
++ refreshed_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++-- A pre-subject Network numeric id → the subject it became (forums imported from Community, once the data
++-- cutover runs): server/identity/network.js resolves through it.
++CREATE TABLE legacy_id_map (
++ source_system text COLLATE "C" NOT NULL,
++ source_type text COLLATE "C" NOT NULL,
++ source_id text COLLATE "C" NOT NULL,
++ target_type text COLLATE "C" NOT NULL,
++ target_id text COLLATE "C" NOT NULL,
++ PRIMARY KEY (source_system, source_type, source_id)
++);
++
++CREATE TABLE space_groups (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ slug text COLLATE "C" UNIQUE NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ description text COLLATE "C",
++ position bigint NOT NULL DEFAULT 0
++);
++
++CREATE TABLE spaces (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ slug text COLLATE "C" UNIQUE NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ description text COLLATE "C",
++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),
++ created_by text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ members_only_owner text COLLATE "C",
++ thread_kind text COLLATE "C" NOT NULL DEFAULT 'discussion',
++ style text COLLATE "C" NOT NULL DEFAULT 'feed',
++ votes bigint NOT NULL DEFAULT 1,
++ reactions bigint NOT NULL DEFAULT 1,
++ group_id bigint REFERENCES space_groups(id) ON DELETE SET NULL,
++ parent_id bigint REFERENCES spaces(id) ON DELETE SET NULL,
++ position bigint NOT NULL DEFAULT 0
++);
++
++CREATE TABLE categories (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ slug text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ description text COLLATE "C",
++ position bigint NOT NULL DEFAULT 0,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ UNIQUE (space_id, slug)
++);
++
++CREATE TABLE threads (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ slug text COLLATE "C" NOT NULL,
++ title text COLLATE "C" NOT NULL,
++ author_subject text COLLATE "C",
++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
++ pinned bigint NOT NULL DEFAULT 0,
++ locked bigint NOT NULL DEFAULT 0,
++ score bigint NOT NULL DEFAULT 0,
++ reply_count bigint NOT NULL DEFAULT 0,
++ last_activity_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ deleted_at text COLLATE "C",
++ members_only_owner text COLLATE "C",
++ kind text COLLATE "C" NOT NULL DEFAULT 'discussion',
++ status text COLLATE "C",
++ category_id bigint REFERENCES categories(id) ON DELETE SET NULL,
++ external_key text COLLATE "C",
++ views bigint NOT NULL DEFAULT 0,
++ crosspost_of bigint REFERENCES threads(id) ON DELETE SET NULL,
++ UNIQUE (space_id, slug)
++);
++
++CREATE TABLE posts (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ author_subject text COLLATE "C",
++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
++ is_opening bigint NOT NULL DEFAULT 0,
++ body_markdown text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL DEFAULT 1,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ deleted_at text COLLATE "C",
++ relay_author text COLLATE "C"
++);
++
++CREATE TABLE post_versions (
++ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
++ revision bigint NOT NULL,
++ body_markdown text COLLATE "C" NOT NULL,
++ edited_by text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (post_id, revision)
++);
++
++CREATE TABLE thread_votes (
++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ subject_id text COLLATE "C" NOT NULL,
++ value bigint NOT NULL CHECK(value IN (-1, 1)),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (thread_id, subject_id)
++);
++
++-- Images on posts: OpenVibe.Media objects (med_ ids), claimed by the post that names them.
++CREATE TABLE attachments (
++ media_id text COLLATE "C" PRIMARY KEY,
++ owner_subject text COLLATE "C" NOT NULL,
++ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
++ position bigint NOT NULL DEFAULT 0,
++ filename text COLLATE "C",
++ mime text COLLATE "C" NOT NULL,
++ size_bytes bigint NOT NULL,
++ url text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++CREATE TABLE post_reactions (
++ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
++ subject_id text COLLATE "C" NOT NULL,
++ reaction text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (post_id, subject_id)
++);
++
++-- A space's chat room on OpenVibe.Chat (server/chat-rooms.js); Chat owns the room itself.
++CREATE TABLE space_chat_rooms (
++ space_id bigint PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
++ room_id text COLLATE "C",
++ room_slug text COLLATE "C" NOT NULL,
++ room_name text COLLATE "C" NOT NULL,
++ room_kind text COLLATE "C" NOT NULL DEFAULT 'community',
++ room_visibility text COLLATE "C" NOT NULL DEFAULT 'public',
++ attached_by text COLLATE "C",
++ attached_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++-- Per-space moderators (moved from Community's migrations/0005_space_moderators.sql): a person listed here
++-- moderates that space (thread state and status, deletes, settings, categories, members-only, its chat room)
++-- as discussion staff do everywhere; the board index and creating spaces stay with staff. People are Network
++-- subjects (usr_…), never a local integer; account deletion erases a row and a subject merge moves it
++-- (server/identity/account-data.js, subject-merge.js).
++CREATE TABLE space_moderators (
++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ subject_id text COLLATE "C" NOT NULL,
++ added_by text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (space_id, subject_id)
++);
++
++CREATE TABLE relay_mappings (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
++ direction text COLLATE "C" NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),
++ webhook_url_ref text COLLATE "C" NOT NULL,
++ enabled bigint NOT NULL DEFAULT 1,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ discord_channel_id text COLLATE "C",
++ discord_thread_id text COLLATE "C",
++ inbound bigint NOT NULL DEFAULT 0,
++ UNIQUE (space_id, direction, webhook_url_ref)
++);
++
++CREATE TABLE relay_deliveries (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
++ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
++ action text COLLATE "C" NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),
++ dedupe_key text COLLATE "C" UNIQUE NOT NULL,
++ source text COLLATE "C" NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),
++ event_id text COLLATE "C",
++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),
++ attempts bigint NOT NULL DEFAULT 0,
++ next_attempt_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ last_status bigint,
++ last_error text COLLATE "C",
++ delivered_at text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++CREATE TABLE relay_message_map (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ platform text COLLATE "C" NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),
++ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
++ direction text COLLATE "C" NOT NULL CHECK(direction IN ('out', 'in')),
++ local_type text COLLATE "C" NOT NULL CHECK(local_type IN ('thread', 'post')),
++ local_id bigint NOT NULL,
++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
++ external_channel_id text COLLATE "C" NOT NULL,
++ external_thread_id text COLLATE "C",
++ external_message_id text COLLATE "C" NOT NULL,
++ external_webhook_id text COLLATE "C",
++ external_deleted_at text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ UNIQUE (platform, external_message_id),
++ UNIQUE (platform, mapping_id, local_type, local_id)
++);
++
++CREATE TABLE relay_cursors (
++ name text COLLATE "C" PRIMARY KEY,
++ cursor bigint NOT NULL,
++ latest_seq bigint,
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++CREATE TABLE relay_inbound_failures (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ mapping_id bigint REFERENCES relay_mappings(id) ON DELETE SET NULL,
++ event text COLLATE "C" NOT NULL,
++ external_channel_id text COLLATE "C",
++ external_message_id text COLLATE "C",
++ external_author_id text COLLATE "C",
++ error text COLLATE "C" NOT NULL,
++ dismissed_at text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++
++-- platform blocks (WS-E task 5) projected from network.block.changed; the newest revision per pair wins.
++CREATE TABLE network_blocks (
++ blocker_subject text COLLATE "C" NOT NULL,
++ blocked_subject text COLLATE "C" NOT NULL,
++ active bigint NOT NULL,
++ revision bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (blocker_subject, blocked_subject)
++);
++
++-- account export/deletion bookkeeping (server/identity/account-data.js): one row per export or deletion,
++-- applied once, answered after Network took the part or the confirmation.
++CREATE TABLE account_data_events (
++ id text COLLATE "C" PRIMARY KEY,
++ kind text COLLATE "C" NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ outcome text COLLATE "C",
++ sent_at text COLLATE "C",
++ applied_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++CREATE INDEX idx_subject_projection_username ON subject_projection(lower(username));
++CREATE INDEX idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;
++CREATE INDEX idx_threads_author ON threads(author_subject, created_at DESC);
++CREATE INDEX idx_posts_thread ON posts(thread_id, id);
++CREATE INDEX idx_posts_author ON posts(author_subject, created_at DESC);
++CREATE INDEX idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
++CREATE INDEX idx_post_reactions_post ON post_reactions(post_id, reaction);
++CREATE INDEX idx_space_moderators_subject ON space_moderators (subject_id);
++CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);
++CREATE INDEX idx_relay_message_map_thread ON relay_message_map(thread_id);
++CREATE UNIQUE INDEX idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;
++CREATE INDEX idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;
++CREATE INDEX idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id);
++CREATE INDEX idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL;
++CREATE INDEX idx_network_blocks_blocked ON network_blocks(blocked_subject, active);
++
++-- The board the forum opens with: the same groups, spaces and categories Community seeded (the Roadmap
++-- space follows docs/roadmap/public.json — server/forum/roadmap.js).
++INSERT INTO space_groups (id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES
++ (1, 'openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),
++ (2, 'community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);
++SELECT setval(pg_get_serial_sequence('space_groups', 'id'), (SELECT MAX(id) FROM space_groups));
++INSERT INTO spaces (id, slug, name, description, visibility, created_by, members_only_owner, thread_kind, style, votes, reactions, group_id, parent_id, position) OVERRIDING SYSTEM VALUE VALUES
++ (1, 'general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 1),
++ (2, 'feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.', 'public', 'system', NULL, 'request', 'feed', 1, 1, 1, NULL, 3),
++ (3, 'showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system', NULL, 'discussion', 'feed', 1, 1, 2, NULL, 1),
++ (4, 'roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', NULL, 'roadmap', 'feed', 1, 1, 1, NULL, 4),
++ (5, 'help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 2),
++ (6, 'off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 2, NULL, 2);
++SELECT setval(pg_get_serial_sequence('spaces', 'id'), (SELECT MAX(id) FROM spaces));
++INSERT INTO categories (id, space_id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES
++ (1, 2, 'ideas', 'Ideas', 'Something new, or something better.', 1),
++ (2, 2, 'bugs', 'Bugs', 'Something is broken or wrong.', 2),
++ (3, 2, 'questions', 'Questions', 'How do I…? Why does…?', 3),
++ (4, 4, 'launches', 'New sites', 'Sites that open next.', 1),
++ (5, 4, 'features', 'Features', 'New things on sites that are already open.', 2),
++ (6, 4, 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3);
++SELECT setval(pg_get_serial_sequence('categories', 'id'), (SELECT MAX(id) FROM categories));
++
++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled (server/events-consumer.js)
++CREATE TABLE IF NOT EXISTS space_event_inbox (
++ consumer text NOT NULL,
++ event_id text NOT NULL,
++ processed_at bigint NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++);
++
++-- openvibe-sdk/auth createPgRevocationStore: Network's per-person token cutoffs (revocationSchema('token_revocations'))
++CREATE TABLE IF NOT EXISTS token_revocations (
++ subject_id text COLLATE "C" PRIMARY KEY,
++ valid_after_ms bigint NOT NULL,
++ reason text,
++ updated_at bigint NOT NULL
++);
++
++-- openvibe-sdk/events PostgreSQL outbox (server/events.js): Space → OpenVibe.Events
++CREATE TABLE IF NOT EXISTS event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..699fa65
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1458 @@
++{
++ "name": "openvibe-space",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-space",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.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.108.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "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",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "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",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "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",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "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",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.defaults": {
++ "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"
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "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",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.108.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
++ "integrity": "sha512-T07n7B482/XIdSD3ikehPGHFUTWsyGaeLCF2SerWsKD1V09s1RYs7TD9+Y2z7vdxYjSYhQPMHr1r2xP3F9xIbA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.34.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
++ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "2.11.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
++ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "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",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..4ab4565
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,44 @@
++{
++ "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.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "test:pg": "SPACE_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe",
++ "space",
++ "forum",
++ "forums",
++ "boards",
++ "community"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Space",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.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.108.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/public/assets/logo-192.png b/public/assets/logo-192.png
nnew file mode 100644
iindex 0000000..2d63be4
BBinary files /dev/null and b/public/assets/logo-192.png differ
ddiff --git a/public/assets/logo-512.png b/public/assets/logo-512.png
nnew file mode 100644
iindex 0000000..5a18ae3
BBinary files /dev/null and b/public/assets/logo-512.png differ
ddiff --git a/public/assets/logo-72.png b/public/assets/logo-72.png
nnew file mode 100644
iindex 0000000..c74235d
BBinary files /dev/null and b/public/assets/logo-72.png differ
ddiff --git a/public/assets/logo-maskable-192.png b/public/assets/logo-maskable-192.png
nnew file mode 100644
iindex 0000000..4feb6a5
BBinary files /dev/null and b/public/assets/logo-maskable-192.png differ
ddiff --git a/public/assets/logo-maskable-512.png b/public/assets/logo-maskable-512.png
nnew file mode 100644
iindex 0000000..1b6f809
BBinary files /dev/null and b/public/assets/logo-maskable-512.png differ
ddiff --git a/public/assets/logo.svg b/public/assets/logo.svg
nnew file mode 100644
iindex 0000000..2dea82f
---- /dev/null
++++ b/public/assets/logo.svg
@@@ -0,0 +1,8 @@
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#60a5fa"/><stop offset="1" stop-color="#2563eb"/></linearGradient>
++<radialGradient id="glow"><stop offset="0" stop-color="#60a5fa" stop-opacity=".34"/><stop offset="1" stop-color="#60a5fa" stop-opacity="0"/></radialGradient>
++<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="#0a0f1c"/><stop offset="1" stop-color="#060912"/></radialGradient></defs><rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/><g transform="scale(1.22)">
++<circle r="21" fill="url(#glow)"/>
++<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>
++<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>
++<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>
++<circle cx="17.4" cy="4.6" r="3.2" fill="#ffffff"/></g></svg>
\\ No newline at end of file
ddiff --git a/public/css/space.css b/public/css/space.css
nnew file mode 100644
iindex 0000000..d9421e1
---- /dev/null
++++ b/public/css/space.css
@@@ -0,0 +1,262 @@
++/* 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; }
++
++/* 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; }
++
++/* Errors + footer host */
++.error { text-align: center; padding: 60px 0; }
++.error .btn { margin: 4px; }
++.ov-footer { margin-top: 24px; }
++
++@media (max-width: 640px) {
++ .page { padding: 16px 16px 40px; }
++ .thread-row { flex-wrap: wrap; }
++ .thread-head { gap: 10px; }
++}
++
++/* ── 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%; }
++}
++
++/* ── 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); }
++
++/* Crossposts */
++.crosspost { margin: 8px 0; }
++.crosspost-note { margin: 4px 0; }
++
++/* 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; }
ddiff --git a/public/js/space.js b/public/js/space.js
nnew file mode 100644
iindex 0000000..f48bf50
---- /dev/null
++++ b/public/js/space.js
@@@ -0,0 +1,33 @@
++/* 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 */ });
++ });
++})();
ddiff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
nnew file mode 100644
iindex 0000000..b517322
---- /dev/null
++++ b/public/manifest.webmanifest
@@@ -0,0 +1,48 @@
++{
++ "name": "OpenVibe.Space",
++ "short_name": "Space",
++ "description": "The forums of the OpenVibe network: spaces, threads, replies and votes",
++ "start_url": "/s",
++ "scope": "/",
++ "display": "standalone",
++ "background_color": "#0a0f1c",
++ "theme_color": "#3b82f6",
++ "icons": [
++ {
++ "src": "/assets/logo-72.png",
++ "sizes": "72x72",
++ "type": "image/png",
++ "purpose": "any"
++ },
++ {
++ "src": "/assets/logo-192.png",
++ "sizes": "192x192",
++ "type": "image/png",
++ "purpose": "any"
++ },
++ {
++ "src": "/assets/logo-512.png",
++ "sizes": "512x512",
++ "type": "image/png",
++ "purpose": "any"
++ },
++ {
++ "src": "/assets/logo-maskable-192.png",
++ "sizes": "192x192",
++ "type": "image/png",
++ "purpose": "maskable"
++ },
++ {
++ "src": "/assets/logo-maskable-512.png",
++ "sizes": "512x512",
++ "type": "image/png",
++ "purpose": "maskable"
++ },
++ {
++ "src": "/assets/logo.svg",
++ "sizes": "any",
++ "type": "image/svg+xml",
++ "purpose": "any"
++ }
++ ]
++}
ddiff --git a/public/og-default.png b/public/og-default.png
nnew file mode 100644
iindex 0000000..9181148
BBinary files /dev/null and b/public/og-default.png differ
ddiff --git a/server/actor-limits.js b/server/actor-limits.js
nnew file mode 100644
iindex 0000000..ed332f4
---- /dev/null
++++ b/server/actor-limits.js
@@@ -0,0 +1,80 @@
++'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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..176ce54
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,243 @@
++'use strict';
++
++/**
++ * OpenVibe.Space — the forums of OpenVibe.
++ *
++ * 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)
++ *
++ * 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.
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++
++const config = require('./config');
++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
++// shared or browser cache keeps one.
++const PAGE_CACHE = cache.htmlHeaders({ private: true });
++
++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, '..') });
++ require('./render/layout').setRelease(release.release);
++ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
++ // direct loopback callers only (Track O). Request metrics only: no content counts.
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'space', release: release.release });
++ app.locals.metrics = metrics.registry;
++
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // Shared chrome (theme-loader, navbar, footer, history, ov-mark) comes from the Network.
++ // Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may
++ // measure performance; script-src loads the beacon, connect-src is where it reports.
++ 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.media', 'https://openvibe.chat', 'https://openvibe.vip', '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'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ scriptSrcAttr: ["'unsafe-inline'"],
++ },
++ },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'cross-origin' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Auth (OAuth2 client of OpenVibe.Network) ─────────────
++ const auth = opts.auth || createAuthClient(config);
++ app.locals.auth = auth;
++ app.locals.config = config;
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
++ 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 ─────
++ 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.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 });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Static assets (content-hashed ?v= → immutable) ───────
++ // This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, etag: true, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ },
++ }));
++
++ // ── Machine endpoints ────────────────────────────────────
++ // Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
++ 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() })));
++ 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) => {
++ let rows;
++ 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 }));
++
++ // ── 404 / errors ─────────────────────────────────────────
++ app.use((req, res) => {
++ if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
++ const pages = require('./render/pages');
++ res.status(404).type('html').set('Cache-Control', PAGE_CACHE).send(pages.errorPage({ status: 404, title: 'Page not found', message: 'Nothing lives at that address.' }));
++ });
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ console.error('[App]', err && err.stack ? err.stack : err);
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) return res.status(500).json({ error: 'Internal error' });
++ const pages = require('./render/pages');
++ res.status(500).type('html').set('Cache-Control', PAGE_CACHE).send(pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }));
++ });
++
++ return app;
++}
++
++/** Async route wrapper — rejections reach the error handler. */
++function wrap(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); }
++
++module.exports = { createApp };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
nnew file mode 100644
iindex 0000000..e0d7b0f
---- /dev/null
++++ b/server/auth/routes.js
@@@ -0,0 +1,437 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.space — OAuth2 CLIENT session layer
++//
++// The identity provider is OpenVibe.Network (https://openvibe.network).
++// This site is registered there as OAuth client `space`, redirect
++// https://openvibe.space/auth/callback. Same shape as the Tools gateway.
++//
++// GET /auth/login → redirect to Network /oauth/authorize
++// ?silent=1 adds prompt=none (one quiet round trip; a
++// signed-out browser comes straight back with
++// error=login_required and lands on next + ?sso=none)
++// ?next=… same-site path or an https://openvibe.network/…
++// URL (sign-in-everywhere chains)
++// A silent login whose ov_token already verifies skips the
++// Network round trip and 302s straight to next.
++// GET /auth/callback → server-side code exchange, set cookies
++// POST /auth/fedcm → browser-native FedCM: the navbar posts the Network's
++// assertion JWT + the nonce it minted; we check the nonce,
++// swap the assertion for tokens (jwt-bearer grant), set the
++// same cookies as the callback
++// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
++// GET /auth/me → offline-verify ov_token, return profile ({ user: null } for a guest)
++// POST /auth/refresh → rotate tokens via refresh_token grant
++//
++// Cookies (all host-only — openvibe.space has no subdomains):
++// ov_token — access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
++// ov_refresh — opaque refresh token, httpOnly, Path=/auth
++// ov_sso_hint — 'account' after a sign-in, 'guest' after a sign-out; 1 year,
++// JS-readable. navbar.js only attempts a silent login when it says
++// 'account', so signed-out visitors never get bounced.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const crypto = require('crypto');
++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const cache = require('openvibe-shared/cache-policy');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++/**
++ * Create the auth client + JWKS fetcher shared by the whole app.
++ * Verification is OFFLINE: we cache the Network's RS256 public key from
++ * GET /api/.well-known/jwks and verify JWTs locally on every request.
++ */
++function createAuthClient(config) {
++ const client = new OpenVibeAuthClient({
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ publicKey: null, // filled by ensureKey()
++ authBase: config.networkUrl,
++ internalBase: config.networkInternalUrl,
++ });
++
++ let lastFetch = 0;
++ let inflight = null;
++ async function ensureKey() {
++ if (client.publicKey) return client.publicKey;
++ // A request that arrives while the key is being fetched waits for that fetch rather than
++ // being treated as signed out.
++ if (inflight) return inflight;
++ // Don't hammer the Network if it's down — retry at most every 30s
++ if (Date.now() - lastFetch < 30_000) return null;
++ lastFetch = Date.now();
++ inflight = fetchKey().finally(() => { inflight = null; });
++ return inflight;
++ }
++ async function fetchKey() {
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) continue;
++ const jwks = await res.json();
++ if (jwks.public_key) {
++ client.publicKey = jwks.public_key;
++ console.log(`[Auth] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
++ return client.publicKey;
++ }
++ } catch (err) {
++ console.warn(`[Auth] JWKS fetch failed from ${base}: ${err.message}`);
++ }
++ }
++ return null;
++ }
++
++ /** Offline JWT verification. Returns decoded claims or null. */
++ async function verify(token) {
++ if (!token) return null;
++ await ensureKey();
++ return client.verifyToken(token);
++ }
++
++ // Warm the key cache at boot (non-fatal if the Network is down)
++ ensureKey().catch(() => {});
++
++ return { client, ensureKey, verify };
++}
++
++/** Token from Authorization header or the ov_token cookie. */
++function extractToken(req) {
++ const h = req.headers.authorization;
++ if (h && h.startsWith('Bearer ')) return h.slice(7);
++ return req.cookies?.[ACCESS_COOKIE] || null;
++}
++
++/** Strip registered claims — what the site shows as "the user". */
++function claimsToUser(claims) {
++ if (!claims) return null;
++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
++ return user;
++}
++
++/**
++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
++ * we cross-check locally (the FedCM nonce) before the Network — which does verify
++ * the signature — sees the assertion. Never use it to trust an identity.
++ */
++function decodeJwtPayload(token) {
++ if (typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
++ } catch { return null; }
++}
++
++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const claims = decodeJwtPayload(token);
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/**
++ * Only allow same-site relative paths, this site's own https origin, or an
++ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
++ */
++function sanitizeNext(next, config) {
++ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
++ // leave the site. A next with any control character or backslash goes home.
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.networkUrl]
++ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
++ .filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
++function withParam(target, key, value) {
++ const hashAt = target.indexOf('#');
++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
++ const sep = base.includes('?') ? '&' : '?';
++ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++/**
++ * Build the Network authorize URL for a login. Exported (and pure) so tests can
++ * cover the silent-login shape without a browser.
++ */
++function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
++ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
++ if (!silent) return { url, state };
++ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
++ // network has no session either, it bounces back with error=login_required and we stay quiet.
++ const u = new URL(url);
++ u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state };
++}
++
++function createAuthRoutes(config, auth) {
++ const router = express.Router();
++
++ const accessCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
++ path: '/',
++ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
++ });
++
++ const refreshCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: true,
++ path: '/auth', // only ever sent back to this session layer
++ maxAge: 30 * 24 * 60 * 60 * 1000,
++ });
++
++ const flowCookieOpts = () => ({
++ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
++ path: '/auth', maxAge: 10 * 60 * 1000,
++ });
++
++ const hintCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
++ path: '/',
++ maxAge: 365 * 24 * 60 * 60 * 1000,
++ });
++
++ function setSessionCookies(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
++ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
++ }
++
++ function clearSessionCookies(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
++ }
++
++ function clearFlowCookies(res) {
++ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });
++ }
++
++ /** Exchange at the Network — internal URL first, public as fallback. */
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetch(`${base}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ client_id: config.oauth.clientId,
++ client_secret: config.oauth.clientSecret,
++ ...body,
++ }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
++ err.status = res.status;
++ err.error = data.error || 'invalid_grant';
++ err.error_description = data.error_description || null;
++ throw err;
++ }
++ return data;
++ } catch (err) {
++ lastErr = err;
++ // 4xx = the grant itself is bad; retrying against another base won't help
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ // ── GET /auth/login ──────────────────────────────────────
++ router.get('/login', async (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ if (silent) {
++ // Already signed in here? Then the silent round trip through the Network would
++ // only hand back the session we have: go straight to where the caller wanted.
++ const existing = req.cookies?.[ACCESS_COOKIE];
++ if (existing && await auth.verify(existing)) {
++ clearFlowCookies(res);
++ return res.redirect(sanitizeNext(req.query.next, config));
++ }
++ }
++ const { url, state } = buildAuthorizeUrl(config, auth, { silent });
++ res.cookie(STATE_COOKIE, state, flowCookieOpts());
++ const next = sanitizeNext(req.query.next, config);
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
++ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
++ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(url);
++ });
++
++ // ── GET /auth/callback ───────────────────────────────────
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
++ const silent = req.cookies?.[SILENT_COOKIE] === '1';
++
++ if (error) {
++ clearFlowCookies(res);
++ // Silent sign-in found no network session — go back quietly as a guest. The
++ // ?sso=none marker stops navbar.js from trying again on that page.
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).send('Missing authorization code');
++
++ const expectedState = req.cookies?.[STATE_COOKIE];
++ clearFlowCookies(res);
++ // The state cookie is required, not just compared when present: skipping the check when
++ // the cookie is missing would let a crafted link sign a visitor into someone else's account.
++ if (!expectedState || !state || !crypto.timingSafeEqual(
++ Buffer.from(String(state).padEnd(64).slice(0, 64)),
++ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
++ )) {
++ return res.status(400).send('OAuth state mismatch — please try signing in again.');
++ }
++
++ try {
++ const data = await tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: config.oauth.redirectUri,
++ code,
++ });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ console.error('[Auth] Code exchange failed:', err.message);
++ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ // ── POST /auth/fedcm ─────────────────────────────────────
++ // The shared navbar obtained a FedCM assertion from openvibe.network (the
++ // browser's own account chooser) and posts it same-origin with the nonce it
++ // put in the FedCM request. We only pre-check the nonce; the Network verifies
++ // the assertion's signature when it swaps it for tokens.
++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
++ }
++ if (!fedcmNonceMatches(token, nonce)) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
++ }
++ try {
++ const data = await tokenGrant({
++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
++ assertion: token,
++ });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ const user = data.user || claimsToUser(await auth.verify(data.access_token));
++ return res.json({ ok: true, user });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
++ }
++ console.error('[Auth] FedCM exchange failed:', err.message);
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ // ── GET /auth/logout ─────────────────────────────────────
++ router.get('/logout', async (req, res) => {
++ // Best-effort refresh revocation. The Network's rotating refresh tokens
++ // self-invalidate, so a failure here is harmless.
++ const refresh = req.cookies?.[REFRESH_COOKIE];
++ if (refresh) {
++ try {
++ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ client_id: config.oauth.clientId,
++ client_secret: config.oauth.clientSecret,
++ token: refresh,
++ }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSessionCookies(res);
++ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ // ── GET /auth/me ─────────────────────────────────────────
++ router.get('/me', async (req, res) => {
++ const token = extractToken(req);
++ // No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
++ // page view, and a 401 logged a console error on each (browser check, OpenVibe.Host). A credential
++ // that is present but invalid or expired still answers 401.
++ if (!token) return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: null });
++ const claims = await auth.verify(token);
++ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
++ });
++
++ // ── POST /auth/refresh ───────────────────────────────────
++ // Small endpoint the shared navbar can call when ov_token expires.
++ router.post('/refresh', async (req, res) => {
++ const refresh = req.cookies?.[REFRESH_COOKIE];
++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ const claims = await auth.verify(data.access_token);
++ return res.json({ token: data.access_token, user: claimsToUser(claims) });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ clearSessionCookies(res);
++ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
++ }
++ console.error('[Auth] Refresh failed:', err.message);
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
++function optionalAuth(auth) {
++ return async (req, _res, next) => {
++ const token = extractToken(req);
++ if (token) {
++ const claims = await auth.verify(token);
++ if (claims) { req.user = claimsToUser(claims); req.token = token; }
++ }
++ next();
++ };
++}
++
++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
ddiff --git a/server/chat-rooms.js b/server/chat-rooms.js
nnew file mode 100644
iindex 0000000..6abc990
---- /dev/null
++++ b/server/chat-rooms.js
@@@ -0,0 +1,89 @@
++'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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..e469f53
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++require('dotenv').config();
++
++const isProduction = (process.env.NODE_ENV || 'development') === 'production';
++
++const port = parseInt(process.env.PORT, 10) || 4940;
++
++module.exports = {
++ port,
++ host: process.env.HOST || '127.0.0.1',
++ nodeEnv: process.env.NODE_ENV || 'development',
++ isProduction,
++
++ // Public URL of this site — canonical links, OG tags, sitemap entries.
++ baseUrl: (process.env.BASE_URL || (isProduction ? 'https://openvibe.space' : `http://localhost:${port}`)).replace(/\/$/, ''),
++
++ // 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(/\/$/, ''),
++
++ // OAuth2 client credentials (client `space` registered in the Network's oauth_clients table)
++ oauth: {
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'space',
++ clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: process.env.OV_OAUTH_REDIRECT_URI
++ || (isProduction ? 'https://openvibe.space/auth/callback' : 'http://localhost:4940/auth/callback'),
++ scope: 'profile theme',
++ },
++
++ // Cookies are host-only for openvibe.space (no Domain attribute — there are no subdomains).
++ cookies: {
++ secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
++ },
++
++ // OpenVibe.Community — the hub (D12). Space's pages link the network Pulse feed it serves; Space
++ // stores none of it (space.pulse.read is planned, not built).
++ 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.
++ 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.
++ 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,
++ },
++};
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..7cda995
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,65 @@
++'use strict';
++
++/**
++ * 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.
++ *
++ * 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.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++/**
++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
++ */
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh space)');
++ const dir = process.env.SPACE_PGLITE_DIR || DEV_PGLITE; // tests that boot the real server give it a directory of its own
++ log.warn(`[Space] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'space', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'space-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'space', registry, log });
++}
++
++let _shared = null;
++/** Open the process-wide database once, at boot (server/index.js). */
++async function initDb(config, opts) {
++ if (!_shared) _shared = await openDb(config, opts);
++ return _shared;
++}
++/** The process-wide database initDb() opened. */
++function getDb() {
++ if (!_shared) throw new Error('the database is not open: await initDb(config) at boot');
++ return _shared;
++}
++/** Tests: use this handle as the process-wide database. */
++function setDb(db) { _shared = db; }
++
++/** Graceful stop: close the process-wide database. */
++async function closeDb() {
++ const db = _shared;
++ _shared = null;
++ if (db) await db.close().catch(() => {});
++}
++
++module.exports = { openDb, initDb, getDb, setDb, closeDb, MIGRATIONS };
ddiff --git a/server/discovery.js b/server/discovery.js
nnew file mode 100644
iindex 0000000..e9cb43a
---- /dev/null
++++ b/server/discovery.js
@@@ -0,0 +1,86 @@
++'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).
++ */
++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(); }
++
++// ── 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: '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;
++}
++
++// ── 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. */
++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;
++}
++
++// ── 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 };
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
nnew file mode 100644
iindex 0000000..6246a0a
---- /dev/null
++++ b/server/events-consumer.js
@@@ -0,0 +1,152 @@
++'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 };
ddiff --git a/server/events.js b/server/events.js
nnew file mode 100644
iindex 0000000..52989b6
---- /dev/null
++++ b/server/events.js
@@@ -0,0 +1,125 @@
++'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 must publish the space.thread.created / space.post.created / space.moderation.action
++ * payload contracts before this producer is switched on: Events refuses an event type it has no contract
++ * for, and both this and the Discord relay's Events worker are off without EVENTS_URL.
++ */
++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 };
ddiff --git a/server/forum/api.js b/server/forum/api.js
nnew file mode 100644
iindex 0000000..fa5a188
---- /dev/null
++++ b/server/forum/api.js
@@@ -0,0 +1,145 @@
++'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 };
ddiff --git a/server/forum/reactions.js b/server/forum/reactions.js
nnew file mode 100644
iindex 0000000..bae87e4
---- /dev/null
++++ b/server/forum/reactions.js
@@@ -0,0 +1,76 @@
++'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 };
ddiff --git a/server/forum/roadmap.js b/server/forum/roadmap.js
nnew file mode 100644
iindex 0000000..1ecfbd1
---- /dev/null
++++ b/server/forum/roadmap.js
@@@ -0,0 +1,58 @@
++'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 };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
nnew file mode 100644
iindex 0000000..d00bf0b
---- /dev/null
++++ b/server/forum/routes.js
@@@ -0,0 +1,319 @@
++'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 };
ddiff --git a/server/forum/service.js b/server/forum/service.js
nnew file mode 100644
iindex 0000000..684d5fd
---- /dev/null
++++ b/server/forum/service.js
@@@ -0,0 +1,994 @@
++'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 };
ddiff --git a/server/forum/store.js b/server/forum/store.js
nnew file mode 100644
iindex 0000000..fdda28a
---- /dev/null
++++ b/server/forum/store.js
@@@ -0,0 +1,330 @@
++'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,
++};
ddiff --git a/server/http/v1.js b/server/http/v1.js
nnew file mode 100644
iindex 0000000..241b925
---- /dev/null
++++ b/server/http/v1.js
@@@ -0,0 +1,111 @@
++'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 };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..839192e
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,154 @@
++'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 };
ddiff --git a/server/identity/authors.js b/server/identity/authors.js
nnew file mode 100644
iindex 0000000..8cb0171
---- /dev/null
++++ b/server/identity/authors.js
@@@ -0,0 +1,44 @@
++'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 };
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
nnew file mode 100644
iindex 0000000..ae8ede7
---- /dev/null
++++ b/server/identity/blocks.js
@@@ -0,0 +1,53 @@
++'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 };
ddiff --git a/server/identity/capabilities.js b/server/identity/capabilities.js
nnew file mode 100644
iindex 0000000..9b6bfeb
---- /dev/null
++++ b/server/identity/capabilities.js
@@@ -0,0 +1,38 @@
++'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 };
ddiff --git a/server/identity/network.js b/server/identity/network.js
nnew file mode 100644
iindex 0000000..077106b
---- /dev/null
++++ b/server/identity/network.js
@@@ -0,0 +1,179 @@
++'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 };
ddiff --git a/server/identity/projection.js b/server/identity/projection.js
nnew file mode 100644
iindex 0000000..1ae3561
---- /dev/null
++++ b/server/identity/projection.js
@@@ -0,0 +1,55 @@
++'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 };
ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
nnew file mode 100644
iindex 0000000..b39b7a5
---- /dev/null
++++ b/server/identity/subject-merge.js
@@@ -0,0 +1,70 @@
++'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 };
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
nnew file mode 100644
iindex 0000000..2d87b87
---- /dev/null
++++ b/server/identity/viewer.js
@@@ -0,0 +1,155 @@
++'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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..42d28be
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,71 @@
++'use strict';
++
++/**
++ * OpenVibe.Space — process entry. `node server/index.js`
++ * Listens on PORT (4940) behind nginx; see deploy/ for the unit and vhost.
++ */
++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.
++ 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}`);
++ });
++ 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');
++ gracefulStop({
++ name: 'Space', server,
++ drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
++ stop: [
++ () => { if (subscriptions) subscriptions.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(() => {});
++ return await require('./db').closeDb();
++ },
++ ],
++ });
++})().catch((err) => {
++ console.error('[Space] failed to start:', err);
++ process.exit(1);
++});
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..e099ce8
---- /dev/null
++++ b/server/limits.js
@@@ -0,0 +1,56 @@
++'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 };
ddiff --git a/server/media/objects.js b/server/media/objects.js
nnew file mode 100644
iindex 0000000..4ae2ca9
---- /dev/null
++++ b/server/media/objects.js
@@@ -0,0 +1,67 @@
++'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 };
ddiff --git a/server/media/strip-metadata.js b/server/media/strip-metadata.js
nnew file mode 100644
iindex 0000000..577fe97
---- /dev/null
++++ b/server/media/strip-metadata.js
@@@ -0,0 +1,85 @@
++'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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..053579c
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * 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)
++ *
++ * Request metrics come from openvibe-shared/metrics in app.js. Content counts (spaces, threads,
++ * posts) are deliberately not metrics.
++ */
++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 }) {
++ const checks = [
++ {
++ name: 'db', required: true,
++ // A real round trip that names the store (postgresql / pglite), and a migrated schema.
++ check: async () => {
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = current_schema()').get()).n;
++ 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';
++ },
++ },
++ ];
++ 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;
++ },
++ });
++}
++
++module.exports = { createSpaceReadiness };
ddiff --git a/server/relay/api.js b/server/relay/api.js
nnew file mode 100644
iindex 0000000..6089b38
---- /dev/null
++++ b/server/relay/api.js
@@@ -0,0 +1,103 @@
++'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 };
ddiff --git a/server/relay/discord-gateway.js b/server/relay/discord-gateway.js
nnew file mode 100644
iindex 0000000..103990c
---- /dev/null
++++ b/server/relay/discord-gateway.js
@@@ -0,0 +1,242 @@
++'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 };
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
nnew file mode 100644
iindex 0000000..f6d2e14
---- /dev/null
++++ b/server/relay/discord.js
@@@ -0,0 +1,517 @@
++'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 };
ddiff --git a/server/relay/events-worker.js b/server/relay/events-worker.js
nnew file mode 100644
iindex 0000000..67a560f
---- /dev/null
++++ b/server/relay/events-worker.js
@@@ -0,0 +1,164 @@
++'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 };
ddiff --git a/server/relay/inbound.js b/server/relay/inbound.js
nnew file mode 100644
iindex 0000000..30d2714
---- /dev/null
++++ b/server/relay/inbound.js
@@@ -0,0 +1,199 @@
++'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 };
ddiff --git a/server/render/board.js b/server/render/board.js
nnew file mode 100644
iindex 0000000..2c972aa
---- /dev/null
++++ b/server/render/board.js
@@@ -0,0 +1,286 @@
++'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\`, &gt; 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 };
ddiff --git a/server/render/forum.js b/server/render/forum.js
nnew file mode 100644
iindex 0000000..d16f44a
---- /dev/null
++++ b/server/render/forum.js
@@@ -0,0 +1,350 @@
++'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://…), &gt; 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,
++};
ddiff --git a/server/render/highlight.js b/server/render/highlight.js
nnew file mode 100644
iindex 0000000..3f8de6e
---- /dev/null
++++ b/server/render/highlight.js
@@@ -0,0 +1,51 @@
++'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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/render/jsonld.js b/server/render/jsonld.js
nnew file mode 100644
iindex 0000000..fde1ec7
---- /dev/null
++++ b/server/render/jsonld.js
@@@ -0,0 +1,77 @@
++'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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..008fed8
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,159 @@
++'use strict';
++
++/**
++ * 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.
++ */
++const crypto = require('crypto');
++const ovServe = require('openvibe-shared/serve');
++const shell = require('openvibe-shared/shell');
++const appIcon = require('openvibe-shared/app-icon');
++const fs = require('fs');
++const path = require('path');
++const config = require('../config');
++const { escapeHtml } = require('./highlight');
++
++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_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
++// every deploy (the same scheme Live uses in server/web/assets.js).
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const _hashes = new Map();
++function assetVersion(rel) {
++ if (_hashes.has(rel)) return _hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('md5').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* */ }
++ if (config.isProduction) _hashes.set(rel, v);
++ return v;
++}
++function asset(rel) { return `/${rel}?v=${assetVersion(rel)}`; }
++
++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
++// between pages of the same release, and does a normal load across a deploy.
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
++
++// openvibe-shared/seo cuts descriptions at 160 characters on a word; a longer one loses whole
++// trailing sentences here first, so a cut never leaves half a phrase ("… Free…").
++const DESCRIPTION_MAX = 160;
++function fitDescription(text) {
++ if (text.length <= DESCRIPTION_MAX) return text;
++ let out = '';
++ for (const sentence of text.split(/(?<=[.!?])\s+/)) {
++ const next = out ? `${out} ${sentence}` : sentence;
++ if (next.length > DESCRIPTION_MAX) break;
++ out = next;
++ }
++ return out || text;
++}
++
++function navbarInit(opts) {
++ const cfg = {
++ service: 'space',
++ apiBase: NETWORK_URL,
++ 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 },
++ ],
++ menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
++ history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
++ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
++ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
++ };
++ return cfg;
++}
++
++function footerInit(opts) {
++ return {
++ service: 'space',
++ variant: opts.footerVariant || 'full',
++ 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.',
++ 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: 'OpenVibe.Community', url: config.communityUrl },
++ { name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Space' },
++ ],
++ }],
++ };
++}
++
++/**
++ * @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']),
++ * published/modified (ISO, for article:*), noFrame (error pages during outages)
++ */
++function renderPage(o) {
++ const description = fitDescription((o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300));
++ 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' }];
++
++ // 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`,
++ 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 })),
++ footer: { service: 'space', variant: 'full', updates: `${config.communityUrl}/updates` },
++ head: [
++ o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
++ o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
++ appIcon.headTags({ site: 'space', iconBase: '/assets' }),
++ ...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
++ ...(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'),
++ bodyClass: o.bodyClass,
++ bodyAttributes: { 'data-page': o.active || 'page' },
++ body: `<div id="navbar-mount"></div>
++<main id="main" class="page">
++${o.body || ''}
++</main>
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer: foot }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the SSR footer stays */ }
++});
++</script>`,
++ });
++ // The default share image is a small card: only a page's own image gets the large Twitter card.
++ 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 };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..5813f5a
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,226 @@
++'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 };
ddiff --git a/server/render/pages.js b/server/render/pages.js
nnew file mode 100644
iindex 0000000..f9e5258
---- /dev/null
++++ b/server/render/pages.js
@@@ -0,0 +1,73 @@
++'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).
++ *
++ * 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.
++ */
++const config = require('../config');
++const ld = require('./jsonld');
++const { renderPage } = require('./layout');
++const { escapeHtml: esc } = require('./highlight');
++
++// ── 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>`;
++}
++
++// ── Errors ───────────────────────────────────────────────────
++function errorPage({ status = 500, title = 'Something went wrong', message = '', links = true }) {
++ const body = `
++<section class="error">
++ <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>` : ''}
++</section>`;
++ return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
++}
++
++module.exports = { errorPage, timeAgo, timeTag, fmtDate, num, authorName, authorHtml, avatarUrl };
ddiff --git a/server/vip/index.js b/server/vip/index.js
nnew file mode 100644
iindex 0000000..6271c85
---- /dev/null
++++ b/server/vip/index.js
@@@ -0,0 +1,67 @@
++'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 };
ddiff --git a/server/vip/vip-client.js b/server/vip/vip-client.js
nnew file mode 100644
iindex 0000000..e8cd83a
---- /dev/null
++++ b/server/vip/vip-client.js
@@@ -0,0 +1,4 @@
++'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');
ddiff --git a/server/votes.js b/server/votes.js
nnew file mode 100644
iindex 0000000..cfcd248
---- /dev/null
++++ b/server/votes.js
@@@ -0,0 +1,55 @@
++'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 };
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
nnew file mode 100644
iindex 0000000..a5791cd
---- /dev/null
++++ b/test/attachments.test.js
@@@ -0,0 +1,147 @@
++'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);
++})();
ddiff --git a/test/boot.test.js b/test/boot.test.js
nnew file mode 100644
iindex 0000000..584c198
---- /dev/null
++++ b/test/boot.test.js
@@@ -0,0 +1,117 @@
++'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).
++ * Nothing here is mocked: this is the entry point the unit would run.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const net = require('net');
++const os = require('os');
++const path = require('path');
++const { spawn } = require('child_process');
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, "\n ", (e.stack || String(e)).split("\n").slice(0, process.env.DEBUG ? 40 : 4).join("\n ")); }
++}
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-boot-'));
++ const port = await freePort();
++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ cwd: path.join(__dirname, '..'),
++ env: {
++ ...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: '',
++ BASE_URL: `http://127.0.0.1:${port}`,
++ EVENTS_URL: '', SPACE_EVENTS_SECRET: '',
++ },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let out = '';
++ child.stdout.on('data', (d) => { out += d; });
++ child.stderr.on('data', (d) => { out += d; });
++ const exited = new Promise((r) => child.on('exit', (code, signal) => r({ code, signal })));
++ const base = `http://127.0.0.1:${port}`;
++
++ try {
++ let up = false;
++ for (let i = 0; i < 200 && !up; i++) {
++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
++ if (!up) await sleep(100);
++ }
++ assert.ok(up, `the server did not start:\n${out}`);
++ assert.match(out, /\[Space\] test on http:\/\/127\.0\.0\.1:\d+ → http:\/\/127\.0\.0\.1:\d+/, 'the process announces itself');
++
++ await check('a fresh database: migrations ran, /api/ready is 2xx and names the store', async () => {
++ const r = await fetch(`${base}/api/ready`);
++ const body = await r.json();
++ assert.ok(r.status >= 200 && r.status < 300, `ready answered ${r.status}: ${JSON.stringify(body)}`);
++ assert.strictEqual(body.service, 'space');
++ // The database is the required check; without a Network to reach, the optional JWKS check
++ // degrades the report (server/observability.js) — the process is still serving pages.
++ assert.ok(['ok', 'degraded'].includes(body.status), `status ${body.status}`);
++ assert.strictEqual(body.checks.db.status, 'ok');
++ 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`);
++ 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"/);
++ });
++
++ 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('crawler endpoints answer: robots.txt, llms.txt, sitemap.xml', async () => {
++ const robots = await fetch(`${base}/robots.txt`);
++ assert.strictEqual(robots.status, 200);
++ assert.match(await robots.text(), /Sitemap:/);
++ const llms = await fetch(`${base}/llms.txt`);
++ assert.strictEqual(llms.status, 200);
++ 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>/);
++ });
++
++ await check('SIGTERM stops it cleanly (exit 0, within the manifest’s 5 s)', async () => {
++ child.kill('SIGTERM');
++ 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');
++ });
++ } finally {
++ if (child.exitCode === null) child.kill('SIGKILL');
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++
++ console.log(failures ? `\n${failures} failed` : '\nall passed');
++ process.exit(failures ? 1 : 0);
++})();
ddiff --git a/test/categories-roadmap.test.js b/test/categories-roadmap.test.js
nnew file mode 100644
iindex 0000000..d205a6a
---- /dev/null
++++ b/test/categories-roadmap.test.js
@@@ -0,0 +1,138 @@
++'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);
++})();
ddiff --git a/test/chat-room.test.js b/test/chat-room.test.js
nnew file mode 100644
iindex 0000000..fa4d577
---- /dev/null
++++ b/test/chat-room.test.js
@@@ -0,0 +1,188 @@
++'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();
++})();
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
nnew file mode 100644
iindex 0000000..28e7c1d
---- /dev/null
++++ b/test/forum-ssr.test.js
@@@ -0,0 +1,200 @@
++'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, '&lt;img src=x onerror=alert(1)&gt;</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 &lt;b&gt;embed&lt;/b&gt; 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, '&lt;script&gt;alert(1)&lt;/script&gt;');
++ 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&amp;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&amp;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 &lt;b&gt;embed&lt;/b&gt; 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();
++})();
ddiff --git a/test/forum-style.test.js b/test/forum-style.test.js
nnew file mode 100644
iindex 0000000..2d86485
---- /dev/null
++++ b/test/forum-style.test.js
@@@ -0,0 +1,130 @@
++'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&gt; 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);
++})();
ddiff --git a/test/forum.test.js b/test/forum.test.js
nnew file mode 100644
iindex 0000000..3b3412c
---- /dev/null
++++ b/test/forum.test.js
@@@ -0,0 +1,210 @@
++'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> &lt;script&gt;alert(1)&lt;/script&gt;</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();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..6f3c993
---- /dev/null
++++ b/test/helpers/app.js
@@@ -0,0 +1,75 @@
++'use strict';
++/** Boots the mocks, points the app at them, 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, …).
++ * 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';
++ process.env.OV_OAUTH_CLIENT_SECRET = 'shh';
++ 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');
++ const appOpts = { ...(opts.appOpts || {}) };
++ appOpts.db = appOpts.db || await require('./db').testDb();
++ const app = await createApp(appOpts);
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const jar = new Map();
++ async function get(path, opts = {}) {
++ const headers = { ...(opts.headers || {}) };
++ const cookies = [...jar.entries()].map(([k, v]) => `${k}=${v}`).concat(opts.cookies || []);
++ if (cookies.length) headers.cookie = cookies.join('; ');
++ const res = await fetch(base + path, { method: opts.method || 'GET', headers, body: opts.body, redirect: 'manual', duplex: opts.body && typeof opts.body.pipe === 'function' ? 'half' : undefined });
++ for (const sc of res.headers.getSetCookie ? res.headers.getSetCookie() : []) {
++ const [pair, ...attrs] = sc.split(';');
++ const [k, v] = pair.split('=');
++ const expired = attrs.some((a) => /max-age=0|expires=thu, 01 jan 1970/i.test(a.trim()));
++ if (expired) jar.delete(k.trim()); else jar.set(k.trim(), v);
++ }
++ const text = await res.text();
++ 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,
++ 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 appOpts.db.close().catch(() => {});
++ },
++ };
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(" ✗", name, "\n ", (e.stack || String(e)).split("\n").slice(0, process.env.DEBUG ? 40 : 4).join("\n ")); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..2f97a8e
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,20 @@
++'use strict';
++/**
++ * A migrated database for one test (ADR-035), from openvibe-sdk/testing: PGlite by default; with SPACE_TEST_STORE=pg
++ * (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own. The handle's close()
++ * also drops the test database.
++ */
++const { createTestDb } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++async function testDb({ store = process.env.SPACE_TEST_STORE || 'pglite', max = 4 } = {}) {
++ const t = await createTestDb({ migrations: MIGRATIONS, store, service: 'space', max });
++ // db.close() runs the test database's own close (PGlite: closes it; the containers: also drops its roles and
++ // schema), once; that close calls the handle's original close, which is put back first.
++ const own = t.db.close;
++ let closing = null;
++ t.db.close = () => { if (!closing) { t.db.close = own; closing = t.close(); } return closing; };
++ return t.db;
++}
++
++module.exports = { testDb };
ddiff --git a/test/helpers/fake-discord.js b/test/helpers/fake-discord.js
nnew file mode 100644
iindex 0000000..34e72ab
---- /dev/null
++++ b/test/helpers/fake-discord.js
@@@ -0,0 +1,97 @@
++'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 };
ddiff --git a/test/helpers/fake-events.js b/test/helpers/fake-events.js
nnew file mode 100644
iindex 0000000..c607d73
---- /dev/null
++++ b/test/helpers/fake-events.js
@@@ -0,0 +1,74 @@
++'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 };
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
nnew file mode 100644
iindex 0000000..e283c4f
---- /dev/null
++++ b/test/helpers/mock-media.js
@@@ -0,0 +1,59 @@
++'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 };
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
nnew file mode 100644
iindex 0000000..fbc4299
---- /dev/null
++++ b/test/helpers/mock-network.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair.
++ *
++ * Also the service-principal surface Space uses:
++ * - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)
++ * mint service tokens with the requested audience and scope as capabilities;
++ * - POST /internal/identity/resolve-batch (and GET /internal/identity/resolve?username=) answer from `directory` and insist on a service
++ * token for openvibe.network holding identity.subject.resolve.
++ * signService() mints tokens as another service (e.g. Live) would present them to Community.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function start({ clientSecret = 'shh' } = {}) {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const grants = [];
++ const resolveCalls = [];
++ const issued = []; // every client-credentials access token handed out (security-secrets.test.js looks for them)
++ let issuer = 'http://network.test';
++
++ // Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
++ // legacy ids other services registered: legacy[system][id] = subject_id.
++ const directory = { users: [], legacy: { live: {} }, down: false };
++ function addUser(u) {
++ const user = { subject_id: ids.newId('user'), display_name: u.username, avatar_url: null, ...u };
++ directory.users.push(user);
++ return user;
++ }
++ const projection = (u) => (u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.network_user_id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, banned: false } : null);
++
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (req.url === '/oauth/token' && req.method === 'POST') {
++ let body = {};
++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
++ else { try { body = JSON.parse(raw); } catch { /* */ } }
++ grants.push(body);
++ if (body.client_secret !== clientSecret) return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'client_credentials') {
++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
++ const token = signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap });
++ issued.push(token);
++ return json(200, { access_token: token, token_type: 'Bearer', expires_in: 300 });
++ }
++ if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
++ if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
++ if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
++ // A FedCM assertion: must be one of ours (signed with our key) and carry a nonce.
++ try { const a = jwt.verify(body.assertion, publicPem, { algorithms: ['RS256'] }); if (!a.nonce) throw new Error('no nonce'); }
++ catch (e) { return json(400, { error: 'invalid_grant', error_description: `assertion rejected: ${e.message}` }); }
++ }
++ const user = { id: 7, username: 'alex', display_name: 'Alex', role: 'user' };
++ return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
++ }
++ if (req.url === '/oauth/revoke') return json(200, { ok: true });
++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ let body = {}; try { body = JSON.parse(raw); } catch { /* */ }
++ resolveCalls.push(body);
++ if (directory.down) return json(503, { error: 'down' });
++ const results = {};
++ if (Array.isArray(body.subject_ids)) {
++ for (const s of body.subject_ids) results[s] = projection(directory.users.find((u) => u.subject_id === s));
++ } else {
++ for (const id of body.ids || []) {
++ const k = String(id);
++ if (body.system === 'network') results[k] = projection(directory.users.find((u) => String(u.network_user_id) === k));
++ else {
++ const sid = (directory.legacy[body.system] || {})[k];
++ results[k] = projection(sid ? directory.users.find((u) => u.subject_id === sid) : null);
++ }
++ }
++ }
++ return json(200, { results });
++ }
++ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
++ // GET /internal/identity/resolve?username= (Network #56): the current holder of a name, any case, a
++ // leading @ ignored; 404 for nobody (or a banned account).
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ if (directory.down) return json(503, { error: 'down' });
++ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').replace(/^@/, '').toLowerCase();
++ const u = directory.users.find((x) => String(x.username || '').toLowerCase() === name && !x.banned);
++ return u ? json(200, projection(u)) : json(404, { code: 'identity.subject_not_found' });
++ }
++ json(404, { error: 'not found' });
++ });
++ });
++ function sign(claims, opts = {}) {
++ return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
++ }
++ /** A client-credentials service token (identity.service-token-claims@1). */
++ function signService({ sub = 'svc:live', actorType = 'service', aud = ['openvibe.space'], cap = [], iss = issuer, expSec = 300, key = privatePem, extra = {} } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss, sub, actor_type: actorType, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'), ...extra }, key);
++ }
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
++ const url = `http://127.0.0.1:${server.address().port}`;
++ issuer = url;
++ resolve({ url, grants, issued, resolveCalls, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
++ }));
++}
++
++module.exports = { start };
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
nnew file mode 100644
iindex 0000000..d885bc3
---- /dev/null
++++ b/test/markdown.test.js
@@@ -0,0 +1,82 @@
++'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 &lt; 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">&lt;b&gt;raw&lt;/b&gt;</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&amp;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('&lt;'), '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', 'java&#115;cript: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();
++})();
ddiff --git a/test/members-only.test.js b/test/members-only.test.js
nnew file mode 100644
iindex 0000000..cd6307a
---- /dev/null
++++ b/test/members-only.test.js
@@@ -0,0 +1,253 @@
++'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();
++})();
ddiff --git a/test/relay-events.test.js b/test/relay-events.test.js
nnew file mode 100644
iindex 0000000..9667a36
---- /dev/null
++++ b/test/relay-events.test.js
@@@ -0,0 +1,207 @@
++'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();
++})();
ddiff --git a/test/relay-inbound.test.js b/test/relay-inbound.test.js
nnew file mode 100644
iindex 0000000..ffc94e8
---- /dev/null
++++ b/test/relay-inbound.test.js
@@@ -0,0 +1,346 @@
++'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();
++})();
ddiff --git a/test/relay.test.js b/test/relay.test.js
nnew file mode 100644
iindex 0000000..ee47943
---- /dev/null
++++ b/test/relay.test.js
@@@ -0,0 +1,509 @@
++'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();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..62e0a25
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,15 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use in-process mocks of OpenVibe.Network, OpenVibe.Media, OpenVibe.Chat
++ * and OpenVibe.VIP only; none of them needs the network or a running site.
++ *
++ * npm test # everything
++ * npm test -- seo proxy # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ *
++ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
++ * ○ and not counted as passed (openvibe-shared/test-runner).
++ */
++'use strict';
++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 });
ddiff --git a/test/space-moderators.test.js b/test/space-moderators.test.js
nnew file mode 100644
iindex 0000000..92d2ac2
---- /dev/null
++++ b/test/space-moderators.test.js
@@@ -0,0 +1,194 @@
++'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();
++})();
ddiff --git a/test/votes-race.test.js b/test/votes-race.test.js
nnew file mode 100644
iindex 0000000..8a926ae
---- /dev/null
++++ b/test/votes-race.test.js
@@@ -0,0 +1,73 @@
++'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();
++})();