openvibers/openvibe.fun Commits

OpenVibe.Fun v1

OP OpenVibers committed 16 days ago · 0a7173e75b · 62 files, +9256 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..b35e487
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,92 @@
++# OpenVibe.Fun — environment (production: /etc/openvibe/fun.env). Names only; never commit values.
++
++NODE_ENV=production
++PORT=5140
++HOST=127.0.0.1
++BASE_URL=https://openvibe.fun
++TRUST_PROXY=2
++
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations), and Valkey (shared counters/readiness).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=ov:fun:
++# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
++#FUN_LIMITS_MINUTE=120
++#FUN_LIMITS_HOUR=3000
++#FUN_API_RATE_LIMIT_PER_MIN=240
++#FUN_RATE_LIMIT_PER_MIN=300
++
++# OpenVibe.Network: SSO (OAuth client `fun`, PKCE S256), JWKS, and (only when FUN_COINS=on) OpenCoins.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# OV_NETWORK_ISSUER=https://openvibe.network
++OV_OAUTH_CLIENT_ID=fun
++OV_OAUTH_CLIENT_SECRET=
++# OV_OAUTH_REDIRECT_URI=https://openvibe.fun/auth/callback
++# FUN_AUDIENCE=openvibe.fun
++# OV_SESSION_AUDIENCE=openvibe.network
++# FUN_PGLITE_DIR=./data/pglite
++
++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503). The subscription
++# needs the events.subscription.manage capability for svc:fun.
++FUN_EVENTS_SECRET=
++FUN_EVENTS_URL=
++#FUN_EVENTS_ENDPOINT=http://127.0.0.1:5140/internal/events
++#FUN_EVENTS_INTERVAL_MS=5000
++
++# Public upstreams. Live is the channel/live check; Chat is the public history Fun reads (never posts);
++# Today is called with the viewer's own token only.
++OV_LIVE_URL=https://openvibe.live
++OV_CHAT_URL=https://openvibe.chat
++OV_TODAY_URL=https://openvibe.today
++#FUN_UPSTREAM_TIMEOUT_MS=8000
++
++# OpenStreetMap: identify, pace, cache, bound. Ticketmaster is OFF unless TICKETMASTER_API_KEY is set.
++FUN_NOMINATIM_URL=https://nominatim.openstreetmap.org
++FUN_OVERPASS_URL=https://overpass-api.de/api/interpreter
++FUN_OSM_USER_AGENT=OpenVibeFun/0.1 (+https://openvibe.fun; [email protected])
++#FUN_OSM_MIN_INTERVAL_MS=1000
++#FUN_OSM_TIMEOUT_MS=20000
++#FUN_OSM_RETRY_AFTER_MS=120000
++#FUN_GEO_CACHE_TTL_MS=604800000
++#FUN_GEO_STALE_TTL_MS=2592000000
++#FUN_GUIDE_RADIUS_KM=5
++#FUN_GUIDE_MAX_RADIUS_KM=25
++#FUN_GUIDE_MAX_RESULTS=30
++
++# Ticketmaster Discovery: only read when this is set. Free API, never a paid provider; never log the key.
++#TICKETMASTER_API_KEY=
++# or the prefixed alias:
++#FUN_TICKETMASTER_API_KEY=
++#FUN_TICKETMASTER_URL=https://app.ticketmaster.com/discovery/v2/events.json
++#FUN_TICKETMASTER_MIN_INTERVAL_MS=1000
++#FUN_TICKETMASTER_TIMEOUT_MS=8000
++#FUN_TICKETMASTER_TTL_MS=900000
++#FUN_TICKETMASTER_STALE_TTL_MS=86400000
++#FUN_TICKETMASTER_RADIUS_KM=10
++#FUN_TICKETMASTER_SIZE=12
++
++# IRL quests and the read-only chat vote timer.
++#FUN_QUEST_VERIFY_VOTES=3
++#FUN_QUEST_MAX_SUGGESTIONS=30
++#FUN_QUEST_COINS=25
++#FUN_CHAT_SYNC_INTERVAL_MS=60000
++#FUN_CHAT_SYNC_LIMIT=200
++
++# OpenCoins (loyalty points, never money): off by default. When on, svc:fun needs identity.subject.resolve and
++# network.coins.credit, and the wallet applies these caps.
++FUN_COINS=off
++#FUN_COINS_DAILY_CAP=50
++#FUN_COINS_GLOBAL_DAILY_CAP=5000
++#FUN_COINS_RETRY_MS=300000
++
++# A restore drill starts no jobs and creates no subscriptions.
++#FUN_DRILL=0
++
++# optional: defaults to true in production
++# COOKIE_SECURE=true
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..a261f88
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,17 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ # install, node --check and npm test: the shared test job (Track Q).
++ test:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
++ with:
++ syntax-dirs: server test
++ # 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: '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@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..40dbad5
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++/data/
++dist/
++.env
++.env.*
++!.env.example
++.npm-cache/
++*.log
++*.pem
++*.key
++!server/data/
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..035e3a5
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,41 @@
++# Changelog
++
++What changed in OpenVibe.Fun, newest first.
++
++## 0.1.0 — 2026-10-28
++
++The first version: IRL quests on Live, the local guide and group plans.
++
++- **IRL quests on Live.** `fun_quest_boards` is a streamer's board for one session, checked against the public
++ OpenVibe.Live channel and the signed-in Network username. `fun_quests` is suggested → accepted → live → completed
++ → verified, with `rejected` and `hidden`. Suggestions pass an explainable dangerous/illegal/trespassing/
++ harassment filter before they are stored; a board can be accessible-only and has a suggestion cap. One vote per
++ person per quest.
++- **Verification and reward.** A completed quest is verified by `FUN_QUEST_VERIFY_VOTES` distinct public chat checks
++ or by a Network moderator (`global_mod`+). A local `fun_badges` row is always paid; `fun_quest_rewards` records the
++ OpenCoins ledger. OpenCoins are loyalty points, never money, and are credited only with `FUN_COINS=on` through
++ Network's `identity.subject.resolve` + `network.coins.credit` with the reward id as the idempotency key.
++- **Chat is read, never posted.** A timer reads one public page of the channel's `GET /api/chat/global/history` and
++ applies `!quest vote`, `!quest suggest`, `!quest verify` and `!plan vote` at most once per message id. Deleted or
++ unsafe messages are skipped. Under `FUN_DRILL` the timer is off; tests call `runOnce()`.
++- **The local guide.** `fun_geo_cache` stores OSM/Ticketmaster JSON. Nominatim and Overpass requests carry
++ `FUN_OSM_USER_AGENT`, are paced per host (1/s by default), cached seven days with stale fallback and are bounded to
++ one around: radius. Places show distance, address, opening hours, accessibility, a tile-free map and ODbL
++ attribution. Ticketmaster Discovery is off unless `TICKETMASTER_API_KEY` is set; the key is never cached, returned
++ or logged. No model call, no AI capability, no paid provider.
++- **Group plans.** `fun_plans` + `fun_plan_options` + `fun_plan_votes`: one vote per person or public chat username,
++ changing allowed until close, a tie refused, owner/staff decides, and the winner goes to the caller's own
++ OpenVibe.Today through Today's public event API with the caller's token and `Idempotency-Key: fun-plan-<id>`.
++- **Safety and moderation.** `fun_reports` collects signed-in safety/spam/other reports; staff list them and can hide
++ a public quest or plan. Hiding writes a Search tombstone.
++- **Search.** Each public quest/plan is one `search.index-document@1` in `fun_outbox` (owner `fun`, types `quest`
++ and `plan`), with a revision, public URL and public body. The relay waits for the `search.document.write` grant.
++- **The service.** Network SSO as OAuth client `fun` (PKCE S256, `fun_at`/`fun_rt`/`fun_oauth`), truthful readiness,
++ Prometheus metrics on loopback, discovery, deploy files, and server-rendered pages with stable `fu-` ids plus a
++ no-JS OBS overlay. `public/css/fun.css` and `public/js/fun.js` are one-comment placeholders for the main session.
++- **Account data.** Export covers boards, suggestions, votes, checks, badges, rewards, plans, options, plan votes and
++ reports. Deletion erases personal rows and anonymizes the community record (subject NULL); another person's rows
++ and public titles stay.
++- **Tests.** Migration, guide/cache/pacer/Ticketmaster, quest safety and state machine, chat idempotency, plans and
++ Today, Search document validation, SSO, pages/cache, coins opt-in and account export/deletion, each in its own
++ process on a temporary PGlite database with a mock Network and upstream fixtures.
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..0012627
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,232 @@
++# OpenVibe.Fun
++
++> Something to do, alone, together, or live.
++
++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
++**Domain:** `openvibe.fun` · **Port:** 5140 · **Service id:** `fun` · **Env prefix:** `FUN`
++**License:** AGPL-3.0-only (the same as every OpenVibe service).
++
++OpenVibe.Fun is the Fun room. V1 has three things:
++
++- **IRL quests on Live.** A streamer opens a board for their public Live channel. Viewers suggest safe real-world
++ challenges, chat votes with `!quest vote <slug>`, the streamer accepts one on stream, marks it live and completed,
++ and the chat or a moderator verifies it. A badge is paid locally; OpenCoins are loyalty points, never money, and
++ only when the wallet is configured.
++- **The local guide.** "What's fun near me?" — places from OpenStreetMap (Nominatim + Overpass), local events from
++ Ticketmaster only when a key is configured, a tile-free map, times, links and an accessibility mark. It is
++ deterministic in v1: no model call, no new AI capability, no paid provider.
++- **Group plans.** Two or three options, one link, one vote each, a tie refused, the owner decides, and the winner
++ can be added to the viewer's own Today with the viewer's own Network token.
++
++**No social posting.** Fun reads OpenVibe.Chat's public history for votes and reads OpenVibe.Live's public channel
++for the streamer/live state. It never posts a chat message, a stream announcement or a social entry. The one
++external write is a plan added to *your own* Today.
++
++## Owns
++
++| Table | Holds | Migration |
++|---|---|---|
++| `fun_quest_boards` | `qbd_` + ULID, streamer subject (nullable after deletion), public Live/Chat username, title, city/point, accessible-only, max suggestions, status, accepted quest, window | `migrations/0001_initial.sql` |
++| `fun_quests` | `qst_` + ULID, board, unique slug (`/q/<slug>`), title, brief, category, source, status `suggested\|accepted\|live\|completed\|verified\|rejected\|hidden`, visibility, place, accessible, streamer, revision, reward badge/coins, timestamps | `0001_initial.sql` |
++| `fun_quest_votes` | one (`quest`, voter) row; voter is `usr_…` or `chat:<username>`; via web/chat, chat message id | `0001_initial.sql` |
++| `fun_quest_checks` | one verification check per voter; chat or moderator, chat message id | `0001_initial.sql` |
++| `fun_badges` | one local badge per (`quest`, subject) after verification | `0001_initial.sql` |
++| `fun_quest_rewards` | `qrw_` + ULID, the OpenCoin ledger per quest/streamer: pending/credited/skipped/failed, attempts | `0001_initial.sql` |
++| `fun_plans` | `pln_` + ULID, slug (`/plans/<slug>`), owner (nullable after deletion), title, city/point, timezone, start/close, status `voting\|decided\|cancelled`, winner, visibility, revision | `0001_initial.sql` |
++| `fun_plan_options` | `pop_` + ULID, plan, title/details/place/point, accessible, price note, position 1–10 | `0001_initial.sql` |
++| `fun_plan_votes` | one (`plan`, voter) row; option, via web/chat, chat message id | `0001_initial.sql` |
++| `fun_reports` | `frp_` + ULID, quest/plan target, reporter, kind `safety\|spam\|other`, reason, status | `0001_initial.sql` |
++| `fun_chat_seen`, `fun_chat_cursors` | the read-only chat page's idempotency and cursor | `0001_initial.sql` |
++| `fun_geo_cache` | public OSM/Ticketmaster JSON, fetched/expires (never a person, never a secret) | `0001_initial.sql` |
++| `fun_outbox` | Search Fabric documents/tombstones (Today/Meme shape) | `0001_initial.sql` |
++| `account_data_events` | `openvibe-sdk/account-data` export/deletion receipts | `0001_initial.sql` |
++| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
++
++## Does not own
++
++- **Live, Chat and Today** — OpenVibe.Live, OpenVibe.Chat and OpenVibe.Today. Fun uses their public reads and, for
++ Today, the person's own token on the public event API. It never posts to Live or Chat and never writes to anyone
++ else's calendar.
++- **Identity and roles** — OpenVibe.Network. Fun is OAuth client `fun` (PKCE S256) and verifies session tokens
++ against the Network JWKS; staff means Network role `global_mod` or above.
++- **OpenStreetMap and Ticketmaster** — external public sources, cached and credited; never a paid provider.
++- **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox` when its writer is
++ configured; the relay needs `search.document.write` and is not in v1.
++- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
++
++## Capabilities and grants
++
++**Inbound, v1: none.** No Fun route requires a service, app or agent capability. A person is the `fun_at` session
++cookie or a Network user token; a signed-in write from the cookie must come from `https://openvibe.fun`
++(`fun.cross_site`).
++
++**Outbound, v1:**
++
++- `events.subscription.manage` — at boot, with `svc:fun`, to create the two account export/deletion subscriptions at
++ OpenVibe.Events. The delivery itself is verified by the `FUN_EVENTS_SECRET` signature, not a token.
++- `network.account.export.contribute` and `network.account.deletion.confirm` — the `createNetworkSender` calls that
++ push the person's export part and the deletion confirmation to OpenVibe.Network's internal routes.
++- `identity.subject.resolve` and `network.coins.credit` — **only when `FUN_COINS=on`**, with `svc:fun`, app id `fun`,
++ to credit OpenCoins through OpenVibe.Network after a quest is verified. Off by default; the badge is still paid.
++
++**Not needed in v1:** no Live, Chat, Today, Quest, Media or AI service token. The Today write uses the signed-in
++person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. A future Search relay needs
++`search.document.write`; a future streamer-ownership proof could use a Live capability, but v1 checks the Network
++username against the public Live channel.
++
++## API
++
++Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `fun_at`
++session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write
++made with the session cookie must come from `https://openvibe.fun` (403 `fun.cross_site`). A signed-in account also
++gets per-actor minute/hour limits (`FUN_LIMITS_MINUTE`, `FUN_LIMITS_HOUR`).
++
++| Route | What it does |
++|---|---|
++| `GET /guide?q=&kind=&accessible=&rainy=&kids=&cheap=&radius=&limit=` | OSM places with attribution/map links, plus Ticketmaster events only when configured; cached |
++| `GET /quests?limit=&cursor=&status=&city=` | public IRL quests, newest first, opaque keyset cursor |
++| `GET /quests/ideas` | the safe starter deck (curated in code, not model output) |
++| `GET /quests/boards/:id` | one board and its suggestions (the share link; noindex) |
++| `GET /quests/:slug` | one quest, its vote count, chat-check count and the viewer's vote |
++| `POST /quests/boards` | a streamer opens a board; the Network username must match the public Live channel |
++| `POST /quests/boards/:id/suggest` | a signed-in suggestion, safety-filtered before it is stored |
++| `POST /quests/boards/:id/idea` | owner/staff adds one unused safe idea from the deck |
++| `POST /quests/:slug/vote` | one vote per person; a repeat is idempotent |
++| `POST /quests/:slug/accept\|live\|complete\|verify` | the streamer (or staff) moves the quest on; `verify` takes `{ via: "chat"\|"moderator" }` |
++| `POST /quests/:slug/hide` | staff hides a public quest and writes a Search tombstone |
++| `POST /quests/:slug/report` | signed-in safety/spam/other report |
++| `GET /plans?limit=&cursor=&city=` | public group plans |
++| `GET /plans/:slug` | one plan with options, vote counts and the winner |
++| `POST /plans` | start a plan: title, start, timezone, 1–10 options |
++| `POST /plans/:slug/options\|vote\|decide\|today` | add an option, cast one vote, choose the winner (tie refused), add the winner to your own Today |
++| `POST /plans/:slug/hide` | staff hides a plan and writes a Search tombstone |
++| `POST /plans/:slug/report` | signed-in report |
++| `GET /reports` | staff: open reports |
++
++Validation refusals are `400 { code: "fun.invalid" }` (or `fun.quest.invalid`, `fun.plan.invalid`,
++`fun.report.invalid`). Other codes: `fun.sign_in_required`, `fun.person_required`, `fun.staff_required`,
++`fun.streamer_required`, `fun.streamer_username`, `fun.cross_site`, `fun.not_found`, `fun.quest_not_found`,
++`fun.plan_not_found`, `fun.board_not_found`, `fun.board_closed`, `fun.board_full`, `fun.quest_closed`,
++`fun.quest_state`, `fun.quest_quorum`, `fun.plan_owner`, `fun.plan_closed`, `fun.plan_tie`,
++`fun.plan_no_votes`, `fun.plan_not_decided`, `fun.rate_limited`, `fun.guide.invalid`, `fun.guide.not_found`,
++`fun.guide.upstream`, `fun.channel_not_found`, `fun.live.unavailable`, `fun.today.*`.
++
++## IRL quests and safety
++
++The state machine is `suggested → accepted → live → completed → verified`, with `rejected` for the suggestions the
++streamer did not choose and `hidden` for a staff takedown. Before any suggestion is stored, `server/quests/safety.js`
++checks it against a small, explainable list of dangerous, illegal, trespassing, harassment and adult patterns. The
++plan's own playful example ("get a stranger to beatbox") is allowed; "climb a bridge and jump off" is not. The
++streamer can make a board accessible-only and cap the number of suggestions. The public page carries a report form;
++staff see the open reports and can hide the quest.
++
++Chat voting is read-only. A timer reads one page of the public `GET /api/chat/global/history?username=<channel>` and
++applies at most one command per message id (`fun_chat_seen`):
++
++- `!quest vote <slug>`
++- `!quest suggest <text>` (safety-filtered; stored unlisted)
++- `!quest verify <slug>` (after the streamer marks it completed)
++- `!plan vote <slug> <number>`
++
++No message text beyond a bounded command, no account and no token is stored; a chat voter is a one-way key of the public username, not a copy of it. A re-read of the same public page
++changes nothing. Under `FUN_DRILL` the timer is not started; tests call `runOnce()`.
++
++Verification by `via: "chat"` needs `FUN_QUEST_VERIFY_VOTES` distinct chat checks (default 3); a Network moderator
++can verify directly with `via: "moderator"`. Verification always writes a local `fun_badges` row; it writes a
++`fun_quest_rewards` row for OpenCoins and settles it through the optional wallet adapter.
++
++## The local guide
++
++`server/guide/upstream.js` is the only outbound host list: Nominatim, Overpass and Ticketmaster. It follows their
++usage policies:
++
++- **Identify:** every request carries `FUN_OSM_USER_AGENT` (an `OpenVibeFun/0.1 (+https://openvibe.fun; …)` contact
++ string). A browser User-Agent is never used.
++- **Pace:** `createPacer` serialises calls per host, at least `FUN_OSM_MIN_INTERVAL_MS` (1000 ms) apart; Ticketmaster
++ has its own interval.
++- **Cache:** every answer is in `fun_geo_cache` for `FUN_GEO_CACHE_TTL_MS` (7 days OSM, 15 minutes Ticketmaster); the
++ last answer is served stale if the host is down.
++- **Bound:** one `around:<radius>,<lat>,<lon>` query with a small set of tags, `out center tags`, never a bulk
++ download. The radius is capped by `FUN_GUIDE_MAX_RADIUS_KM`.
++- **Credit:** every API answer and page shows © OpenStreetMap contributors, ODbL 1.0, and links back.
++
++Ticketmaster is **off by default**. It is read only when `TICKETMASTER_API_KEY` is set; the key is a query parameter
++at the edge, is never in the cache key, is never returned and is never logged. Events show name, venue, local times
++and a public link; Fun never sells a ticket, takes a payment or calls a paid provider.
++
++The guide is deterministic: `kind` (`all`, `outside`, `inside`, `arts`, `food`) plus `accessible`, `rainy`, `kids`
++and `cheap` filter the same OSM answer. There is no model call and no new AI capability.
++
++## Group plans and Today
++
++A plan has 1–10 options and one vote per person (or public chat username); a vote can change until `closes_at`.
++The owner or staff decides; a tie is `409 fun.plan_tie`. A decided plan's winner can be added to the viewer's own
++Today: `POST /plans/:slug/today` calls Today's public `POST /api/v1/events` with the viewer's Network token and
++`Idempotency-Key: fun-plan-<plan id>`. Fun never fans out to other people's calendars; each person adds it to
++their own Today. A hidden plan is 404 to the public.
++
++## Pages
++
++All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with
++semantic HTML and stable `fu-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn
++for a signed-in person is `private, no-store`; a board share page is `no-store`.
++
++| Page | What it is |
++|---|---|
++| `/` | the room, featured quests, plans, the guide teaser |
++| `/guide` | the guide form and its OSM/Ticketmaster answer |
++| `/quests` | public IRL quests and the safe idea deck |
++| `/quests/new` | open a board (streamer) |
++| `/quests/boards/:id` | one board, suggestions, vote and accept forms |
++| `/q/:slug` | one quest, votes/checks, streamer actions, report form |
++| `/live` | a streamer's boards and quests |
++| `/plans`, `/plans/new` | public plans and the form |
++| `/plans/:slug` | options, votes, decide, add to Today |
++| `/developers` | the API and Search document |
++| `/overlay/q/:slug` | a minimal OBS browser source, meta-refresh, noindex |
++| 404 | the not-found page |
++
++`public/css/fun.css` and `public/js/fun.js` are one-comment placeholder files: the pages are complete without them
++and the main session designs them. `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list the public pages
++and every public quest/plan.
++
++## Search
++
++Each public quest is one `search.index-document@1` (owner `fun`, type `quest`, page `/q/<slug>`); each public plan is
++type `plan` (`/plans/<slug>`). The document carries the public title/brief/body, facets (`status`, `category`,
++`city`, `accessible`, `streamer`, `source`), human authorship and a Live channel provenance reference for a quest.
++Publishing, accepting, moving or deciding writes/updates the document in the same transaction; hiding or
++cancelling writes a tombstone. Rows live in `fun_outbox`; the relay is deliberately not in v1 and needs the
++`search.document.write` grant.
++
++## The first proof (covered by the tests)
++
++`test/chat-sync.test.js` and `test/quests.test.js` cover the plan's first proof end to end: a chat message suggests
++a quest, a public chat vote counts, the streamer accepts it on their board, marks it live and completed, three
++distinct chat checks (or a moderator) verify it, and a badge is paid. `test/plans.test.js` covers the second proof:
++a group votes, the winner is decided and goes to the viewer's own Today.
++
++## Run and test
++
++```sh
++npm install
++npm test # PGlite; every test file in its own process, no network needed
++npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
++OV_ALLOW_SUITE=1 npm test
++npm start # PORT 5140, loopback; DATABASE_URL or an embedded PGlite in development
++```
++
++The tests boot the app with a mock Network, a Live/Chat/Today/OSM/Ticketmaster fixture server, and a temporary
++database: the migration, the guide's cache/pacer/User-Agent and Ticketmaster off-by-default, quest safety and the
++full state machine, chat suggestion/vote/verify idempotency, plan voting/tie/Today, Search document validation,
++SSO, pages/cache, coins opt-in, and account export/deletion.
++
++## Deploy
++
++`deploy/systemd/openvibe-fun.service` (127.0.0.1:5140, `/etc/openvibe/fun.env`) and
++`deploy/nginx/openvibe.fun.conf` (TLS, www → apex, `/auth/me` on the API zone, a forms zone for the POST routes,
++`/internal` and `/metrics` 404). Not deployed by this change.
++
++## License
++
++AGPL-3.0-only. See [LICENSE](LICENSE).
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..c8bd43f
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,49 @@
++# 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.fun, openvibe.live, openvibe.network, openvibe.today, 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.
++
++## What OpenVibe.Fun holds
++
++- Public quests and boards: the title, brief, category, place/city/point, accessibility mark, status, streamer
++ username and reward id. A public quest is the community's record, not one person's private row.
++- Public group plans: title, summary, city/point, time, timezone, options, public vote counts and the winner.
++- A person's suggestions, votes, verification checks, badges, OpenCoin reward ledger, plans they started, plan
++ options they proposed, plan votes and reports. These are exported and erased with an account; the public quest or
++ shared plan stays with the author column set to NULL.
++- A one-way key of the public chat username and the public message id for the read-only chat commands. A chat
++ message's text is not stored, and Fun never posts a message. A chat vote is not linked to a Network subject, so
++ it is not exportable by account, but it is also not stored as the plaintext username.
++- Network session tokens only in httpOnly cookies (`fun_at`, `fun_rt`); no token is stored in the database,
++ rendered into a page or logged. The Today call uses the person's own token and never stores it.
++- OSM and Ticketmaster answers in `fun_geo_cache`: public place/event JSON, no person and no secret. The Ticketmaster
++ key is a query parameter at the edge, never a cache key, a response field or a log line.
++
++## Safety
++
++Every suggestion and chat suggestion is checked against a small dangerous/illegal/trespassing/harassment/adult
++filter before it is stored. A streamer can make a board accessible-only and cap suggestions. The chat verification
++needs distinct public chat voters or a Network moderator. Any public quest or plan can be reported by a signed-in
++person; staff can hide it. A hidden target answers 404 and writes a Search tombstone.
++
++No Fun route needs a service token. A signed-in write from the session cookie must come from `openvibe.fun`
++(Sec-Fetch-Site same-origin or an Origin equal to ours); anonymous and cross-site writes are refused. `/internal/events`
++is loopback only and refuses a proxied request; nginx answers 404 for `/internal` and `/metrics`. The OBS overlay is
++`noindex, nofollow` and has no script.
++
++## 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..1be960f
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,68 @@
++{
++ "repository": "OpenVibers/OpenVibe.Fun",
++ "version": "0.1.0",
++ "stage": "product",
++ "code": true,
++ "wave": "W21",
++ "plan": "OpenVibe.Fun: something to do, alone, together, or live — IRL quests on Live, a local guide from OpenStreetMap (Ticketmaster only with a key), and group plans",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
++ "port": "5140",
++ "domain": "openvibe.fun",
++ "contracts": "openvibe-contracts v0.229.0",
++ "sdk": "openvibe-sdk v0.70.0",
++ "shared": "openvibe-shared v4.31.0",
++ "packages": [
++ "openvibe-contracts v0.229.0",
++ "openvibe-sdk v0.70.0",
++ "openvibe-shared v4.31.0"
++ ],
++ "summary": "OpenVibe.Fun is the Fun room. A streamer opens an IRL quest board on their public Live channel, viewers suggest safe challenges and chat votes with !quest vote, the streamer accepts one and marks it live/completed, and the chat or a moderator verifies it; a local badge is paid and OpenCoins are credited only when the wallet is configured. The local guide reads OpenStreetMap (Nominatim/Overpass with a contact User-Agent, one request a second per host, a seven-day cache and a bounded around: query) and Ticketmaster only when a key is configured. Group plans have options, one vote each, a tie refused, and a winner the viewer can add to their own Today with their own token. No route needs a service token; Fun never posts to Chat or Live.",
++ "works": [
++ "Streamer quest boards tied to a public Live channel, checked against the signed-in Network username",
++ "Viewer suggestions with an explainable dangerous/illegal/trespassing/harassment filter, accessible-only boards and suggestion caps",
++ "One vote per person per quest and one per public chat username; public counts",
++ "Accept, live, complete and verify by a streamer; chat quorum (default 3) or a Network moderator",
++ "Local badge always paid on verification; OpenCoins ledger with per-person and global daily caps when FUN_COINS=on",
++ "Read-only chat vote ingestion for !quest vote, !quest suggest, !quest verify and !plan vote, idempotent per message id",
++ "A deterministic local guide from Nominatim/Overpass with a contact User-Agent, a per-host pacer, a seven-day cache, stale fallback and ODbL attribution",
++ "Ticketmaster Discovery events only when TICKETMASTER_API_KEY is set; the key is never cached, returned or logged",
++ "Group plans with options, one vote per person, a tie refused, an owner/staff decision and a winner added to the viewer's own Today via the person's token",
++ "Search Fabric documents (owner fun; types quest and plan) in fun_outbox, with valid tombstones on hide/cancel",
++ "Sign-in with OpenVibe.Network (PKCE S256, fun_at/fun_rt/fun_oauth), staff role global_mod+",
++ "Account export and deletion: personal rows erased; public quests/boards and shared plans anonymized, never silently kept with a name",
++ "Server-rendered pages with stable fu- ids/classes, a no-JS OBS overlay, discovery artifacts and deploy files"
++ ],
++ "notYet": [
++ "An AI chat or model-generated guide: v1 is deterministic and adds no AI capability",
++ "Ticketmaster events without a key: v1 is OSM-only by default",
++ "OpenCoins without the FUN_COINS=on setting and the identity.subject.resolve + network.coins.credit grants: the badge is still paid",
++ "The Search document relay: documents wait in fun_outbox until Network grants fun search.document.write",
++ "A service-token proof of Live channel ownership: v1 checks the public channel and matching Network username",
++ "Fan-out of a decided group plan to every member's Today: each viewer adds it to their own with their own token",
++ "Parsing OSM opening_hours into open-now; the guide shows the raw OSM value",
++ "Optional tips on a quest: v1 has no payment provider and never moves money",
++ "The v2/v3 mosaic, party games, creator quests, city-by-city guide and partnerships"
++ ],
++ "capabilitiesRegistered": false,
++ "serviceManifestRegistered": false,
++ "deployed": false,
++ "placeholderInSites": false,
++ "features": [
++ "IRL quests on Live",
++ "quest safety filter and reports",
++ "chat votes read-only",
++ "local guide (OpenStreetMap)",
++ "optional Ticketmaster events",
++ "group plans and votes",
++ "add the winner to Today",
++ "Search Fabric outbox",
++ "account export and deletion",
++ "Network sign-in",
++ "server-rendered pages",
++ "OBS overlay",
++ "discovery",
++ "deploy files"
++ ],
++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-fun.service and deploy/nginx/openvibe.fun.conf",
++ "updated": "2026-10-28"
++}
ddiff --git a/deploy/nginx/openvibe.fun.conf b/deploy/nginx/openvibe.fun.conf
nnew file mode 100644
iindex 0000000..3dd659b
---- /dev/null
++++ b/deploy/nginx/openvibe.fun.conf
@@@ -0,0 +1,147 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.fun — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.fun.conf (ov-vhost-install) — keep them in sync and reload
++## nginx after changing it. The Node app (port 5140, systemd unit openvibe-fun) renders every page and serves
++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the form writes, and
++## compresses text.
++##
++## No proxy_cache: the app decides cacheability per response (public for the guide and public quests/plans;
++## private, no-store for anything drawn for a signed-in person or a board share page). Never log request bodies.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=fun_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=fun_api:10m rate=5r/s;
++limit_req_zone $binary_remote_addr zone=fun_forms:10m rate=2r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.fun www.openvibe.fun;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.fun$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.fun;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.fun/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.fun/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.fun$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.fun;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.fun/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.fun/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.fun.access.log;
++ error_log /var/log/nginx/openvibe.fun.error.log;
++
++ # The API's JSON body cap is 64 KB; leave room for the headers.
++ client_max_body_size 128k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ 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 application/problem+json image/svg+xml text/plain application/xml;
++
++ 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;
++
++ # ── Network SSO session endpoints ───────────────────────
++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
++ location = /auth/me {
++ limit_req zone=fun_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ location /auth/ {
++ limit_req zone=fun_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=fun_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ # ── The OBS overlay: a browser source may refresh every 15 s; keep it cheap ──
++ location /overlay/ {
++ limit_req zone=fun_api burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ # ── Forms and other writes: the board, a suggestion, a vote, a plan, a report ──
++ location = /quests/boards {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++ location ~ ^/quests/boards/[^/]+/(suggest|idea)$ {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++ location ~ ^/quests/[a-z0-9-]+/(vote|accept|live|complete|verify|hide|report)$ {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++ location = /plans {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++ location ~ ^/plans/[a-z0-9-]+/(options|vote|decide|today|hide|report)$ {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++ location ~ ^/q/[a-z0-9-]+/report$ {
++ limit_req zone=fun_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5140/metrics) ──
++ location ~* ^/metrics(/|$) { return 404; }
++
++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:5140/internal/events directly. Never proxied from outside (ADR-033).
++ location ~* ^/internal(/|$) { return 404; }
++
++ # ── Pages, docs, robots.txt, sitemap, static assets ─────
++ location / {
++ proxy_pass http://127.0.0.1:5140;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-fun.service b/deploy/systemd/openvibe-fun.service
nnew file mode 100644
iindex 0000000..fea4a45
---- /dev/null
++++ b/deploy/systemd/openvibe-fun.service
@@@ -0,0 +1,32 @@
++[Unit]
++Description=OpenVibe.Fun — something to do, alone, together, or live.
++# Network (sign-in, JWKS, account events) should be up first, but Fun degrades honestly without it: the public pages
++# and the guide serve, and sign-in and the guarded routes wait for the signing key (see /api/ready). Live, Chat and
++# Today are public upstreams and are not required to start.
++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.fun
++EnvironmentFile=/etc/openvibe/fun.env
++Environment=NODE_ENV=production
++Environment=PORT=5140
++Environment=HOST=127.0.0.1
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.fun/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++# In-flight requests get the 10 s drain.
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# OpenVibe.Fun's own database is PostgreSQL on the host's data role; the code tree stays read-only.
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..ecdac42
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,363 @@
++-- phase: expand
++-- OpenVibe.Fun v1: IRL quests on Live, a local guide from OpenStreetMap (Ticketmaster only when a key is set), and
++-- group plans. Nothing here is a credential. A quest/plan is public community text; a vote, suggestion, report and
++-- badge is a person's own row to export or erase (server/identity/account-data.js). OSM/Ticketmaster answers are
++-- cached public data, never personal data.
++
++-- ── IRL quests ────────────────────────────────────────────────
++-- A streamer opens one board on their Live channel for one session. Viewers suggest quests; the board stays
++-- unlisted, and only the quest the streamer accepts becomes public.
++CREATE TABLE fun_quest_boards (
++ id text COLLATE "C" PRIMARY KEY, -- qbd_ + ULID
++ streamer_subject text COLLATE "C", -- usr_…; NULL after an account deletion
++ live_username text COLLATE "C" NOT NULL,
++ chat_username text COLLATE "C" NOT NULL, -- the public Chat channel the votes are read from
++ title text NOT NULL,
++ city text,
++ lat double precision,
++ lon double precision,
++ accessible_only boolean NOT NULL DEFAULT false,
++ max_suggestions integer NOT NULL DEFAULT 30,
++ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | accepted | closed
++ accepted_quest_id text COLLATE "C",
++ opens_at timestamptz NOT NULL DEFAULT now(),
++ closes_at timestamptz NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_quest_boards_id CHECK (id ~ '^qbd_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_boards_subject CHECK (streamer_subject IS NULL OR streamer_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_boards_username CHECK (live_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$' AND chat_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$'),
++ CONSTRAINT fun_quest_boards_title CHECK (length(title) BETWEEN 3 AND 120),
++ CONSTRAINT fun_quest_boards_city CHECK (city IS NULL OR length(city) BETWEEN 1 AND 120),
++ CONSTRAINT fun_quest_boards_lat CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
++ CONSTRAINT fun_quest_boards_lon CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
++ CONSTRAINT fun_quest_boards_suggestions CHECK (max_suggestions BETWEEN 1 AND 100),
++ CONSTRAINT fun_quest_boards_status CHECK (status IN ('open', 'accepted', 'closed')),
++ CONSTRAINT fun_quest_boards_window CHECK (closes_at > opens_at)
++);
++CREATE INDEX fun_quest_boards_open ON fun_quest_boards (status, closes_at) WHERE status = 'open';
++CREATE INDEX fun_quest_boards_by_streamer ON fun_quest_boards (streamer_subject, created_at DESC);
++
++CREATE TABLE fun_quests (
++ id text COLLATE "C" PRIMARY KEY, -- qst_ + ULID
++ board_id text COLLATE "C" NOT NULL REFERENCES fun_quest_boards (id) ON DELETE CASCADE,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ title text NOT NULL,
++ brief text NOT NULL,
++ category text COLLATE "C" NOT NULL, -- near_me | outside | brainy | silly | chill
++ source text COLLATE "C" NOT NULL, -- suggestion | chat | idea | streamer
++ status text COLLATE "C" NOT NULL DEFAULT 'suggested', -- suggested | accepted | live | completed | verified | rejected | hidden
++ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted', -- public | unlisted | hidden
++ city text,
++ lat double precision,
++ lon double precision,
++ place_name text,
++ accessible boolean NOT NULL DEFAULT false,
++ streamer_username text COLLATE "C" NOT NULL,
++ chat_username text COLLATE "C" NOT NULL,
++ stream_url text NOT NULL,
++ created_by text COLLATE "C", -- usr_…; NULL for chat/idea suggestions and after deletion
++ chat_author text COLLATE "C", -- public chat username, when source = chat
++ revision bigint NOT NULL DEFAULT 1,
++ accepted_at timestamptz,
++ completed_at timestamptz,
++ verified_at timestamptz,
++ verified_by text COLLATE "C",
++ verification text COLLATE "C", -- chat | moderator
++ reward_badge text COLLATE "C",
++ reward_coins integer NOT NULL DEFAULT 0,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_quests_id CHECK (id ~ '^qst_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quests_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
++ CONSTRAINT fun_quests_title CHECK (length(title) BETWEEN 3 AND 120),
++ CONSTRAINT fun_quests_brief CHECK (length(brief) BETWEEN 3 AND 1200),
++ CONSTRAINT fun_quests_category CHECK (category IN ('near_me', 'outside', 'brainy', 'silly', 'chill')),
++ CONSTRAINT fun_quests_source CHECK (source IN ('suggestion', 'chat', 'idea', 'streamer')),
++ CONSTRAINT fun_quests_status CHECK (status IN ('suggested', 'accepted', 'live', 'completed', 'verified', 'rejected', 'hidden')),
++ CONSTRAINT fun_quests_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden')),
++ CONSTRAINT fun_quests_public_only_when_accepted CHECK (visibility <> 'public' OR status IN ('accepted', 'live', 'completed', 'verified')),
++ CONSTRAINT fun_quests_city CHECK (city IS NULL OR length(city) BETWEEN 1 AND 120),
++ CONSTRAINT fun_quests_lat CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
++ CONSTRAINT fun_quests_lon CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
++ CONSTRAINT fun_quests_place CHECK (place_name IS NULL OR length(place_name) BETWEEN 1 AND 200),
++ CONSTRAINT fun_quests_streamer CHECK (streamer_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$' AND chat_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$'),
++ CONSTRAINT fun_quests_stream_url CHECK (stream_url ~ '^https://'),
++ CONSTRAINT fun_quests_created_by CHECK (created_by IS NULL OR created_by ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quests_chat_author CHECK (chat_author IS NULL OR length(chat_author) BETWEEN 1 AND 64),
++ CONSTRAINT fun_quests_revision CHECK (revision > 0),
++ CONSTRAINT fun_quests_verification CHECK (verification IS NULL OR verification IN ('chat', 'moderator')),
++ CONSTRAINT fun_quests_coins CHECK (reward_coins BETWEEN 0 AND 500)
++);
++CREATE INDEX fun_quests_public ON fun_quests (visibility, status, created_at DESC);
++CREATE INDEX fun_quests_by_board ON fun_quests (board_id, created_at DESC);
++CREATE INDEX fun_quests_by_streamer ON fun_quests (streamer_username, created_at DESC);
++CREATE INDEX fun_quests_verified ON fun_quests (verified_at DESC) WHERE status = 'verified';
++ALTER TABLE fun_quest_boards
++ ADD CONSTRAINT fun_quest_boards_accepted_quest FOREIGN KEY (accepted_quest_id) REFERENCES fun_quests (id) ON DELETE SET NULL;
++
++-- One vote per person (or one per public chat username) per quest. A chat vote carries the message id so a re-read
++-- of the same public history changes nothing.
++CREATE TABLE fun_quest_votes (
++ quest_id text COLLATE "C" NOT NULL REFERENCES fun_quests (id) ON DELETE CASCADE,
++ voter text COLLATE "C" NOT NULL, -- usr_… for a person, chat:<username> for chat
++ subject text COLLATE "C", -- usr_… only for a person
++ via text COLLATE "C" NOT NULL DEFAULT 'web', -- web | chat
++ chat_message_id text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (quest_id, voter),
++ CONSTRAINT fun_quest_votes_voter CHECK (voter ~ '^(usr_[0-9A-HJKMNP-TV-Z]{26}|chat:.{1,64})$'),
++ CONSTRAINT fun_quest_votes_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_votes_via CHECK (via IN ('web', 'chat'))
++);
++CREATE INDEX fun_quest_votes_by_quest ON fun_quest_votes (quest_id, created_at);
++CREATE UNIQUE INDEX fun_quest_votes_chat_message ON fun_quest_votes (chat_message_id) WHERE chat_message_id IS NOT NULL;
++
++-- A chat vote on whether a completed quest is done. The streamer/owner may verify via chat once enough distinct
++-- public chat voters have said so; a Network moderator may verify directly.
++CREATE TABLE fun_quest_checks (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ quest_id text COLLATE "C" NOT NULL REFERENCES fun_quests (id) ON DELETE CASCADE,
++ voter text COLLATE "C" NOT NULL,
++ subject text COLLATE "C",
++ via text COLLATE "C" NOT NULL DEFAULT 'chat', -- chat | moderator
++ chat_message_id text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_quest_checks_voter CHECK (voter ~ '^(usr_[0-9A-HJKMNP-TV-Z]{26}|chat:.{1,64}|staff:.{1,64})$'),
++ CONSTRAINT fun_quest_checks_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_checks_via CHECK (via IN ('chat', 'moderator')),
++ UNIQUE (quest_id, voter)
++);
++CREATE INDEX fun_quest_checks_by_quest ON fun_quest_checks (quest_id, created_at);
++CREATE UNIQUE INDEX fun_quest_checks_chat_message ON fun_quest_checks (chat_message_id) WHERE chat_message_id IS NOT NULL;
++
++-- A local badge is free and always paid on verification. OpenCoins live in fun_quest_rewards and are credited only
++-- when FUN_COINS=on (loyalty points, never money). Neither table holds a token.
++CREATE TABLE fun_badges (
++ quest_id text COLLATE "C" NOT NULL REFERENCES fun_quests (id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ badge_id text COLLATE "C" NOT NULL,
++ name text NOT NULL,
++ awarded_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (quest_id, subject),
++ CONSTRAINT fun_badges_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_badges_badge CHECK (badge_id ~ '^fun_[a-z0-9_]{2,60}$'),
++ CONSTRAINT fun_badges_name CHECK (length(name) BETWEEN 1 AND 120)
++);
++CREATE INDEX fun_badges_by_subject ON fun_badges (subject, awarded_at DESC);
++
++CREATE TABLE fun_quest_rewards (
++ id text COLLATE "C" PRIMARY KEY, -- qrw_ + ULID
++ quest_id text COLLATE "C" NOT NULL REFERENCES fun_quests (id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ coins integer NOT NULL DEFAULT 0,
++ state text COLLATE "C" NOT NULL DEFAULT 'pending', -- pending | credited | skipped | failed
++ detail text,
++ attempts integer NOT NULL DEFAULT 0,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ UNIQUE (quest_id, subject),
++ CONSTRAINT fun_quest_rewards_id CHECK (id ~ '^qrw_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_rewards_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_quest_rewards_coins CHECK (coins BETWEEN 0 AND 500),
++ CONSTRAINT fun_quest_rewards_state CHECK (state IN ('pending', 'credited', 'skipped', 'failed'))
++);
++CREATE INDEX fun_quest_rewards_pending ON fun_quest_rewards (state, updated_at) WHERE state IN ('pending', 'failed');
++
++-- ── Group plans ───────────────────────────────────────────────
++-- A plan is the group's shared record. Its owner may delete their account: the owner column becomes NULL and the
++-- plan and other people's votes stay, because the plan is not one person's private row.
++CREATE TABLE fun_plans (
++ id text COLLATE "C" PRIMARY KEY, -- pln_ + ULID
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ owner_subject text COLLATE "C",
++ owner_username text COLLATE "C",
++ title text NOT NULL,
++ summary text,
++ city text,
++ lat double precision,
++ lon double precision,
++ timezone text NOT NULL DEFAULT 'UTC',
++ starts_at timestamptz NOT NULL,
++ closes_at timestamptz NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'voting', -- voting | decided | cancelled
++ winner_option_id text COLLATE "C",
++ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted', -- public | unlisted | hidden
++ revision bigint NOT NULL DEFAULT 1,
++ chat_username text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_plans_id CHECK (id ~ '^pln_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_plans_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
++ CONSTRAINT fun_plans_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_plans_owner_name CHECK (owner_username IS NULL OR length(owner_username) BETWEEN 1 AND 64),
++ CONSTRAINT fun_plans_title CHECK (length(title) BETWEEN 3 AND 120),
++ CONSTRAINT fun_plans_summary CHECK (summary IS NULL OR length(summary) <= 1200),
++ CONSTRAINT fun_plans_city CHECK (city IS NULL OR length(city) BETWEEN 1 AND 120),
++ CONSTRAINT fun_plans_lat CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
++ CONSTRAINT fun_plans_lon CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
++ CONSTRAINT fun_plans_timezone CHECK (length(timezone) BETWEEN 1 AND 80),
++ CONSTRAINT fun_plans_window CHECK (closes_at <= starts_at),
++ CONSTRAINT fun_plans_status CHECK (status IN ('voting', 'decided', 'cancelled')),
++ CONSTRAINT fun_plans_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden')),
++ CONSTRAINT fun_plans_revision CHECK (revision > 0),
++ CONSTRAINT fun_plans_chat CHECK (chat_username IS NULL OR chat_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$')
++);
++CREATE INDEX fun_plans_public ON fun_plans (visibility, status, starts_at ASC);
++CREATE INDEX fun_plans_by_owner ON fun_plans (owner_subject, created_at DESC);
++
++CREATE TABLE fun_plan_options (
++ id text COLLATE "C" PRIMARY KEY, -- pop_ + ULID
++ plan_id text COLLATE "C" NOT NULL REFERENCES fun_plans (id) ON DELETE CASCADE,
++ title text NOT NULL,
++ details text,
++ place_name text,
++ lat double precision,
++ lon double precision,
++ accessible boolean NOT NULL DEFAULT false,
++ price_note text,
++ starts_at timestamptz,
++ created_by text COLLATE "C", -- usr_…; NULL after deletion
++ position integer NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_plan_options_id CHECK (id ~ '^pop_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_plan_options_title CHECK (length(title) BETWEEN 1 AND 120),
++ CONSTRAINT fun_plan_options_details CHECK (details IS NULL OR length(details) <= 1200),
++ CONSTRAINT fun_plan_options_place CHECK (place_name IS NULL OR length(place_name) BETWEEN 1 AND 200),
++ CONSTRAINT fun_plan_options_lat CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
++ CONSTRAINT fun_plan_options_lon CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
++ CONSTRAINT fun_plan_options_price CHECK (price_note IS NULL OR length(price_note) <= 80),
++ CONSTRAINT fun_plan_options_created_by CHECK (created_by IS NULL OR created_by ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_plan_options_position CHECK (position BETWEEN 1 AND 10),
++ UNIQUE (plan_id, position)
++);
++CREATE INDEX fun_plan_options_by_plan ON fun_plan_options (plan_id, position);
++
++ALTER TABLE fun_plans
++ ADD CONSTRAINT fun_plans_winner_option FOREIGN KEY (winner_option_id) REFERENCES fun_plan_options (id) ON DELETE SET NULL;
++
++CREATE TABLE fun_plan_votes (
++ plan_id text COLLATE "C" NOT NULL REFERENCES fun_plans (id) ON DELETE CASCADE,
++ voter text COLLATE "C" NOT NULL,
++ subject text COLLATE "C",
++ option_id text COLLATE "C" NOT NULL REFERENCES fun_plan_options (id) ON DELETE CASCADE,
++ via text COLLATE "C" NOT NULL DEFAULT 'web',
++ chat_message_id text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (plan_id, voter),
++ CONSTRAINT fun_plan_votes_voter CHECK (voter ~ '^(usr_[0-9A-HJKMNP-TV-Z]{26}|chat:.{1,64})$'),
++ CONSTRAINT fun_plan_votes_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_plan_votes_via CHECK (via IN ('web', 'chat'))
++);
++CREATE INDEX fun_plan_votes_by_option ON fun_plan_votes (plan_id, option_id);
++CREATE UNIQUE INDEX fun_plan_votes_chat_message ON fun_plan_votes (chat_message_id) WHERE chat_message_id IS NOT NULL;
++
++-- ── Safety, chat ingestion and the OSM/Ticketmaster cache ─────
++CREATE TABLE fun_reports (
++ id text COLLATE "C" PRIMARY KEY, -- frp_ + ULID
++ target_kind text COLLATE "C" NOT NULL, -- quest | plan
++ target_id text COLLATE "C" NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL, -- safety | spam | other
++ reason text NOT NULL,
++ note text,
++ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | handled | dismissed
++ created_at timestamptz NOT NULL DEFAULT now(),
++ handled_at timestamptz,
++ handled_by text COLLATE "C",
++ CONSTRAINT fun_reports_id CHECK (id ~ '^frp_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_reports_target CHECK (target_kind IN ('quest', 'plan')),
++ CONSTRAINT fun_reports_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_reports_kind CHECK (kind IN ('safety', 'spam', 'other')),
++ CONSTRAINT fun_reports_reason CHECK (length(reason) BETWEEN 3 AND 2000),
++ CONSTRAINT fun_reports_note CHECK (note IS NULL OR length(note) <= 4000),
++ CONSTRAINT fun_reports_status CHECK (status IN ('open', 'handled', 'dismissed'))
++);
++CREATE INDEX fun_reports_open ON fun_reports (status, created_at);
++
++-- One row per public chat message already read, so a redelivered page is idempotent even when the command itself
++-- writes something else (a vote, a suggestion, a check).
++CREATE TABLE fun_chat_seen (
++ chat_username text COLLATE "C" NOT NULL,
++ message_id text COLLATE "C" NOT NULL,
++ seen_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (chat_username, message_id),
++ CONSTRAINT fun_chat_seen_username CHECK (chat_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$')
++);
++CREATE INDEX fun_chat_seen_at ON fun_chat_seen (seen_at);
++
++-- How far each public chat channel has been read. `after_id` is Chat's own cursor; the seen table above is the
++-- second, content-level guard, so a channel read twice still creates one vote.
++CREATE TABLE fun_chat_cursors (
++ chat_username text COLLATE "C" PRIMARY KEY,
++ last_message_id text COLLATE "C",
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_chat_cursors_username CHECK (chat_username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$')
++);
++
++-- The shared cache for Nominatim/Overpass and Ticketmaster. body is public JSON, never a secret and never a person.
++CREATE TABLE fun_geo_cache (
++ key text COLLATE "C" PRIMARY KEY,
++ body jsonb NOT NULL,
++ fetched_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz NOT NULL
++);
++CREATE INDEX fun_geo_cache_expiry ON fun_geo_cache (expires_at);
++
++-- The Search Fabric outbox (Meme/Today shape). A public quest or plan queues one document in the same transaction;
++-- hiding or deleting it queues a tombstone. Rows wait until OpenVibe.Search's document writer is configured for
++-- `fun` (the relay is deliberately not in v1; it needs the search.document.write grant).
++CREATE TABLE fun_outbox (
++ event_id text COLLATE "C" PRIMARY KEY,
++ topic text COLLATE "C" NOT NULL,
++ payload jsonb NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ published_at timestamptz,
++ attempts integer NOT NULL DEFAULT 0,
++ last_error text,
++ next_attempt_at timestamptz NOT NULL DEFAULT now(),
++ claimed_at timestamptz,
++ claimed_by text
++);
++CREATE INDEX fun_outbox_pending ON fun_outbox (next_attempt_at) WHERE published_at IS NULL;
++
++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes
++-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++CREATE TABLE account_data_events (
++ id text PRIMARY KEY,
++ kind text NOT NULL,
++ subject text NOT NULL,
++ outcome jsonb,
++ applied_at timestamptz NOT NULL DEFAULT now(),
++ sent_at timestamptz
++);
++
++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
++CREATE TABLE telemetry_samples (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at timestamptz NOT NULL,
++ service text NOT NULL,
++ project text,
++ subject text,
++ resource text,
++ provider text,
++ node text,
++ cell text,
++ region text,
++ operation text NOT NULL,
++ latency_ms double precision,
++ queue_delay_ms double precision,
++ ttfb_ms double precision,
++ throughput_per_second double precision,
++ bytes bigint,
++ status text,
++ cache_status text,
++ cost_estimate double precision,
++ route_epoch bigint,
++ trace_id text,
++ extra jsonb NOT NULL DEFAULT '{}'::jsonb
++);
++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..98df72c
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1407 @@
++{
++ "name": "openvibe-fun",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-fun",
++ "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",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.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.13",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
++ "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/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.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
++ "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/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.9",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
++ "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/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/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.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.229.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
++ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==",
++ "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.70.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.31.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
++ "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.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "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/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..ddff483
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,36 @@
++{
++ "name": "openvibe-fun",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Fun — something to do, alone, together, or live.",
++ "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": "FUN_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe"
++ ],
++ "author": "OpenVibers",
++ "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",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v${CONTRACTS_TAG:-0.235.0}",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/public/css/fun.css b/public/css/fun.css
nnew file mode 100644
iindex 0000000..f9f9283
---- /dev/null
++++ b/public/css/fun.css
@@@ -0,0 +1,114 @@
++/* OpenVibe.Fun: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
++ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
++ first; nothing scrolls sideways. Places come from OpenStreetMap and say so. */
++*, *::before, *::after { box-sizing: border-box; }
++html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
++.skip { position: absolute; left: -9999px; }
++.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
++.fu { display: block; }
++.fu a { color: var(--room-strong); }
++.fu .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
++.fu-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
++.fu-narrow { max-width: 620px; }
++.fu-page-pad { padding-top: clamp(28px, 6vw, 64px); }
++.fu h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
++.fu-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
++.fu-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
++.fu-page-h h1, .fu-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
++.fu-lede, .fu-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++.fu-fine, .fu-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
++.fu-section { margin-top: clamp(28px, 4vw, 44px); }
++.fu-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
++.fu-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
++.fu-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
++.fu-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
++.fu-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
++.fu-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
++.fu-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
++.fu-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
++.fu-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
++.fu-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
++.fu-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
++.fu-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
++.fu-empty p { margin: 0; line-height: 1.5; }
++.fu-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
++
++
++.fu-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.fu-form { display: grid; gap: 14px; }
++.fu-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
++.fu-field input, .fu-field select, .fu-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
++.fu-field input:focus, .fu-field select:focus, .fu-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
++.fu-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
++.fu-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
++.fu-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.fu-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
++.fu-eps { display: grid; gap: 10px; margin: 12px 0 0; }
++.fu-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.fu-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
++.fu-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
++
++
++.fu-inline { margin: 0; display: inline; }
++.fu-list { margin: 0; padding-left: 20px; display: grid; gap: 6px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
++.fu-badge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: .78rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); }
++.fu-status { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-muted, #7386a3) 16%, transparent); color: var(--text-secondary, #96a7c2); }
++.fu-status-accepted { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 18%, transparent); }
++.fu-status-decided { color: var(--warning-text, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 14%, transparent); }
++.fu-status-verified { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
++.fu-accessible { color: var(--success-text, #22c55e); font-weight: 700; }
++
++/* The motif: a city map where idea pins pop up and a quest card flips */
++.fu-map-motif { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 280; margin: 0 auto; border-radius: 22px; overflow: hidden; background: color-mix(in srgb, var(--room-accent) 8%, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); }
++.fu-map-lines { position: absolute; inset: 0; width: 100%; height: 100%; }
++.fu-map-lines path { fill: none; stroke: var(--text-muted, #7386a3); stroke-opacity: .35; stroke-width: 6; stroke-linecap: round; }
++.fu-pin { position: absolute; translate: -50% -100%; padding: 5px 10px; border-radius: 10px 10px 10px 2px; font-size: .78rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); box-shadow: 0 6px 16px rgba(0, 0, 0, .25); white-space: nowrap; }
++.fu-pin-1 { left: 22%; top: 30%; } .fu-pin-2 { left: 66%; top: 24%; } .fu-pin-3 { left: 40%; top: 72%; } .fu-pin-4 { left: 82%; top: 64%; }
++html.ov-motion .fu-map-motif .fu-pin { animation: fu-pop .5s cubic-bezier(.2,.9,.3,1.5) both; } .fu-pin-2 { animation-delay: .2s !important; } .fu-pin-3 { animation-delay: .4s !important; } .fu-pin-4 { animation-delay: .6s !important; }
++@keyframes fu-pop { from { opacity: 0; translate: -50% -60%; scale: .5; } }
++.fu-pin-static { position: static; translate: none; }
++.fu-flip-card { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; gap: 2px; width: min(70%, 260px); padding: 10px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 2px solid var(--room-accent); box-shadow: 0 12px 28px rgba(0, 0, 0, .3); transform-origin: center; }
++.fu-flip-card small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
++.fu-flip-card b { font-size: .95rem; line-height: 1.3; }
++.fu-flip-card span { font-size: .76rem; color: var(--text-muted, #7386a3); }
++html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .9s both; }
++@keyframes fu-flip { from { transform: perspective(600px) rotateX(90deg); opacity: 0; } }
++
++/* Scenes' demos */
++.fu-demo-board, .fu-demo-plan, .fu-demo-verified { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; width: min(100%, 420px); margin: 0 auto; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.fu-demo-board b, .fu-demo-plan b { font-size: 1.05rem; }
++.fu-demo-board small { flex-basis: 100%; color: var(--text-muted, #7386a3); }
++.fu-arrow { color: var(--room-strong); font-weight: 900; }
++
++/* Quests, plans, places, events, options */
++.fu-quests, .fu-plans, .fu-places, .fu-events, .fu-options, .fu-options-list, .fu-ideas { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
++.fu-quest-link, .fu-plan-link, .fu-place, .fu-event, .fu-option, .fu-idea { display: grid; gap: 6px; height: 100%; padding: 15px 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.fu-quest-link:hover, .fu-plan-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.fu-quest-link, .fu-plan-link { transition: border-color .15s, transform .15s; }
++.fu-quest-card, .fu-plan-card { display: grid; }
++.fu-quest-title, .fu-plan-title, .fu-place-body b, .fu-event-body b, .fu-option-body b { font-size: 1.04rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
++.fu-quest-meta, .fu-quest-foot, .fu-plan-meta, .fu-plan-foot, .fu-place-addr, .fu-place-type, .fu-event-meta, .fu-event-type, .fu-option-meta, .fu-hours { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-muted, #7386a3); }
++.fu-quest-detail, .fu-plan-detail, .fu-quest-body, .fu-place-body, .fu-event-body, .fu-option-body { display: grid; gap: 4px; min-width: 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
++.fu-quest-facts, .fu-place-facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; font-size: .82rem; }
++.fu-place-links, .fu-event-links { display: flex; flex-wrap: wrap; gap: 10px; font-size: .86rem; font-weight: 700; }
++.fu-winner { border-color: var(--room-accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 16%, transparent); }
++.fu-votes { display: flex; align-items: center; gap: 8px; font-size: .84rem; color: var(--text-muted, #7386a3); }
++.fu-vote-bar { flex: 1; height: 8px; border-radius: 999px; background: color-mix(in srgb, var(--text-muted, #7386a3) 18%, transparent); overflow: hidden; }
++.fu-vote-bar > * , .fu-vote-bar::before { display: block; height: 100%; background: var(--room-fill); }
++.fu-add-option { margin-top: 12px; }
++
++/* The guide's map */
++.fu-guide-page .fu-map, .fu-map { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--border, #1f2d47); background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); min-height: 220px; }
++.fu-map-bg, .fu-map-grid { position: absolute; inset: 0; }
++.fu-map-grid { background-image: linear-gradient(var(--border, #1f2d47) 1px, transparent 1px), linear-gradient(90deg, var(--border, #1f2d47) 1px, transparent 1px); background-size: 40px 40px; opacity: .5; }
++
++/* The Live overlay (a quest on a stream) */
++.fu-overlay { display: grid; gap: 4px; max-width: 420px; padding: 14px 16px; border-radius: 16px; background: color-mix(in srgb, #000 70%, transparent); color: #fff; border: 2px solid var(--room-accent); }
++.fu-overlay-kicker { font-size: .7rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-accent); }
++.fu-overlay-title { font-size: 1.15rem; font-weight: 800; }
++.fu-overlay-meta, .fu-overlay-fine, .fu-overlay-place { font-size: .8rem; opacity: .85; }
++.fu-report summary { cursor: pointer; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++
++@media (max-width: 759px) { .sc-room-pic .fu-pin, .sc-room-pic .fu-flip-card { display: none; } }
++@media (prefers-reduced-motion: reduce) { .fu-pin, .fu-flip-card { animation: none !important; } }
++html[data-ov-motion=reduced] .fu-pin, html[data-ov-motion=reduced] .fu-flip-card { animation: none !important; }
ddiff --git a/public/js/fun.js b/public/js/fun.js
nnew file mode 100644
iindex 0000000..64f0601
---- /dev/null
++++ b/public/js/fun.js
@@@ -0,0 +1 @@
++/* OpenVibe.Fun: what the pages are complete without. The main session designs this file; no page needs JavaScript. */
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..1e69811
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,184 @@
++'use strict';
++
++/**
++ * OpenVibe.Fun — Express app factory; server/index.js listens, tests build their own instance with a temp database
++ * and a mock Network/upstreams.
++ *
++ * /, /guide, /quests, /q/:slug, /live, /plans, /p/:slug, /developers the pages (http/pages.js)
++ * /api/v1/* the API (http/api.js)
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
++ * /internal/events account export/deletion (loopback only)
++ *
++ * The chat-vote reader and the OpenCoin retry timer are made here but started by server/index.js (and never under
++ * FUN_DRILL); tests drive their runOnce()/retryPending().
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createSso } = require('./auth/sso');
++const { createPrincipal } = require('./http/principal');
++const { createApi } = require('./http/api');
++const { createPageRoutes } = require('./http/pages');
++const { createServiceReadiness } = require('./observability');
++const { assetVersion, send } = require('./render/layout');
++const { raw } = require('./render/html');
++
++const accountDataLib = require('./identity/account-data');
++const { createStore } = require('./store');
++const { createSearchIndex } = require('./search');
++const { createUpstreams } = require('./upstreams');
++const { createGuide } = require('./guide');
++const { createUpstreams: createGuideUpstreams } = require('./guide/upstream');
++const { createCoins } = require('./coins');
++const { createChatSync } = require('./chat-sync');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++const { createHttpTelemetry } = require('openvibe-sdk/service');
++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
++
++/**
++ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a
++ * stand-in for Network's internal routes; tests only), telemetrySink.
++ */
++async function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const s = opts.store || await openStore(config, { now: opts.now, log });
++
++ const keys = createKeyStore({ config, fetchImpl, log });
++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
++ const principal = createPrincipal({ config, keys });
++
++ const search = createSearchIndex({ config, s });
++ const store = createStore(s, { search, config });
++ const upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });
++ const guideUpstream = createGuideUpstreams({ config, s, fetchImpl, now: s.now, log });
++ const guide = createGuide({ config, s, upstream: guideUpstream, now: s.now, log });
++ const coins = createCoins({ config, s, store, fetchImpl, log, now: s.now });
++ const chatSync = createChatSync({ config, s, store, upstreams, log, now: s.now });
++
++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
++ // client-credentials token; a test injects a stand-in through opts.accountSend.
++ const accountData = accountDataLib.create({ db: s.db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Fun cannot answer account events'); });
++
++ const ctx = {
++ config, s, keys, sso, principal, search, store, upstreams, guide, guideUpstream, coins, chatSync,
++ accountData, accountSend, log,
++ };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'fun', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'fun', release: release.release });
++ app.locals.metrics = metrics.registry;
++ ctx.telemetry = createHttpTelemetry({ service: 'fun', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
++ app.use(ctx.telemetry.middleware());
++ app.locals.ctx = ctx;
++
++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
++ ctx.valkey = valkey;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.chat'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-fun', name: 'OpenVibe.Fun', version: VERSION }));
++ release.mount(app, { registry: metrics.registry });
++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'fun', service: 'fun', host: 'openvibe.fun', name: 'OpenVibe.Fun', profile: 'ugc' })); }
++
++ // ── OpenVibe.Events → this service (loopback only) ──────
++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
++ // nginx answers 404 for /internal/.
++ app.use('/internal/events', accountData.consumer({
++ secrets: config.events.secrets,
++ send: accountSend,
++ now: s.now,
++ onEvent: async (ev) => `ignored:${ev.event_type}`,
++ }));
++
++ // ── Static assets (content-hashed ?v= → immutable) ──────
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, 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) }));
++ },
++ }));
++
++ // ── API ─────────────────────────────────────────────────
++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.FUN_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createPageRoutes(ctx));
++ app.use((req, res) => send(res, 404, {
++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
++ body: raw(require('./views').notFound()),
++ }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[OpenVibe.Fun]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..08cda6e
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
++ * (sign-in) and a person's Bearer token (the API).
++ *
++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
++ * this module: token.no_key answers 'signing key not loaded yet'.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null;
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[OpenVibe.Fun] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
++ }
++ async function ensure() { await refreshMirror(); }
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Fun] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++
++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Fun] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..4977c8a
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,260 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `fun`.
++ *
++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
++ * (?silent=1 adds prompt=none; ?next= a same-site path)
++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
++ *
++ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives
++ * in an httpOnly cookie (fun_at) no script can read; the refresh token (fun_rt) is httpOnly too. Neither is ever
++ * stored server-side, rendered or logged.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++
++const ACCESS_COOKIE = 'fun_at';
++const REFRESH_COOKIE = 'fun_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'fun_oauth';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++function ownOrigins(config) {
++ const out = new Set();
++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
++ }
++ return out;
++}
++
++/** Same-site relative paths only (never protocol-relative, never another origin). */
++function sanitizeNext(next, config) {
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
++ let u = null;
++ try { u = new URL(next); } catch { return '/'; }
++ if (!ownOrigins(config).has(u.origin)) return '/';
++ next = `${u.pathname}${u.search}${u.hash}`;
++ }
++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
++ return next;
++}
++
++function authorizeUrl(config, { state, challenge, silent = false }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ if (silent) q.set('prompt', 'none');
++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
++}
++
++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++
++ function setSession(res, data) {
++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, cookieBase());
++ res.clearCookie(REFRESH_COOKIE, cookieBase());
++ }
++
++ async function tokenGrant(fields) {
++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
++ let res;
++ try {
++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(10_000),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
++ { status: res.status, error: (data && data.error) || 'invalid_grant' });
++ }
++ return data;
++ }
++
++ /**
++ * A Fun session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.
++ */
++ async function verifySession(token) {
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
++ const c = v.claims;
++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
++ return v;
++ }
++
++ function viewerFromClaims(claims, token) {
++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ const role = typeof claims.role === 'string' ? claims.role : 'user';
++ return {
++ kind: 'user',
++ subject,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
++ role,
++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
++ token,
++ };
++ }
++
++ async function refresh(req, res) {
++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
++ return req.sessionRefresh;
++ }
++
++ async function doRefresh(req, res) {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!rt) return null;
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
++ setSession(res, data);
++ const v = await verifySession(data.access_token);
++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
++ } catch (err) {
++ if (err.status && err.status < 500) clearSession(res);
++ return null;
++ }
++ }
++
++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
++ function middleware() {
++ return async (req, res, next) => {
++ req.viewer = { kind: 'anonymous' };
++ const token = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!token) return next();
++ await keys.ensure();
++ const v = await verifySession(token);
++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
++ if (v.expired) {
++ const fresh = await refresh(req, res);
++ if (fresh) { req.viewer = fresh; return next(); }
++ }
++ if (keys.loaded()) clearSession(res);
++ next();
++ };
++ }
++
++ async function asViewer(req, res, fn) {
++ try {
++ return await fn(req.viewer.token);
++ } catch (err) {
++ if (!err || err.status !== 401) throw err;
++ const fresh = await refresh(req, res);
++ if (!fresh) throw err;
++ req.viewer = fresh;
++ return fn(fresh.token);
++ }
++ }
++
++ function routes() {
++ const r = express.Router();
++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
++
++ r.get('/login', (req, res) => {
++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
++ const state = crypto.randomBytes(16).toString('hex');
++ const { verifier, challenge } = pkcePair();
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.redirect(authorizeUrl(config, { state, challenge, silent }));
++ });
++
++ r.get('/callback', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ let flow = null;
++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
++ const next = flow ? sanitizeNext(flow.next, config) : '/';
++ const { code, state, error } = req.query;
++ if (error) {
++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
++ }
++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
++ }
++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
++ setSession(res, data);
++ res.set('Clear-Site-Data', '"cache"');
++ return res.redirect(next);
++ } catch (err) {
++ log.warn('[OpenVibe.Fun] sign-in exchange failed:', err.status || '', err.error || '');
++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
++ }
++ });
++
++ const sameOriginRequest = (req) => {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
++ const origin = req.get('origin');
++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
++ return req.method === 'GET';
++ };
++ r.get('/logout', (req, res, next) => {
++ if (sameOriginRequest(req)) return next();
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
++ + `<p>Sign out of OpenVibe.Fun?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
++ });
++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ r.all('/logout', async (req, res) => {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${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: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ clearSession(res);
++ res.set('Clear-Site-Data', '"cache"');
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ res.redirect(303, sanitizeNext(req.query.next, config));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const c = req.cookies || {};
++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
++ const v = req.viewer;
++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/chat-sync.js b/server/chat-sync.js
nnew file mode 100644
iindex 0000000..41e0138
---- /dev/null
++++ b/server/chat-sync.js
@@@ -0,0 +1,151 @@
++'use strict';
++
++/**
++ * Chat votes, read — never posted. The public OpenVibe.Chat history for the streamer's channel is read on a timer;
++ * a message is considered at most once (fun_chat_seen), and the commands it understands are:
++ *
++ * !quest vote <slug> one vote for that suggestion
++ * !quest suggest <text> a suggestion on the open board for this channel (safety-filtered)
++ * !quest verify <slug> a check towards the chat quorum on a completed quest
++ * !plan vote <slug> <number> one vote for option <number> on a group plan
++ *
++ * No message, account or token is written: a chat vote stores the public chat username, the message id and the
++ * choice. The job does nothing under FUN_DRILL; tests call runOnce().
++ */
++const { USERNAME_RE } = require('./store');
++
++const COMMAND_RE = /^!(quest|plan)\s+(vote|suggest|verify)\b\s*(.*)$/i;
++
++/** Chat ids are integers today; compare numerically when both are, else as strings. */
++function newer(a, b) {
++ if (b == null || b === '') return true;
++ if (/^\d+$/.test(String(a)) && /^\d+$/.test(String(b))) return BigInt(a) > BigInt(b);
++ return String(a) > String(b);
++}
++
++function parseCommand(message, username) {
++ const text = String(message || '').replace(/\s+/g, ' ').trim();
++ const m = COMMAND_RE.exec(text);
++ if (!m) return null;
++ const kind = m[1].toLowerCase();
++ const verb = m[2].toLowerCase();
++ const rest = m[3].trim();
++ if (!USERNAME_RE.test(String(username || ''))) return null;
++ if (kind === 'quest' && verb === 'vote') {
++ const slug = rest.split(/\s+/)[0];
++ return slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) ? { kind, verb, slug } : null;
++ }
++ if (kind === 'quest' && verb === 'suggest') return rest.length >= 3 ? { kind, verb, text: rest.slice(0, 600) } : null;
++ if (kind === 'quest' && verb === 'verify') {
++ const slug = rest.split(/\s+/)[0];
++ return slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) ? { kind, verb, slug } : null;
++ }
++ if (kind === 'plan' && verb === 'vote') {
++ const parts = rest.split(/\s+/);
++ const slug = parts[0];
++ const position = Number.parseInt(parts[1], 10);
++ return slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) && Number.isInteger(position) && position >= 1 && position <= 10
++ ? { kind, verb, slug, position } : null;
++ }
++ return null;
++}
++
++function createChatSync({ config, s, store, upstreams, log = console, now = () => Date.now() }) {
++ let timer = null;
++ let running = false;
++
++ async function boardsToRead() {
++ const rows = await s.db.many(
++ `SELECT DISTINCT chat_username FROM fun_quest_boards WHERE closes_at > now() - interval '1 day'
++ UNION SELECT DISTINCT chat_username FROM fun_plans WHERE status = 'voting' AND visibility <> 'hidden' AND chat_username IS NOT NULL
++ ORDER BY chat_username LIMIT 20`,
++ );
++ return rows.map((r) => r.chat_username).filter((u) => USERNAME_RE.test(String(u)));
++ }
++
++ async function cursorFor(username) {
++ const row = await s.db.maybe('SELECT last_message_id FROM fun_chat_cursors WHERE chat_username = $1', [username]);
++ return row ? row.last_message_id : null;
++ }
++
++ async function setCursor(username, messageId) {
++ await s.db.exec(
++ `INSERT INTO fun_chat_cursors (chat_username, last_message_id, updated_at) VALUES ($1, $2, now())
++ ON CONFLICT (chat_username) DO UPDATE SET last_message_id = EXCLUDED.last_message_id, updated_at = now()`,
++ [username, messageId == null ? null : String(messageId).slice(0, 80)],
++ );
++ }
++
++ /** Read one channel's public history and apply at most one command per new message. */
++ async function readChannel(username) {
++ const afterId = await cursorFor(username);
++ const page = await upstreams.chatHistory({ username, afterId, limit: config.quests.syncLimit });
++ const messages = Array.isArray(page.messages) ? page.messages : [];
++ const out = { messages: messages.length, votes: 0, suggestions: 0, checks: 0, planVotes: 0 };
++ let lastId = afterId;
++ for (const msg of messages) {
++ if (!msg || msg.id == null) continue;
++ const messageId = String(msg.id);
++ // Advance the cursor even for a deleted or ignored message, or it would be read forever.
++ if (newer(messageId, lastId)) lastId = messageId;
++ if (Number(msg.is_deleted) === 1) continue;
++ const seen = await s.db.maybe(
++ 'INSERT INTO fun_chat_seen (chat_username, message_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING message_id',
++ [username, messageId],
++ );
++ if (!seen) continue;
++ const command = parseCommand(msg.message, msg.username);
++ if (!command) continue;
++ const chatUsername = String(msg.username).slice(0, 64);
++ if (command.kind === 'quest' && command.verb === 'vote') {
++ const out2 = await store.castQuestVote({ slug: command.slug, chatUsername, via: 'chat', chatMessageId: messageId });
++ if (out2.ok) out.votes++;
++ } else if (command.kind === 'quest' && command.verb === 'suggest') {
++ const boards = await store.openBoardsForChat(username);
++ if (boards.length) {
++ const out2 = await store.createSuggestion({ boardId: boards[0].id, chatUsername, title: command.text, brief: command.text, category: 'near_me', source: 'chat', accessible: false });
++ if (out2.ok) out.suggestions++;
++ }
++ } else if (command.kind === 'quest' && command.verb === 'verify') {
++ const out2 = await store.recordQuestCheck({ slug: command.slug, chatUsername, via: 'chat', chatMessageId: messageId });
++ if (out2.ok) out.checks++;
++ } else if (command.kind === 'plan' && command.verb === 'vote') {
++ const out2 = await store.castPlanVote({ slug: command.slug, chatUsername, position: command.position, via: 'chat', chatMessageId: messageId });
++ if (out2.ok) out.planVotes++;
++ }
++ }
++ if (lastId != null && lastId !== afterId) await setCursor(username, lastId);
++ return out;
++ }
++
++ async function runOnce() {
++ if (running) return { skipped: 'already_running' };
++ running = true;
++ const total = { channels: 0, messages: 0, votes: 0, suggestions: 0, checks: 0, planVotes: 0, errors: 0 };
++ try {
++ for (const username of await boardsToRead()) {
++ total.channels++;
++ try {
++ const out = await readChannel(username);
++ total.messages += out.messages; total.votes += out.votes; total.suggestions += out.suggestions; total.checks += out.checks; total.planVotes += out.planVotes;
++ } catch (err) {
++ total.errors++;
++ log.warn(`[OpenVibe.Fun] chat read for @${username} failed: ${(err && err.message) || err}`);
++ }
++ }
++ } finally { running = false; }
++ return total;
++ }
++
++ function start() {
++ if (config.drill || timer) return null;
++ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Fun] chat sync failed:', (err && err.message) || err)); }, config.quests.syncIntervalMs);
++ if (timer.unref) timer.unref();
++ return timer;
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return { runOnce, readChannel, parseCommand, start, stop, status: () => ({ running, timer: Boolean(timer) }) };
++}
++
++module.exports = { createChatSync, parseCommand, COMMAND_RE };
ddiff --git a/server/coins.js b/server/coins.js
nnew file mode 100644
iindex 0000000..6656f9c
---- /dev/null
++++ b/server/coins.js
@@@ -0,0 +1,145 @@
++'use strict';
++
++/**
++ * OpenCoins for a verified IRL quest. OpenCoins are loyalty points, never money: Fun holds no balance and never
++ * reads one. The wallet belongs to OpenVibe.Network (POST /internal/coins/credit, network.coins-change-request@1),
++ * keyed by the Network user id, so a subject is resolved first (GET /internal/identity/resolve).
++ *
++ * Auth is this service's own client credentials for `fun`, audience openvibe.network, scopes
++ * `identity.subject.resolve network.coins.credit`. Off by default (`FUN_COINS=off`): the badge is still paid, the
++ * reward is recorded `skipped`, and nothing is sent. The daily caps are the same shape as Quest's. A failure is
++ * recorded and retried with the reward id as the idempotency key, so a retry cannot double-credit.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const APP_ID = 'fun';
++const AUDIENCE = 'openvibe.network';
++const SCOPES = 'identity.subject.resolve network.coins.credit';
++const TIMEOUT_MS = 5000;
++
++function createCoins({ config, s, store, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const enabled = config.coins.mode === 'on';
++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
++ let tokens = null;
++ if (enabled && config.oauth.clientSecret) {
++ tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: AUDIENCE, scope: SCOPES, fetchImpl,
++ });
++ }
++ let timer = null;
++ const stats = { credited: 0, skipped: 0, failed: 0, attempts: 0, last_at: null };
++
++ async function call(method, path, { query, body } = {}) {
++ if (!tokens) throw new Error('OpenCoins is on but the OAuth client secret is unset');
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (body) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url.toString(), { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ const json = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Network ${res.status}${json && (json.error || json.code) ? `: ${json.error || json.code}` : ''}`);
++ err.status = res.status;
++ throw err;
++ }
++ return json || {};
++ }
++
++ async function networkUserId(subject) {
++ try {
++ const out = await call('GET', '/internal/identity/resolve', { query: { subject_id: subject } });
++ const id = out && out.network_user_id;
++ return Number.isInteger(id) && id > 0 ? id : null;
++ } catch (err) {
++ if (err.status === 404) return null;
++ throw err;
++ }
++ }
++
++ async function creditedToday(subject) {
++ const row = await s.db.maybe("SELECT COALESCE(SUM(coins), 0)::int AS n FROM fun_quest_rewards WHERE subject = $1 AND state = 'credited' AND updated_at::date = (now() AT TIME ZONE 'UTC')::date", [subject]);
++ return Number(row && row.n) || 0;
++ }
++ async function creditedAllToday() {
++ const row = await s.db.maybe("SELECT COALESCE(SUM(coins), 0)::int AS n FROM fun_quest_rewards WHERE state = 'credited' AND updated_at::date = (now() AT TIME ZONE 'UTC')::date");
++ return Number(row && row.n) || 0;
++ }
++
++ async function credit({ reward, quest }) {
++ const userId = await networkUserId(reward.subject);
++ if (!userId) throw new Error('the Network has no account for this subject yet');
++ const out = await call('POST', '/internal/coins/credit', {
++ body: {
++ user_id: userId,
++ app_id: APP_ID,
++ amount: reward.coins,
++ reason: `fun:${quest.slug}`,
++ ref: `${String(config.baseUrl).replace(/\/+$/, '')}/q/${quest.slug}`,
++ idempotency_key: reward.id,
++ },
++ });
++ return Number.isInteger(out.balance) ? out.balance : null;
++ }
++
++ /** Decide what happens to one reward now. Never throws; records a safe reason. */
++ async function settle(reward, quest) {
++ if (!reward) return null;
++ if (!reward.coins) return store.setRewardState(reward.id, { state: 'skipped', detail: 'No OpenCoins are set for this quest.' });
++ if (!enabled) {
++ stats.skipped++;
++ return store.setRewardState(reward.id, { state: 'skipped', detail: 'OpenCoins are off (FUN_COINS); the badge is paid.' });
++ }
++ try {
++ const mine = await creditedToday(reward.subject);
++ if (mine + reward.coins > config.coins.dailyCap) {
++ stats.skipped++;
++ return store.setRewardState(reward.id, { state: 'skipped', detail: `the daily cap of ${config.coins.dailyCap} OpenCoins per person is reached` });
++ }
++ const everyone = await creditedAllToday();
++ if (everyone + reward.coins > config.coins.globalDailyCap) {
++ stats.skipped++;
++ return store.setRewardState(reward.id, { state: 'skipped', detail: `the network-wide daily cap of ${config.coins.globalDailyCap} OpenCoins is reached` });
++ }
++ stats.attempts++;
++ const balance = await credit({ reward, quest });
++ stats.credited++; stats.last_at = new Date(now()).toISOString();
++ return store.setRewardState(reward.id, { state: 'credited', detail: balance == null ? null : `balance ${balance}` });
++ } catch (err) {
++ stats.failed++;
++ const message = String((err && err.message) || err).slice(0, 200);
++ log.warn(`[OpenCoins] reward ${reward.id} not credited: ${message}`);
++ return store.setRewardState(reward.id, { state: 'failed', detail: message });
++ }
++ }
++
++ async function retryPending({ limit = 50 } = {}) {
++ if (!enabled) return { tried: 0, credited: 0, skipped: 0, failed: 0 };
++ const rows = await s.db.many(
++ `SELECT r.* FROM fun_quest_rewards r JOIN fun_quests q ON q.id = r.quest_id
++ WHERE r.state IN ('pending', 'failed') ORDER BY r.updated_at ASC LIMIT $1`, [limit]);
++ const out = { tried: rows.length, credited: 0, skipped: 0, failed: 0 };
++ for (const row of rows) {
++ const q = await s.db.maybe('SELECT slug FROM fun_quests WHERE id = $1', [row.quest_id]);
++ if (!q) continue;
++ const after = await settle(store.wireReward(row), q);
++ if (after && after.state === 'credited') out.credited++;
++ else if (after && after.state === 'skipped') out.skipped++;
++ else if (after) out.failed++;
++ }
++ if (rows.length) log.log(`[OpenCoins] retried ${rows.length}: ${out.credited} credited, ${out.skipped} skipped, ${out.failed} still failing`);
++ return out;
++ }
++
++ function start() {
++ if (!enabled || timer) return null;
++ timer = setInterval(() => { retryPending().catch((err) => log.warn('[OpenCoins] retry failed:', (err && err.message) || err)); }, config.coins.retryMs);
++ if (timer.unref) timer.unref();
++ return timer;
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return { enabled, settle, retryPending, start, stop, networkUserId, credit, status: () => ({ enabled, daily_cap: config.coins.dailyCap, global_daily_cap: config.coins.globalDailyCap, ...stats }) };
++}
++
++module.exports = { createCoins, APP_ID, AUDIENCE, SCOPES };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..6310f27
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++/**
++ * OpenVibe.Fun configuration. Every value comes from the environment (production: /etc/openvibe/fun.env,
++ * see .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++require('dotenv').config();
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const bool = (v, def = false) => (v == null || v === '' ? def : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 5140);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.fun' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ const ticketmasterKey = String(env.TICKETMASTER_API_KEY || env.FUN_TICKETMASTER_API_KEY || '').trim();
++
++ return {
++ service: 'fun',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ drill: bool(env.FUN_DRILL),
++ limits: {
++ minute: Math.max(1, int(env.FUN_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.FUN_LIMITS_HOUR, 3000)),
++ },
++
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.FUN_PGLITE_DIR || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:fun:' },
++
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS and (when on) OpenCoins.
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ audience: env.FUN_AUDIENCE || 'openvibe.fun',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'fun',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
++ events: {
++ secrets: String(env.FUN_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
++ url: trim(env.FUN_EVENTS_URL || env.EVENTS_URL || ''),
++ endpoint: env.FUN_EVENTS_ENDPOINT || '',
++ intervalMs: Math.max(500, int(env.FUN_EVENTS_INTERVAL_MS, 5000)),
++ scope: env.FUN_EVENTS_SCOPE || 'events.subscription.manage',
++ },
++
++ // The public APIs Fun reads: Live's channel status, Chat's public history (chat votes are read, never
++ // posted), and Today's public event API (called with the viewer's own Network token, never a service one).
++ upstreams: {
++ live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
++ chat: trim(env.OV_CHAT_URL || 'https://openvibe.chat'),
++ today: trim(env.OV_TODAY_URL || 'https://openvibe.today'),
++ timeoutMs: Math.max(500, int(env.FUN_UPSTREAM_TIMEOUT_MS, 8000)),
++ },
++
++ // OpenStreetMap: the same two community hosts Food uses, with the same obligations — identify every request,
++ // pace it, cache the answer, and keep a bounded around: query. Ticketmaster is off unless a key is configured.
++ osm: {
++ nominatimUrl: trim(env.FUN_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),
++ overpassUrl: trim(env.FUN_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),
++ userAgent: env.FUN_OSM_USER_AGENT || `OpenVibeFun/0.1 (+${baseUrl}; [email protected])`,
++ email: String(env.FUN_OSM_EMAIL || '').trim(),
++ minIntervalMs: Math.max(0, int(env.FUN_OSM_MIN_INTERVAL_MS, 1000)),
++ timeoutMs: Math.max(1000, int(env.FUN_OSM_TIMEOUT_MS, 20_000)),
++ retryAfterMs: Math.max(0, int(env.FUN_OSM_RETRY_AFTER_MS, 120_000)),
++ cacheTtlMs: Math.max(60_000, int(env.FUN_GEO_CACHE_TTL_MS, 7 * 24 * 60 * 60 * 1000)),
++ staleTtlMs: Math.max(60_000, int(env.FUN_GEO_STALE_TTL_MS, 30 * 24 * 60 * 60 * 1000)),
++ defaultRadiusKm: Math.min(50, Math.max(1, int(env.FUN_GUIDE_RADIUS_KM, 5))),
++ maxRadiusKm: Math.min(50, Math.max(1, int(env.FUN_GUIDE_MAX_RADIUS_KM, 25))),
++ maxResults: Math.min(60, Math.max(1, int(env.FUN_GUIDE_MAX_RESULTS, 30))),
++ },
++ ticketmaster: {
++ key: ticketmasterKey,
++ enabled: Boolean(ticketmasterKey),
++ url: trim(env.FUN_TICKETMASTER_URL || 'https://app.ticketmaster.com/discovery/v2/events.json'),
++ minIntervalMs: Math.max(0, int(env.FUN_TICKETMASTER_MIN_INTERVAL_MS, 1000)),
++ timeoutMs: Math.max(1000, int(env.FUN_TICKETMASTER_TIMEOUT_MS, 8000)),
++ ttlMs: Math.max(60_000, int(env.FUN_TICKETMASTER_TTL_MS, 15 * 60_000)),
++ staleTtlMs: Math.max(60_000, int(env.FUN_TICKETMASTER_STALE_TTL_MS, 24 * 60 * 60_000)),
++ radiusKm: Math.min(100, Math.max(1, int(env.FUN_TICKETMASTER_RADIUS_KM, 10))),
++ size: Math.min(50, Math.max(1, int(env.FUN_TICKETMASTER_SIZE, 12))),
++ },
++
++ // IRL quests: a chat verification needs this many distinct chat votes; a suggestion list is capped per board.
++ quests: {
++ verifyVotes: Math.max(1, int(env.FUN_QUEST_VERIFY_VOTES, 3)),
++ maxSuggestions: Math.min(100, Math.max(1, int(env.FUN_QUEST_MAX_SUGGESTIONS, 30))),
++ coins: Math.min(500, Math.max(0, int(env.FUN_QUEST_COINS, 25))),
++ syncIntervalMs: Math.max(15_000, int(env.FUN_CHAT_SYNC_INTERVAL_MS, 60_000)),
++ syncLimit: Math.min(500, Math.max(1, int(env.FUN_CHAT_SYNC_LIMIT, 200))),
++ },
++
++ // OpenCoins are loyalty points, never money. Off by default; when on, Fun asks Network for a credit with
++ // its own service token (identity.subject.resolve, network.coins.credit) and the same caps Quest uses.
++ coins: {
++ mode: bool(env.FUN_COINS) ? 'on' : 'off',
++ dailyCap: Math.max(0, int(env.FUN_COINS_DAILY_CAP, 50)),
++ globalDailyCap: Math.max(0, int(env.FUN_COINS_GLOBAL_DAILY_CAP, 5000)),
++ retryMs: Math.max(30_000, int(env.FUN_COINS_RETRY_MS, 5 * 60_000)),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..bf15b62
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,48 @@
++'use strict';
++
++/**
++ * OpenVibe.Fun's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
++ * Nothing written here is ever a secret. An OSM or Ticketmaster answer is public data; a person's vote,
++ * suggestion, plan or report is theirs to export and erase (server/identity/account-data.js).
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++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 today)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Fun] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'fun', 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: 'fun-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'fun', registry, log });
++}
++
++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx((t) => fn(t)),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/guide/events.js b/server/guide/events.js
nnew file mode 100644
iindex 0000000..4cc8fc4
---- /dev/null
++++ b/server/guide/events.js
@@@ -0,0 +1,100 @@
++'use strict';
++
++/**
++ * Ticketmaster Discovery, off unless TICKETMASTER_API_KEY is configured. It is a free API for event discovery;
++ * Fun never sells a ticket, never takes a payment and never calls a paid provider. The events are a second source
++ * beside OpenStreetMap: a name, a venue, local times and a public link. An event description is never passed
++ * through a look description and no image is proxied; the link goes to Ticketmaster.
++ */
++const { GuideError } = require('./upstream');
++
++const SOURCE = {
++ name: 'Ticketmaster',
++ url: 'https://www.ticketmaster.com/',
++ via: 'Discovery API v2',
++ fetched_note: 'fetched by OpenVibe.Fun and cached for 15 minutes',
++};
++
++const safeUrl = (v) => {
++ if (typeof v !== 'string' || !v.trim()) return null;
++ try { const u = new URL(v.trim()); return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null; } catch { return null; }
++};
++
++const clean = (v, n = 300) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n) || null;
++
++function dateOf(ev) {
++ const d = ev && ev.dates && ev.dates.start ? ev.dates.start : {};
++ if (d.dateTime) {
++ const t = new Date(d.dateTime);
++ if (!Number.isNaN(t.getTime())) return { starts_at: t.toISOString(), local_date: d.localDate || null, local_time: d.localTime || null, timezone: ev.dates.timezone || null };
++ }
++ if (d.localDate) {
++ const t = new Date(`${d.localDate}T${d.localTime || '00:00:00'}Z`);
++ if (!Number.isNaN(t.getTime())) return { starts_at: t.toISOString(), local_date: d.localDate, local_time: d.localTime || null, timezone: ev.dates.timezone || null };
++ }
++ return null;
++}
++
++/** One Discovery event → Fun's wire shape. Missing pieces are omitted, never invented. */
++function eventOf(ev) {
++ if (!ev || !ev.name) return null;
++ const when = dateOf(ev);
++ const venue = ev._embedded && Array.isArray(ev._embedded.venues) ? ev._embedded.venues[0] : null;
++ const classification = ev.classifications && ev.classifications[0] ? ev.classifications[0] : null;
++ const segment = classification && classification.segment ? clean(classification.segment.name, 80) : null;
++ const genre = classification && classification.genre ? clean(classification.genre.name, 80) : null;
++ return {
++ id: `tm-${String(ev.id || '').slice(0, 80)}`,
++ kind: 'event',
++ source: 'ticketmaster',
++ name: clean(ev.name, 200),
++ url: safeUrl(ev.url),
++ ...(when || {}),
++ venue: venue ? clean(venue.name, 160) : null,
++ city: venue && venue.city ? clean(venue.city.name, 120) : null,
++ address: venue && venue.address ? clean(venue.address.line1, 200) : null,
++ lat: venue && venue.location && Number.isFinite(Number(venue.location.latitude)) ? Number(venue.location.latitude) : null,
++ lon: venue && venue.location && Number.isFinite(Number(venue.location.longitude)) ? Number(venue.location.longitude) : null,
++ segment,
++ genre,
++ info: clean(ev.info, 500),
++ map_url: venue && venue.location && Number.isFinite(Number(venue.location.latitude)) && Number.isFinite(Number(venue.location.longitude))
++ ? `https://www.openstreetmap.org/?mlat=${venue.location.latitude}&mlon=${venue.location.longitude}#map=16/${venue.location.latitude}/${venue.location.longitude}`
++ : null,
++ };
++}
++
++async function searchEvents({ city = null, lat = null, lon = null, radiusKm = null, size = null, start = null, end = null } = {}, { config, upstream }) {
++ if (!config.ticketmaster.enabled) return { configured: false, events: [], source: SOURCE };
++ const params = {
++ size: Math.min(50, Math.max(1, Number(size) || config.ticketmaster.size)),
++ sort: 'date,asc',
++ locale: '*',
++ };
++ const radius = Math.min(100, Math.max(1, Number(radiusKm) || config.ticketmaster.radiusKm));
++ if (lat != null && lat !== '' && lon != null && lon !== '' && Number.isFinite(Number(lat)) && Number.isFinite(Number(lon))) {
++ params.latlong = `${Number(lat)},${Number(lon)}`;
++ params.radius = String(Math.round(radius * 0.621371));
++ params.unit = 'miles';
++ } else if (city) {
++ params.city = String(city).slice(0, 120);
++ params.radius = String(Math.round(radius * 0.621371));
++ params.unit = 'miles';
++ } else {
++ throw new GuideError('fun.guide.invalid', 'Ticketmaster needs a city or a point', { status: 422 });
++ }
++ if (start) params.startDateTime = new Date(start).toISOString().replace(/\.\d{3}Z$/, 'Z');
++ if (end) params.endDateTime = new Date(end).toISOString().replace(/\.\d{3}Z$/, 'Z');
++ const answer = await upstream.ticketmaster(params);
++ const list = answer.body && answer.body._embedded && Array.isArray(answer.body._embedded.events) ? answer.body._embedded.events : [];
++ return {
++ configured: true,
++ events: list.map(eventOf).filter(Boolean),
++ fetched_at: answer.fetched_at,
++ cached: Boolean(answer.cached),
++ stale: Boolean(answer.stale),
++ source: SOURCE,
++ };
++}
++
++module.exports = { searchEvents, eventOf, dateOf, SOURCE };
ddiff --git a/server/guide/index.js b/server/guide/index.js
nnew file mode 100644
iindex 0000000..a9fdff5
---- /dev/null
++++ b/server/guide/index.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * The local guide: OpenStreetMap places first, Ticketmaster events when a key is configured, and nothing else. This
++ * is deterministic in v1 — a short, checked set of filters, no model call, no new AI capability. The page shows a
++ * map link, opening hours/times and public links, and every answer carries OSM's attribution.
++ */
++const { searchPlaces } = require('./places');
++const { searchEvents } = require('./events');
++
++function createGuide({ config, s, upstream, now = () => Date.now(), log = console }) {
++ async function search(params = {}) {
++ const places = await searchPlaces(upstream, params, config);
++ let events = { configured: Boolean(config.ticketmaster.enabled), events: [], source: null };
++ let eventsError = null;
++ if (config.ticketmaster.enabled) {
++ try {
++ events = await searchEvents({
++ city: places.location.name || (places.location.display_name || '').split(',')[0] || null,
++ lat: places.location.lat,
++ lon: places.location.lon,
++ radiusKm: places.radius_km,
++ size: config.ticketmaster.size,
++ start: new Date(Math.floor(now() / 3_600_000) * 3_600_000).toISOString(),
++ end: new Date(Math.floor(now() / 3_600_000) * 3_600_000 + 30 * 86_400_000).toISOString(),
++ }, { config, upstream });
++ } catch (err) {
++ eventsError = String((err && err.message) || err).slice(0, 200);
++ log.warn(`[OpenVibe.Fun] Ticketmaster unavailable: ${eventsError}`);
++ }
++ }
++ return {
++ ...places,
++ events: events.events || [],
++ events_configured: Boolean(config.ticketmaster.enabled),
++ events_error: eventsError,
++ ticketmaster: events.source || null,
++ };
++ }
++ return { search, ATTRIBUTION: require('./places').ATTRIBUTION };
++}
++
++module.exports = { createGuide };
ddiff --git a/server/guide/places.js b/server/guide/places.js
nnew file mode 100644
iindex 0000000..ac76667
---- /dev/null
++++ b/server/guide/places.js
@@@ -0,0 +1,200 @@
++'use strict';
++
++/**
++ * "What's fun near me": places from OpenStreetMap through Overpass, bounded to one radius and one small set of tags.
++ * The shapes are the ones Food/Tools use for OSM places; the filters are Fun's own (outside, inside for a rainy day,
++ * arts, food, kids, accessible, cheap). Everything returned is © OpenStreetMap contributors, ODbL 1.0, and every
++ * caller shows the attribution.
++ */
++const { GuideError } = require('./upstream');
++
++const ATTRIBUTION = {
++ text: '© OpenStreetMap contributors',
++ url: 'https://www.openstreetmap.org/copyright',
++ license: 'ODbL 1.0',
++ license_url: 'https://opendatacommons.org/licenses/odbl/1-0/',
++};
++const SOURCE = {
++ name: 'OpenStreetMap',
++ url: 'https://www.openstreetmap.org/',
++ via: 'Overpass API',
++ license: 'ODbL 1.0',
++ fetched_note: 'fetched by OpenVibe.Fun and cached for 7 days',
++};
++
++const KINDS = Object.freeze(['all', 'outside', 'inside', 'arts', 'food']);
++const TYPE_LABEL = {
++ attraction: 'Attraction', museum: 'Museum', gallery: 'Gallery', viewpoint: 'Viewpoint', zoo: 'Zoo',
++ theme_park: 'Theme park', artwork: 'Public art', park: 'Park', playground: 'Playground',
++ miniature_golf: 'Mini golf', water_park: 'Water park', bowling_alley: 'Bowling', sports_centre: 'Sports centre',
++ amusement_arcade: 'Arcade', cinema: 'Cinema', theatre: 'Theatre', planetarium: 'Planetarium',
++ arts_centre: 'Arts centre', cafe: 'Café', restaurant: 'Restaurant', pub: 'Pub', bar: 'Bar', ice_cream: 'Ice cream',
++};
++const INDOOR = new Set(['museum', 'gallery', 'cinema', 'theatre', 'planetarium', 'arts_centre', 'bowling_alley', 'amusement_arcade', 'cafe', 'restaurant', 'pub', 'bar', 'ice_cream']);
++const FAMILY = new Set(['zoo', 'theme_park', 'playground', 'water_park', 'miniature_golf', 'bowling_alley', 'amusement_arcade', 'museum', 'cinema']);
++
++const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
++
++function haversineKm(aLat, aLon, bLat, bLon) {
++ const R = 6371.0088;
++ const toRad = (d) => d * Math.PI / 180;
++ const dLat = toRad(bLat - aLat);
++ const dLon = toRad(bLon - aLon);
++ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
++ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
++}
++
++function safeUrl(v) {
++ if (typeof v !== 'string' || !v.trim()) return null;
++ try {
++ const u = new URL(v.trim());
++ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
++ } catch { return null; }
++}
++
++function formatAddress(tags) {
++ return [tags['addr:housenumber'], tags['addr:street'], tags['addr:city'], tags['addr:state'], tags['addr:postcode']]
++ .map((p) => (p == null ? '' : String(p).trim())).filter(Boolean).join(', ') || null;
++}
++
++function typeOf(tags) {
++ return tags.tourism || tags.leisure || tags.amenity || 'place';
++}
++
++/** The Overpass query for the selected filters, always an around: radius and a single out center tags. */
++function queryFor({ kind = 'all', kids = false, rainy = false } = {}, lat, lon, radiusM) {
++ const around = `(around:${Math.round(radiusM)},${lat},${lon})`;
++ const clauses = new Set();
++ const add = (tag, values) => {
++ for (const value of values) clauses.add(`nwr["${tag}"="${value}"]${around};`);
++ };
++ if (kind === 'all' || kind === 'outside') {
++ add('tourism', ['attraction', 'viewpoint', 'zoo', 'theme_park', 'artwork']);
++ add('leisure', ['park', 'playground', 'miniature_golf', 'water_park', 'sports_centre', 'bowling_alley']);
++ }
++ if (kind === 'all' || kind === 'inside' || rainy) {
++ add('tourism', ['museum', 'gallery']);
++ add('amenity', ['cinema', 'theatre', 'arts_centre', 'planetarium']);
++ add('leisure', ['bowling_alley', 'amusement_arcade']);
++ }
++ if (kind === 'arts') {
++ add('tourism', ['artwork', 'gallery', 'museum']);
++ add('amenity', ['arts_centre', 'theatre']);
++ }
++ if (kind === 'food') add('amenity', ['cafe', 'restaurant', 'pub', 'bar', 'ice_cream']);
++ if (kids) {
++ add('tourism', ['zoo', 'theme_park']);
++ add('leisure', ['playground', 'water_park', 'miniature_golf']);
++ }
++ // A filter that somehow selected nothing still gets a safe, tiny query rather than an unbounded one.
++ if (!clauses.size) add('tourism', ['attraction']);
++ return `[out:json][timeout:25];(\n${[...clauses].join('\n')}\n);out center tags;`;
++}
++
++/** One Overpass element → one result, or null when it is not a usable place. */
++function toResult(el, origin, { kind = 'all', kids = false, rainy = false, cheap = false, accessible = false } = {}) {
++ const lat = el.lat != null ? Number(el.lat) : (el.center && Number(el.center.lat));
++ const lon = el.lon != null ? Number(el.lon) : (el.center && Number(el.center.lon));
++ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
++ const tags = el.tags || {};
++ const type = typeOf(tags);
++ if (kind === 'food' && !['cafe', 'restaurant', 'pub', 'bar', 'ice_cream'].includes(type)) return null;
++ const indoor = tags.indoor === 'yes' || INDOOR.has(type);
++ const family = FAMILY.has(type) || tags.family === 'yes';
++ const wheelchair = tags.wheelchair === 'yes' ? true : tags.wheelchair === 'no' ? false : null;
++ if (rainy && !indoor) return null;
++ if (kids && !family) return null;
++ if (accessible && wheelchair !== true) return null;
++ if (cheap && tags.fee === 'yes') return null;
++ return {
++ id: `osm-${el.type}-${el.id}`,
++ osm_type: el.type,
++ osm_id: el.id,
++ osm_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
++ kind: 'place',
++ type,
++ type_label: TYPE_LABEL[type] || type.replace(/_/g, ' '),
++ name: tags.name || tags.operator || (TYPE_LABEL[type] || 'A place'),
++ lat, lon,
++ distance_km: Math.round(haversineKm(origin.lat, origin.lon, lat, lon) * 10) / 10,
++ address: formatAddress(tags),
++ opening_hours: tags.opening_hours || null,
++ website: safeUrl(tags.website || tags['contact:website'] || tags.url),
++ phone: tags.phone || tags['contact:phone'] || null,
++ wheelchair,
++ indoor,
++ family,
++ fee: tags.fee === 'no' ? false : tags.fee === 'yes' ? true : null,
++ map_url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=17/${lat}/${lon}`,
++ };
++}
++
++function elementsToResults(elements, origin, filters, limit) {
++ const seen = new Set();
++ const out = [];
++ for (const el of elements || []) {
++ const r = toResult(el, origin, filters);
++ if (!r) continue;
++ const key = `${r.name.toLowerCase()}@${r.lat.toFixed(4)},${r.lon.toFixed(4)}`;
++ if (seen.has(key)) continue;
++ seen.add(key);
++ out.push(r);
++ }
++ out.sort((a, b) => a.distance_km - b.distance_km || a.name.localeCompare(b.name));
++ return limit ? out.slice(0, limit) : out;
++}
++
++function radiusMetres(radiusKm, config) {
++ const max = (config && config.osm.maxRadiusKm) || 25;
++ return Math.round(clamp(Number(radiusKm) || (config && config.osm.defaultRadiusKm) || 5, 0.5, max) * 1000);
++}
++
++/**
++ * The place search behind GET /api/v1/guide and /guide. A name is geocoded once through Nominatim; the area query
++ * is cached through Overpass. Throws GuideError (fun.guide.invalid on bad input, fun.guide.not_found on an unknown
++ * place, fun.guide.upstream when OSM cannot be reached).
++ */
++async function searchPlaces(upstream, { q = null, lat = null, lon = null, radiusKm = null, kind = 'all', kids = false, rainy = false, cheap = false, accessible = false, limit = null } = {}, config) {
++ const k = String(kind || 'all');
++ if (!KINDS.includes(k)) throw new GuideError('fun.guide.invalid', `kind: ${KINDS.join(', ')}`, { status: 422 });
++ let place = null;
++ if (lat != null && lat !== '' && lon != null && lon !== '' && Number.isFinite(Number(lat)) && Number.isFinite(Number(lon))) {
++ const la = Number(lat); const lo = Number(lon);
++ if (la < -90 || la > 90 || lo < -180 || lo > 180) throw new GuideError('fun.guide.invalid', 'lat and lon must be a real point', { status: 422 });
++ place = { name: null, display_name: null, lat: la, lon: lo, from: 'coordinates' };
++ } else if (q && String(q).trim()) {
++ const g = await upstream.geocode(q, { limit: 1 });
++ const hit = Array.isArray(g.body) ? g.body[0] : null;
++ if (!hit) throw new GuideError('fun.guide.not_found', `no place called “${String(q).slice(0, 80)}”; try a town and region, or a postcode`, { status: 404 });
++ const la = Number(hit.lat); const lo = Number(hit.lon);
++ if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new GuideError('fun.guide.upstream', 'OpenStreetMap gave no coordinates for that place');
++ place = { name: String(q).trim(), display_name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, geocode_cached: g.cached };
++ } else {
++ throw new GuideError('fun.guide.invalid', 'q: a place to search for (or lat and lon)', { status: 422 });
++ }
++
++ const radiusM = radiusMetres(radiusKm, config);
++ const filters = { kind: k, kids: Boolean(kids), rainy: Boolean(rainy), cheap: Boolean(cheap), accessible: Boolean(accessible) };
++ const query = queryFor(filters, place.lat, place.lon, radiusM);
++ const answer = await upstream.overpass(query);
++ const cap = clamp(Number(limit) || (config && config.osm.maxResults) || 30, 1, (config && config.osm.maxResults) || 30);
++ const results = elementsToResults(answer.body && answer.body.elements, place, filters, cap);
++ return {
++ location: place,
++ filters,
++ radius_km: radiusM / 1000,
++ results,
++ counts: { total: results.length, accessible: results.filter((r) => r.wheelchair === true).length, indoor: results.filter((r) => r.indoor).length },
++ query,
++ fetched_at: answer.fetched_at,
++ cached: Boolean(answer.cached),
++ stale: Boolean(answer.stale),
++ attribution: ATTRIBUTION,
++ source: SOURCE,
++ };
++}
++
++module.exports = {
++ searchPlaces, queryFor, toResult, elementsToResults, radiusMetres, haversineKm, safeUrl, typeOf,
++ ATTRIBUTION, SOURCE, KINDS, TYPE_LABEL, INDOOR, FAMILY,
++};
ddiff --git a/server/guide/upstream.js b/server/guide/upstream.js
nnew file mode 100644
iindex 0000000..3078872
---- /dev/null
++++ b/server/guide/upstream.js
@@@ -0,0 +1,172 @@
++'use strict';
++
++/**
++ * The only outbound hosts the local guide reads: Nominatim (a place name → coordinates), Overpass (OpenStreetMap
++ * places within a small radius) and, only when TICKETMASTER_API_KEY is configured, Ticketmaster's Discovery API.
++ * Each is a community or free service with published usage rules, and this module is what keeps us inside them:
++ *
++ * identify every request carries FUN_OSM_USER_AGENT (an OpenVibe.Fun contact URL), never a browser's
++ * pace one request a second per host by default; calls to a host queue behind each other
++ * cache every answer is kept in fun_geo_cache for 7 days (OSM) or 15 minutes (Ticketmaster); a place is
++ * geocoded once, an area queried once, not once per visitor
++ * stale if a host is down, the last answer is served from the cache rather than losing the page
++ * bound Overpass is one `around:` radius and one set of tags, never a bulk download
++ *
++ * Only a URL built from configured bases is fetched; nothing a caller sends becomes a URL. No Ticketmaster key is
++ * ever put in a cache key, a log line or an error message.
++ */
++const crypto = require('crypto');
++
++class GuideError extends Error {
++ constructor(code, detail, { status = 502 } = {}) { super(detail); this.code = code; this.status = status; }
++}
++
++/** Serialises calls to one host and keeps at least `minIntervalMs` between the moments they start. */
++function createPacer({ minIntervalMs, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
++ let chain = Promise.resolve();
++ let last = -Infinity;
++ return {
++ run(fn) {
++ const p = chain.then(async () => {
++ const wait = last + minIntervalMs - now();
++ if (wait > 0) await sleep(wait);
++ last = now();
++ return fn();
++ });
++ chain = p.then(() => undefined, () => undefined);
++ return p;
++ },
++ };
++}
++
++const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const keyOf = (q) => String(q || '').trim().toLowerCase().replace(/\s+/g, ' ');
++
++function createUpstreams({ config, s, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const osm = config.osm;
++ const tm = config.ticketmaster;
++ const db = s.db;
++ const failed = new Map();
++ const pacers = new Map();
++ const pacerFor = (rawUrl, minIntervalMs) => {
++ const host = new URL(rawUrl).host;
++ if (!pacers.has(host)) pacers.set(host, createPacer({ minIntervalMs, now }));
++ return pacers.get(host);
++ };
++
++ async function cacheGet(key, { stale = false } = {}) {
++ const row = await db.maybe('SELECT body, fetched_at, expires_at FROM fun_geo_cache WHERE key = $1', [key]);
++ if (!row) return null;
++ const expired = new Date(row.expires_at).getTime() <= now();
++ if (expired && !stale) return null;
++ return { body: row.body, fetched_at: new Date(row.fetched_at).toISOString(), expires_at: new Date(row.expires_at).toISOString(), stale: expired };
++ }
++
++ async function cachePut(key, body, ttlMs) {
++ const fetchedAt = new Date(now()).toISOString();
++ const expiresAt = new Date(now() + ttlMs).toISOString();
++ await db.exec(
++ `INSERT INTO fun_geo_cache (key, body, fetched_at, expires_at) VALUES ($1, $2::jsonb, $3::timestamptz, $4::timestamptz)
++ ON CONFLICT (key) DO UPDATE SET body = EXCLUDED.body, fetched_at = EXCLUDED.fetched_at, expires_at = EXCLUDED.expires_at`,
++ [key, JSON.stringify(body), fetchedAt, expiresAt],
++ );
++ return { fetched_at: fetchedAt, expires_at: expiresAt };
++ }
++
++ async function fetchJson(url, init = {}, { timeoutMs = osm.timeoutMs, minIntervalMs = osm.minIntervalMs, what = 'OpenStreetMap' } = {}) {
++ let res;
++ try {
++ res = await pacerFor(url.toString(), minIntervalMs).run(() => fetchImpl(url.toString(), {
++ ...init,
++ headers: {
++ 'User-Agent': osm.userAgent,
++ Accept: 'application/json',
++ ...(init.headers || {}),
++ },
++ signal: AbortSignal.timeout(timeoutMs),
++ }));
++ } catch (err) {
++ throw new GuideError('fun.guide.upstream', `${what} could not be reached (${err && err.name === 'TimeoutError' ? 'timed out' : 'network error'})`);
++ }
++ if (!res.ok) throw new GuideError('fun.guide.upstream', `${what} answered ${res.status}`);
++ try {
++ return await res.json();
++ } catch {
++ throw new GuideError('fun.guide.upstream', `${what} answered something that is not JSON`);
++ }
++ }
++
++ async function cached(key, fn, { ttlMs, failedWithinMs = 0, force = false, what = 'OpenStreetMap' } = {}) {
++ const hit = force ? null : await cacheGet(key);
++ if (hit) return { ...hit, cached: true };
++ if (failedWithinMs && !force && now() - (failed.get(key) || -Infinity) < failedWithinMs) {
++ const old = await cacheGet(key, { stale: true });
++ if (old) return { ...old, cached: true, stale: true };
++ throw new GuideError('fun.guide.upstream', `${what} was busy a moment ago. Try again in a few minutes.`);
++ }
++ let body;
++ try {
++ body = await fn();
++ failed.delete(key);
++ } catch (err) {
++ if (failedWithinMs) {
++ if (failed.size > 1000) failed.clear();
++ failed.set(key, now());
++ }
++ const old = await cacheGet(key, { stale: true });
++ if (old) return { ...old, cached: true, stale: true };
++ throw err;
++ }
++ const written = await cachePut(key, body, ttlMs);
++ return { body, cached: false, stale: false, fetched_at: written.fetched_at, expires_at: written.expires_at };
++ }
++
++ /** A place name → at most five matches (Nominatim search), cached for the OSM TTL. */
++ function geocode(q, { limit = 1 } = {}) {
++ const query = String(q || '').trim().slice(0, 200);
++ if (!query) return Promise.reject(new GuideError('fun.guide.invalid', 'q: a place to search for', { status: 422 }));
++ const url = new URL(osm.nominatimUrl);
++ url.pathname = `${url.pathname.replace(/\/+$/, '')}/search`;
++ const params = new URLSearchParams({ q: query, format: 'jsonv2', limit: String(Math.min(Math.max(limit, 1), 5)), addressdetails: '1' });
++ if (osm.email) params.set('email', osm.email);
++ url.search = params.toString();
++ const key = `nominatim:v1:${sha(`${keyOf(query)}:${limit}`)}`;
++ return cached(key, () => fetchJson(url, {}, { what: 'OpenStreetMap Nominatim' }), { ttlMs: osm.cacheTtlMs, failedWithinMs: osm.retryAfterMs, what: 'OpenStreetMap Nominatim' });
++ }
++
++ /** One bounded Overpass query → its elements, cached by the exact query. */
++ function overpass(query) {
++ const body = `data=${encodeURIComponent(String(query))}`;
++ const key = `overpass:v1:${sha(query)}`;
++ return cached(key, () => fetchJson(new URL(osm.overpassUrl), {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
++ body,
++ }, { what: 'OpenStreetMap Overpass' }), { ttlMs: osm.cacheTtlMs, failedWithinMs: osm.retryAfterMs, what: 'OpenStreetMap Overpass' });
++ }
++
++ /** Ticketmaster Discovery, only when a key is configured. The key is a query parameter at the edge and is never cached. */
++ function ticketmaster(params) {
++ if (!tm.enabled || !tm.key) return Promise.reject(new GuideError('fun.guide.disabled', 'Ticketmaster is not configured', { status: 503 }));
++ const url = new URL(tm.url);
++ const query = new URLSearchParams();
++ for (const [k, v] of Object.entries(params || {})) if (v != null && v !== '') query.set(k, String(v));
++ query.set('apikey', tm.key);
++ url.search = query.toString();
++ const cacheParams = { ...params };
++ const key = `ticketmaster:v1:${sha(JSON.stringify(cacheParams))}`;
++ return cached(key, () => fetchJson(url, {}, {
++ timeoutMs: tm.timeoutMs, minIntervalMs: tm.minIntervalMs, what: 'Ticketmaster',
++ }), { ttlMs: tm.ttlMs, failedWithinMs: 60_000, what: 'Ticketmaster' });
++ }
++
++ /** When an OSM answer expires (an ISO time), or null. Used by the page's "cached" note. */
++ async function cachedUntil(query) {
++ const hit = await cacheGet(`overpass:v1:${sha(query)}`);
++ return hit ? hit.expires_at : null;
++ }
++
++ return { geocode, overpass, ticketmaster, cacheGet, cachePut, cachedUntil, fetchJson, GuideError };
++}
++
++module.exports = { createUpstreams, createPacer, GuideError, sha, keyOf };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..cfc4192
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,347 @@
++'use strict';
++
++/**
++ * /api/v1 — OpenVibe.Fun's JSON API.
++ *
++ * GET /guide?q=&kind=&accessible=&rainy=&kids=&cheap=&lat=&lon=&radius= OSM places + optional Ticketmaster
++ * GET /quests?limit=&cursor=&status=&city= public IRL quests, newest first
++ * GET /quests/ideas the safe starter deck
++ * GET /quests/boards/:id one board and its suggestions (share link)
++ * GET /quests/:slug one quest, its votes and checks
++ * POST /quests/boards a streamer opens a board (Live username checked)
++ * POST /quests/boards/:id/suggest a signed-in suggestion
++ * POST /quests/boards/:id/idea add one safe idea (owner/staff)
++ * POST /quests/:slug/vote one vote per person or chat username
++ * POST /quests/:slug/accept|live|complete|verify the streamer (or staff) moves a quest on
++ * POST /quests/:slug/hide staff hides a public quest
++ * POST /quests/:slug/report a safety report
++ * GET /plans?limit=&cursor=&city= public group plans
++ * GET /plans/:slug one plan, options and votes
++ * POST /plans a signed-in person starts a plan
++ * POST /plans/:slug/options|vote|decide|today add, vote, decide, add to Today
++ * POST /plans/:slug/hide staff
++ * POST /plans/:slug/report a report
++ * GET /reports staff: open reports
++ *
++ * Errors are application/problem+json with a `code`. A person is the fun_at session cookie or a Network user token
++ * as a Bearer; a session write must come from openvibe.fun itself (403 fun.cross_site). No route needs a service
++ * token. The one outbound service write (Today) uses the person's own token, never Fun's.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const { isStaff } = require('../identity/staff');
++const ideas = require('../quests/ideas');
++const { planEvent } = require('../today');
++
++function createApi(ctx) {
++ const { config, s, principal, store, guide, upstreams, coins } = ctx;
++ const r = asyncRouter();
++
++ r.use(express.json({ limit: '64kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.use(principal.middleware);
++
++ // Per-actor limits on top of the per-address limit in server/app.js.
++ const buckets = new Map();
++ function windowCount(key, windowMs, limit) {
++ const now = Date.now();
++ let b = buckets.get(key);
++ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }
++ b.n += 1;
++ return b.n <= limit;
++ }
++ r.use((req, res, next) => {
++ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {
++ const who = `user:${req.principal.subject}`;
++ if (!windowCount(`${who}:m`, 60_000, config.limits.minute) || !windowCount(`${who}:h`, 60 * 60_000, config.limits.hour)) {
++ return problem(res, 429, 'fun.rate_limited', 'too many requests from this account; retry shortly');
++ }
++ if (buckets.size > 20_000) for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);
++ }
++ return next();
++ });
++
++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
++ const invalid = (res, detail, field = null) => problem(res, 400, 'fun.invalid', field ? `${field}: ${detail}` : detail);
++ const refuse = (res, out) => problem(res, out.status || 400, out.code || 'fun.invalid', out.message || 'invalid request');
++ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++ const bool = (v) => v === true || v === '1' || v === 'true' || v === 'yes' || v === 'on';
++
++ function person(req, res) {
++ const p = req.principal;
++ if (p.kind === 'anonymous') { problem(res, 401, 'fun.sign_in_required', 'Sign in at openvibe.fun, or send a person\'s Network token as a Bearer.'); return null; }
++ if (p.kind !== 'user') { problem(res, 403, 'fun.person_required', 'This route is for a person, not an app or service.'); return null; }
++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
++ problem(res, 403, 'fun.cross_site', 'A signed-in request that changes something must come from openvibe.fun itself.');
++ return null;
++ }
++ return p;
++ }
++ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
++ const requireStaff = (req, res, next) => {
++ if (!person(req, res)) return undefined;
++ if (!isStaff(req.principal)) return problem(res, 403, 'fun.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');
++ return next();
++ };
++
++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
++
++ // ── Local guide ─────────────────────────────────────────
++ r.get('/guide', async (req, res) => {
++ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : 'all';
++ const lat = req.query.lat == null || req.query.lat === '' ? null : Number(req.query.lat);
++ const lon = req.query.lon == null || req.query.lon === '' ? null : Number(req.query.lon);
++ if ((lat != null && !Number.isFinite(lat)) || (lon != null && !Number.isFinite(lon))) return invalid(res, 'lat and lon must be numbers', 'lat');
++ const radius = req.query.radius == null || req.query.radius === '' ? null : Number(req.query.radius);
++ if (radius != null && (!Number.isFinite(radius) || radius <= 0)) return invalid(res, 'radius must be a positive number of kilometres', 'radius');
++ try {
++ const out = await guide.search({
++ q: typeof req.query.q === 'string' ? req.query.q : null,
++ lat, lon, radiusKm: radius, kind,
++ accessible: bool(req.query.accessible), rainy: bool(req.query.rainy), kids: bool(req.query.kids), cheap: bool(req.query.cheap),
++ limit: asInt(req.query.limit, config.osm.maxResults),
++ });
++ res.set('Cache-Control', 'public, max-age=300');
++ return res.json(out);
++ } catch (err) {
++ if (err && err.code) return problem(res, err.status || 502, err.code, err.message || 'the guide could not answer');
++ throw err;
++ }
++ });
++
++ // ── Quests: public reads ────────────────────────────────
++ r.get('/quests/ideas', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ return res.json({ count: ideas.IDEAS.length, ideas: ideas.IDEAS });
++ });
++
++ r.get('/quests/boards/:id', async (req, res) => {
++ const out = await store.boardPage(String(req.params.id || ''));
++ if (!out) return problem(res, 404, 'fun.board_not_found', 'No such quest board.');
++ return res.json(out);
++ });
++
++ r.get('/quests', async (req, res) => {
++ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50', 'limit');
++ const status = typeof req.query.status === 'string' && req.query.status ? req.query.status : null;
++ if (status && !store.PUBLIC_QUEST_STATUSES.includes(status)) return invalid(res, `status must be one of ${store.PUBLIC_QUEST_STATUSES.join(', ')}`, 'status');
++ const out = await store.listQuests({
++ limit, status, city: typeof req.query.city === 'string' && req.query.city ? req.query.city : null,
++ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
++ });
++ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out', 'cursor');
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json({ count: out.quests.length, quests: out.quests, next_cursor: out.next_cursor });
++ });
++
++ r.get('/quests/:slug', async (req, res) => {
++ const subject = req.principal.kind === 'user' ? req.principal.subject : null;
++ const out = await store.questForPage(String(req.params.slug || ''), { subject, staff: isStaff(req.principal) });
++ if (!out) return problem(res, 404, 'fun.quest_not_found', 'No such quest.');
++ res.set('Cache-Control', out.quest.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
++ return res.json(out);
++ });
++
++ // ── Quests: a streamer's board and suggestions ──────────
++ r.post('/quests/boards', requirePerson, async (req, res) => {
++ const p = req.principal;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const liveUsername = String(b.live_username || p.username || '').trim();
++ if (!liveUsername) return invalid(res, 'live_username is required', 'live_username');
++ let channel;
++ try { channel = await upstreams.liveChannel(liveUsername); } catch (err) { return problem(res, err.status || 502, err.code || 'fun.live.unavailable', err.message); }
++ if (!channel.found) return problem(res, 404, 'fun.channel_not_found', 'No OpenVibe.Live channel has that username.');
++ if (!p.username || String(p.username).toLowerCase() !== String(channel.username).toLowerCase()) {
++ return problem(res, 403, 'fun.streamer_username', 'The signed-in Network username must match the Live channel it opens a board for.');
++ }
++ const out = await store.createBoard({
++ streamerSubject: p.subject, title: b.title, liveUsername: channel.username, chatUsername: b.chat_username || channel.username,
++ city: b.city, lat: b.lat, lon: b.lon, accessibleOnly: bool(b.accessible_only), maxSuggestions: b.max_suggestions, closesAt: b.closes_at,
++ });
++ if (!out.ok) return refuse(res, out);
++ res.set('Location', `/api/v1/quests/boards/${out.board.id}`);
++ return res.status(201).json(out.board);
++ });
++
++ r.post('/quests/boards/:id/suggest', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.createSuggestion({
++ boardId: String(req.params.id || ''), subject: req.principal.subject,
++ title: b.title, brief: b.brief, category: b.category, placeName: b.place_name,
++ lat: b.lat, lon: b.lon, accessible: bool(b.accessible), source: 'suggestion',
++ });
++ if (!out.ok) return refuse(res, out);
++ res.set('Location', `/api/v1/quests/${out.quest.slug}`);
++ return res.status(201).json(out.quest);
++ });
++
++ r.post('/quests/boards/:id/idea', requirePerson, async (req, res) => {
++ const boardId = String(req.params.id || '');
++ const board = await store.boardById(boardId);
++ if (!board) return problem(res, 404, 'fun.board_not_found', 'No such quest board.');
++ if (!isStaff(req.principal) && board.streamer_subject !== req.principal.subject) return problem(res, 403, 'fun.streamer_required', 'Only the streamer whose board this is can add an idea.');
++ const out0 = await store.boardPage(boardId);
++ const remaining = ideas.forBoard({ accessibleOnly: Boolean(board.accessible_only), excludeTitles: (out0 ? out0.quests : []).map((q) => q.title) });
++ const idea = remaining[0] || null;
++ if (!idea) return problem(res, 409, 'fun.no_idea', 'There is no unused safe idea left for this board.');
++ const out = await store.createSuggestion({
++ boardId, subject: req.principal.subject, title: idea.title, brief: idea.brief, category: idea.category,
++ accessible: Boolean(idea.accessible), source: 'idea',
++ });
++ if (!out.ok) return refuse(res, out);
++ res.set('Location', `/api/v1/quests/${out.quest.slug}`);
++ return res.status(201).json(out.quest);
++ });
++
++ // ── Quests: votes, acceptance, completion, verification ──
++ r.post('/quests/:slug/vote', requirePerson, async (req, res) => {
++ const out = await store.castQuestVote({ slug: String(req.params.slug || ''), subject: req.principal.subject, via: 'web' });
++ if (!out.ok) return refuse(res, out);
++ return res.json({ quest_id: out.quest_id, slug: out.slug, votes: out.votes });
++ });
++
++ r.post('/quests/:slug/accept', requirePerson, async (req, res) => {
++ const out = await store.acceptQuest({ slug: String(req.params.slug || ''), subject: req.principal.subject, staff: isStaff(req.principal) });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.quest);
++ });
++
++ for (const to of ['live', 'completed']) {
++ const path = to === 'live' ? '/quests/:slug/live' : '/quests/:slug/complete';
++ r.post(path, requirePerson, async (req, res) => {
++ const out = await store.setQuestStatus({ slug: String(req.params.slug || ''), subject: req.principal.subject, staff: isStaff(req.principal), to });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.quest);
++ });
++ }
++
++ r.post('/quests/:slug/verify', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const via = b.via === 'moderator' ? 'moderator' : 'chat';
++ const out = await store.verifyQuest({ slug: String(req.params.slug || ''), subject: req.principal.subject, staff: isStaff(req.principal), via });
++ if (!out.ok) return refuse(res, out);
++ let reward = out.reward || null;
++ if (reward) reward = await coins.settle(reward, out.quest);
++ return res.json({ ...out.quest, reward });
++ });
++
++ r.post('/quests/:slug/hide', requireStaff, async (req, res) => {
++ const out = await store.hideQuest({ slug: String(req.params.slug || ''), staff: true });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.quest);
++ });
++
++ r.post('/quests/:slug/report', requirePerson, async (req, res) => {
++ const quest = await store.byQuestSlug(String(req.params.slug || ''));
++ if (!quest) return problem(res, 404, 'fun.quest_not_found', 'No such quest.');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.createReport({ targetKind: 'quest', targetId: quest.id, subject: req.principal.subject, kind: b.kind || 'safety', reason: b.reason, note: b.note });
++ if (!out.ok) return refuse(res, out);
++ return res.status(201).json({ id: out.report.id, status: out.report.status, created_at: out.report.created_at });
++ });
++
++ // ── Group plans ─────────────────────────────────────────
++ r.get('/plans', async (req, res) => {
++ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50', 'limit');
++ const out = await store.listPlans({
++ limit, city: typeof req.query.city === 'string' && req.query.city ? req.query.city : null,
++ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
++ });
++ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out', 'cursor');
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json({ count: out.plans.length, plans: out.plans, next_cursor: out.next_cursor });
++ });
++
++ r.get('/plans/:slug', async (req, res) => {
++ const subject = req.principal.kind === 'user' ? req.principal.subject : null;
++ const out = await store.planForPage(String(req.params.slug || ''), { subject, staff: isStaff(req.principal) });
++ if (!out) return problem(res, 404, 'fun.plan_not_found', 'No such plan.');
++ res.set('Cache-Control', out.plan.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
++ return res.json(out);
++ });
++
++ r.post('/plans', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.createPlan({
++ ownerSubject: req.principal.subject, ownerUsername: req.principal.username,
++ title: b.title, summary: b.summary, city: b.city, lat: b.lat, lon: b.lon, timezone: b.timezone,
++ startsAt: b.starts_at, closesAt: b.closes_at, visibility: b.visibility, chatUsername: b.chat_username,
++ options: Array.isArray(b.options) ? b.options : [],
++ });
++ if (!out.ok) return refuse(res, out);
++ res.set('Location', `/api/v1/plans/${out.plan.slug}`);
++ return res.status(201).json(out.plan);
++ });
++
++ r.post('/plans/:slug/options', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.addPlanOption({
++ slug: String(req.params.slug || ''), subject: req.principal.subject, staff: isStaff(req.principal),
++ title: b.title, details: b.details, placeName: b.place_name, lat: b.lat, lon: b.lon,
++ accessible: bool(b.accessible), priceNote: b.price_note, startsAt: b.starts_at,
++ });
++ if (!out.ok) return refuse(res, out);
++ return res.status(201).json(out.option);
++ });
++
++ r.post('/plans/:slug/vote', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.castPlanVote({
++ slug: String(req.params.slug || ''), subject: req.principal.subject,
++ optionId: b.option_id ? String(b.option_id) : null, position: b.position == null ? null : b.position, via: 'web',
++ });
++ if (!out.ok) return refuse(res, out);
++ return res.json({ plan_id: out.plan_id, option_id: out.option_id, position: out.position });
++ });
++
++ r.post('/plans/:slug/decide', requirePerson, async (req, res) => {
++ const out = await store.decidePlan({ slug: String(req.params.slug || ''), subject: req.principal.subject, staff: isStaff(req.principal) });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.plan);
++ });
++
++ r.post('/plans/:slug/today', requirePerson, async (req, res) => {
++ const out = await store.planForToday(String(req.params.slug || ''));
++ if (!out) return problem(res, 404, 'fun.plan_not_found', 'No such plan.');
++ if (out.error === 'not_decided') return problem(res, 409, 'fun.plan_not_decided', 'Decide the plan before adding it to Today.');
++ if (!out.winner) return problem(res, 409, 'fun.plan_no_winner', 'This plan has no chosen option.');
++ const plan = out.plan;
++ if (new Date(plan.starts_at).getTime() < Date.now() + 5 * 60_000) return problem(res, 409, 'fun.plan_too_late', 'The plan starts too soon to add it to Today.');
++ if (!req.principal.token) return problem(res, 401, 'fun.sign_in_required', 'A person\'s Network token is needed to add a plan to Today.');
++ const body = planEvent(config, plan, out.winner);
++ try {
++ const created = await upstreams.todayCreate({ token: req.principal.token, body, idempotencyKey: `fun-plan-${plan.id}` });
++ return res.status(201).json({ event_id: created.id, url: created.url });
++ } catch (err) {
++ return problem(res, err.status || 502, err.code || 'fun.today.unavailable', err.message);
++ }
++ });
++
++ r.post('/plans/:slug/hide', requireStaff, async (req, res) => {
++ const out = await store.hidePlan({ slug: String(req.params.slug || ''), staff: true });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.plan);
++ });
++
++ r.post('/plans/:slug/report', requirePerson, async (req, res) => {
++ const plan = await store.planBySlug(String(req.params.slug || ''));
++ if (!plan) return problem(res, 404, 'fun.plan_not_found', 'No such plan.');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.createReport({ targetKind: 'plan', targetId: plan.id, subject: req.principal.subject, kind: b.kind || 'safety', reason: b.reason, note: b.note });
++ if (!out.ok) return refuse(res, out);
++ return res.status(201).json({ id: out.report.id, status: out.report.status, created_at: out.report.created_at });
++ });
++
++ r.get('/reports', requireStaff, async (req, res) => {
++ const reports = await store.listOpenReports({ limit: asInt(req.query.limit, 100) });
++ return res.json({ count: reports.length, reports });
++ });
++
++ return r;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..ff7df63
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,119 @@
++'use strict';
++
++/**
++ * Crawl artifacts for openvibe.fun: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public quests and public
++ * plans are for search engines; sign-in, the API, the internal route, a board share link, a plan's share page and
++ * the OBS overlay are not. The sitemap lists every public quest and plan.
++ */
++const fs = require('fs');
++const path = require('path');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const { asyncRouter } = require('./router');
++
++const SITE_NAME = 'OpenVibe.Fun';
++const DESCRIPTION = 'OpenVibe.Fun is something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans the group votes on.';
++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/overlay/', '/quests/boards/', '/quests/new', '/live', '/plans/new'];
++
++function dayOf(ts) {
++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
++ return m ? m[1] : null;
++}
++function siteUpdated() {
++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
++}
++
++function homeJsonLd(config) {
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ return [
++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'EntertainmentApplication', keywords: 'openvibe fun, irl quests, local guide, openstreetmap, group plans, things to do' }),
++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ ];
++}
++
++function publicPages() {
++ return [
++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Fun home', text: 'IRL quests on Live, the local guide and group plans.' },
++ { path: '/guide', changefreq: 'weekly', priority: 0.9, title: 'The local guide', text: 'What is fun near me? Places from OpenStreetMap and local events when Ticketmaster is configured.' },
++ { path: '/quests', changefreq: 'daily', priority: 0.9, title: 'IRL quests on Live', text: 'Safe real-world challenges suggested by viewers, voted by chat and accepted by a streamer.' },
++ { path: '/plans', changefreq: 'daily', priority: 0.8, title: 'Group plans', text: 'Two or three options, one vote each, the winner goes to Today.' },
++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Fun API', text: 'The JSON API, the Search documents and the guide sources.' },
++ ];
++}
++
++function createDiscoveryRoutes(ctx) {
++ const { config, store } = ctx;
++ const r = asyncRouter();
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
++ const pages = publicPages();
++
++ r.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(
++ '# openvibe.fun: the public quests, plans and guide are for search engines; sign-in, the API, boards,\n'
++ + '# private plans and the overlay are not.\n'
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ });
++
++ r.get('/llms.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
++ name: SITE_NAME,
++ summary: 'OpenVibe.Fun is the room for something to do. IRL quests let a streamer\'s chat suggest safe real-world challenges, vote with !quest vote, and have the completion checked by chat or a moderator; the badge is paid locally and OpenCoins are loyalty points only when configured. The local guide answers "what\'s fun near me" from OpenStreetMap (Nominatim and Overpass, with a contact User-Agent, one request a second per host, a bounded radius and a seven-day cache) and Ticketmaster when a key is configured (off by default). Group plans have options, one vote per person, and a winner that the viewer can add to their own OpenVibe.Today with their own token.',
++ details: 'GET /api/v1/guide?q=&kind=&accessible=&rainy=&kids=&cheap= answers with places (ODbL, © OpenStreetMap contributors) and, only when configured, Ticketmaster events. GET /api/v1/quests lists public IRL quests; GET /api/v1/quests/:slug adds votes and checks. GET /api/v1/plans lists public plans; GET /api/v1/plans/:slug adds options and votes. Chat votes are read from the public OpenVibe.Chat history; Fun never posts. No route needs a service token, and the one outbound write to Today uses the signed-in person\'s own token.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'OpenVibe.Fun', url: abs('/'), note: 'something to do, alone, together, or live' },
++ { title: 'The local guide', url: abs('/guide') },
++ { title: 'IRL quests', url: abs('/quests') },
++ { title: 'Group plans', url: abs('/plans') },
++ { title: 'The API', url: abs('/developers') },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Public quests (JSON)', url: abs('/api/v1/quests') },
++ { title: 'Public plans (JSON)', url: abs('/api/v1/plans') },
++ { title: 'Sitemap', url: abs('/sitemap.xml') },
++ { title: 'Full text for language models', url: abs('/llms-full.txt') },
++ { title: 'Release metadata (JSON)', url: abs('/release.json') },
++ ] },
++ { title: 'Elsewhere', links: [
++ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'the streamer and the chat' },
++ { title: 'OpenVibe.Chat', url: 'https://openvibe.chat', note: 'where !quest and !plan votes are typed' },
++ { title: 'OpenVibe.Today', url: 'https://openvibe.today', note: 'the plan goes to your own calendar' },
++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and staff roles' },
++ ] },
++ ],
++ }));
++ });
++
++ r.get('/llms-full.txt', async (_req, res) => {
++ const [quests, plans] = await Promise.all([store.publicQuestsForLlms(2000), store.publicPlansForLlms(2000)]);
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
++ site: SITE_NAME,
++ summary: 'Every public page of OpenVibe.Fun: the guide, public IRL quests, public group plans and the API.',
++ base: site,
++ maxBytes: 512 * 1024,
++ sections: [
++ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
++ { title: 'Quests', pages: quests.map((q) => ({ url: `/q/${q.slug}`, title: q.title, text: [q.category, q.status, q.city, q.brief].filter(Boolean).join(' · ') })) },
++ { title: 'Plans', pages: plans.map((p) => ({ url: `/plans/${p.slug}`, title: p.title, text: [p.status, p.city, p.summary].filter(Boolean).join(' · ') })) },
++ ],
++ }));
++ });
++
++ r.get('/sitemap.xml', async (_req, res) => {
++ const lastmod = siteUpdated();
++ const [quests, plans] = await Promise.all([store.publicQuestsForSitemap(5000), store.publicPlansForSitemap(5000)]);
++ const urls = [
++ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
++ ...quests.map((e) => ({ loc: abs(`/q/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),
++ ...plans.map((e) => ({ loc: abs(`/plans/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),
++ ];
++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
++ });
++
++ return r;
++}
++
++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..72f8639
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,379 @@
++'use strict';
++
++/**
++ * Fun's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real link.
++ * Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
++ * `private, no-store`. The overlay is the one page that is not the Frame: an OBS browser source, noindex, refreshed
++ * by a meta tag so it works with JavaScript off.
++ *
++ * / the room, featured quests, plans and the guide teaser
++ * /guide the local guide form and its OSM/Ticketmaster answer
++ * /quests public IRL quests and the safe idea deck
++ * /quests/new open a board (a streamer; the Live channel is checked)
++ * /quests/boards/:id one board, its suggestions and the vote/accept forms
++ * /q/:slug one quest, its votes/checks, the streamer actions and the report form
++ * /live a streamer's boards and quests
++ * /plans, /plans/new group plans and the form
++ * /plans/:slug one plan: options, votes, decide and add-to-Today
++ * /developers, 404, /overlay/q/:slug
++ */
++const express = require('express');
++const seo = require('openvibe-shared/seo');
++const { asyncRouter } = require('./router');
++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
++const { sameOrigin } = require('./principal');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++const { isStaff } = require('../identity/staff');
++const { planEvent } = require('../today');
++const ideas = require('../quests/ideas');
++const views = require('../views');
++
++const PUBLIC_CACHE = 'public, max-age=60';
++
++function createPageRoutes(ctx) {
++ const { config, s, store, upstreams, guide, coins } = ctx;
++ const r = asyncRouter();
++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++ const base = () => String(config.baseUrl).replace(/\/+$/, '');
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ const staffViewer = (req) => isStaff(req.viewer);
++ const signInPrompt = (what, next) => `<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
++ const notFromHere = (req, res) => page(req, res, {
++ title: 'Not from this site',
++ body: raw('<div class="fu-wrap fu-page-pad fu-narrow"><div class="fu-empty"><h1>That came from somewhere else</h1><p class="fu-muted">Use the form on <a href="/">OpenVibe.Fun</a>.</p></div></div>'),
++ }, 403);
++ const guard = (req, res, next) => {
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.split('?')[0])}`);
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ return next();
++ };
++ const formValues = (body, fields) => Object.fromEntries(fields.map((f) => [f, body && body[f] != null ? String(body[f]) : '']));
++
++ async function liveOf(username) {
++ try { return await upstreams.liveChannel(username); } catch { return null; }
++ }
++
++ // ── Home ─────────────────────────────────────────────────
++ r.get('/', async (req, res) => {
++ const [counts, featured, planOut] = await Promise.all([store.counts(), store.featuredQuests(6), store.listPlans({ limit: 4 })]);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: null,
++ description: 'Something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans that the group votes on.',
++ jsonLd: homeJsonLd(config),
++ body: raw(views.home({ counts, featured, plans: planOut.plans })),
++ });
++ });
++
++ // ── The local guide ─────────────────────────────────────
++ r.get('/guide', async (req, res) => {
++ const values = {
++ q: typeof req.query.q === 'string' ? req.query.q : '',
++ kind: typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : 'all',
++ radius: typeof req.query.radius === 'string' ? req.query.radius : '',
++ accessible: req.query.accessible === '1', rainy: req.query.rainy === '1',
++ kids: req.query.kids === '1', cheap: req.query.cheap === '1',
++ };
++ let result = null; let problem = null; let status = 200;
++ if (values.q.trim()) {
++ try {
++ result = await guide.search({
++ q: values.q, kind: values.kind, radiusKm: values.radius ? Number(values.radius) : null,
++ accessible: values.accessible, rainy: values.rainy, kids: values.kids, cheap: values.cheap,
++ });
++ } catch (err) {
++ problem = (err && err.message) || 'The guide could not answer just now.';
++ status = (err && err.status) || 502;
++ }
++ }
++ return page(req, res, {
++ index: true, cache: result ? PUBLIC_CACHE : null,
++ title: 'The local guide',
++ description: 'What\'s fun near me? Places from OpenStreetMap and local events from Ticketmaster when a key is configured, with a map, times and links.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun local guide', url: `${base()}/guide`, description: 'Places from OpenStreetMap, local events when Ticketmaster is configured.', siteUrl: base() })],
++ body: raw(views.guide({ values, result, error: problem })),
++ }, status);
++ });
++
++ // ── IRL quests ──────────────────────────────────────────
++ r.get('/quests', async (req, res) => {
++ const out = await store.listQuests({ limit: 30 });
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'IRL quests on Live',
++ description: 'Viewers suggest, chat votes, the streamer accepts and a moderator or the chat verifies. Safe, accessible challenges for the real world.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'IRL quests on OpenVibe.Live', url: `${base()}/quests`, description: 'Safe IRL challenges for streamers and their chat.', siteUrl: base() })],
++ body: raw(views.quests({ quests: out.quests, ideas: ideas.IDEAS })),
++ });
++ });
++ r.get('/quests/ideas', (req, res) => res.redirect(303, '/quests#fu-ideas'));
++
++ r.get('/quests/new', (req, res) => {
++ if (!signedIn(req)) return page(req, res, { title: 'Open a quest board', body: raw(views.signIn({ title: 'Open a quest board', text: 'A board belongs to the streamer whose Live channel it is.', next: '/quests/new' })) }, 401);
++ return page(req, res, {
++ index: false, title: 'Open a quest board',
++ body: raw(views.questNew({ values: { live_username: req.viewer.username || '' } })),
++ });
++ });
++
++ r.post('/quests/boards', guard, async (req, res) => {
++ const b = req.body || {};
++ const liveUsername = String(b.live_username || req.viewer.username || '').trim();
++ let channel;
++ try { channel = await upstreams.liveChannel(liveUsername); } catch (err) {
++ return page(req, res, { title: 'Open a quest board', body: raw(views.questNew({ values: formValues(b, ['live_username', 'title', 'city', 'closes_at', 'max_suggestions', 'chat_username']), problem: err.message })) }, err.status || 502);
++ }
++ if (!channel.found) return page(req, res, { title: 'Open a quest board', body: raw(views.questNew({ values: formValues(b, ['live_username', 'title', 'city', 'closes_at', 'max_suggestions', 'chat_username']), problem: 'No OpenVibe.Live channel has that username.' })) }, 404);
++ if (!req.viewer.username || String(req.viewer.username).toLowerCase() !== String(channel.username).toLowerCase()) {
++ return page(req, res, { title: 'Open a quest board', body: raw(views.questNew({ values: formValues(b, ['live_username', 'title', 'city', 'closes_at', 'max_suggestions', 'chat_username']), problem: 'The signed-in Network username must match the Live channel.' })) }, 403);
++ }
++ const out = await store.createBoard({
++ streamerSubject: req.viewer.subject, title: b.title, liveUsername: channel.username, chatUsername: b.chat_username || channel.username,
++ city: b.city, accessibleOnly: b.accessible_only === '1', maxSuggestions: b.max_suggestions, closesAt: b.closes_at,
++ });
++ if (!out.ok) return page(req, res, { title: 'Open a quest board', body: raw(views.questNew({ values: formValues(b, ['live_username', 'title', 'city', 'closes_at', 'max_suggestions', 'chat_username']), problem: out.message })) }, out.status || 400);
++ return res.redirect(303, `/quests/boards/${encodeURIComponent(out.board.id)}`);
++ });
++
++ r.get('/quests/boards/:id', async (req, res) => {
++ const out = await store.boardPage(String(req.params.id || ''));
++ if (!out) return notFound(req, res);
++ const rawBoard = await store.boardById(String(req.params.id || ''));
++ const mine = Boolean(signedIn(req) && rawBoard && rawBoard.streamer_subject === req.viewer.subject);
++ const live = await liveOf(out.board.live_username);
++ return page(req, res, {
++ index: false, title: out.board.title,
++ body: raw(views.board({ board: out.board, quests: out.quests, live, signedIn: Boolean(signedIn(req)), mine, staff: staffViewer(req), flash: req.query.saved === '1' ? 'Saved.' : null })),
++ });
++ });
++
++ r.post('/quests/boards/:id/suggest', guard, async (req, res) => {
++ const b = req.body || {};
++ const out = await store.createSuggestion({
++ boardId: String(req.params.id || ''), subject: req.viewer.subject,
++ title: b.title, brief: b.brief, category: b.category, placeName: b.place_name,
++ accessible: b.accessible === '1', source: 'suggestion',
++ });
++ if (!out.ok) {
++ const pageOut = await store.boardPage(String(req.params.id || ''));
++ if (!pageOut) return notFound(req, res);
++ const rawBoard = await store.boardById(String(req.params.id || ''));
++ return page(req, res, {
++ index: false, title: 'Suggest a challenge',
++ body: raw(views.board({ board: pageOut.board, quests: pageOut.quests, live: await liveOf(pageOut.board.live_username), signedIn: true, mine: Boolean(rawBoard && rawBoard.streamer_subject === req.viewer.subject), staff: staffViewer(req), problem: out.message })),
++ }, out.status || 400);
++ }
++ return res.redirect(303, `/quests/boards/${encodeURIComponent(out.quest.board_id)}?saved=1`);
++ });
++
++ r.post('/quests/boards/:id/idea', guard, async (req, res) => {
++ const id = String(req.params.id || '');
++ const rawBoard = await store.boardById(id);
++ if (!rawBoard) return notFound(req, res);
++ if (!staffViewer(req) && rawBoard.streamer_subject !== req.viewer.subject) return notFromHere(req, res);
++ const outPage = await store.boardPage(id);
++ const remaining = ideas.forBoard({ accessibleOnly: Boolean(rawBoard.accessible_only), excludeTitles: (outPage ? outPage.quests : []).map((q) => q.title) });
++ if (!remaining.length) return res.redirect(303, `/quests/boards/${encodeURIComponent(id)}?saved=0`);
++ const idea = remaining[0];
++ await store.createSuggestion({ boardId: id, subject: req.viewer.subject, title: idea.title, brief: idea.brief, category: idea.category, accessible: Boolean(idea.accessible), source: 'idea' });
++ return res.redirect(303, `/quests/boards/${encodeURIComponent(id)}?saved=1`);
++ });
++
++ async function renderQuest(req, res, { problem = null, status = 200, flash = null } = {}) {
++ const out = await store.questForPage(String(req.params.slug || ''), { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
++ if (!out) return notFound(req, res);
++ const live = await liveOf(out.quest.streamer_username);
++ return page(req, res, {
++ index: out.quest.visibility === 'public', cache: out.quest.visibility === 'public' ? PUBLIC_CACHE : null,
++ title: out.quest.title,
++ description: out.quest.brief.slice(0, 160),
++ jsonLd: out.quest.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.quest.title, url: `${base()}/q/${out.quest.slug}`, description: out.quest.brief.slice(0, 160), siteUrl: base() })] : [],
++ body: raw(views.quest({ q: out.quest, board: out.board, live, signedIn: Boolean(signedIn(req)), voted: out.quest.voted, mine: out.quest.mine, staff: staffViewer(req), flash, problem, verifyVotes: config.quests.verifyVotes })),
++ }, status);
++ }
++ r.get('/q/:slug', (req, res) => renderQuest(req, res, { flash: req.query.reported === '1' ? 'Thank you. Staff will look at the report.' : req.query.verified === '1' ? 'Verified; the badge is paid.' : null }));
++
++ r.post('/q/:slug/vote', guard, async (req, res) => {
++ const out = await store.castQuestVote({ slug: String(req.params.slug || ''), subject: req.viewer.subject, via: 'web' });
++ if (!out.ok) return renderQuest(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/q/${encodeURIComponent(out.slug)}?voted=1`);
++ });
++
++ for (const [suffix, action] of [['accept', 'accept'], ['live', 'live'], ['complete', 'complete']]) {
++ r.post(`/q/:slug/${suffix}`, guard, async (req, res) => {
++ const slug = String(req.params.slug || '');
++ const out = action === 'accept'
++ ? await store.acceptQuest({ slug, subject: req.viewer.subject, staff: staffViewer(req) })
++ : await store.setQuestStatus({ slug, subject: req.viewer.subject, staff: staffViewer(req), to: action });
++ if (!out.ok) return renderQuest(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/q/${encodeURIComponent(slug)}?saved=1`);
++ });
++ }
++
++ r.post('/q/:slug/verify', guard, async (req, res) => {
++ const slug = String(req.params.slug || '');
++ const via = req.body && req.body.via === 'moderator' ? 'moderator' : 'chat';
++ const out = await store.verifyQuest({ slug, subject: req.viewer.subject, staff: staffViewer(req), via });
++ if (!out.ok) return renderQuest(req, res, { problem: out.message, status: out.status || 400 });
++ if (out.reward) await coins.settle(out.reward, out.quest);
++ return res.redirect(303, `/q/${encodeURIComponent(slug)}?verified=1`);
++ });
++
++ r.post('/q/:slug/hide', guard, async (req, res) => {
++ if (!staffViewer(req)) return notFromHere(req, res);
++ const out = await store.hideQuest({ slug: String(req.params.slug || ''), staff: true });
++ if (!out.ok) return renderQuest(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, '/quests');
++ });
++
++ r.post('/q/:slug/report', guard, async (req, res) => {
++ const quest = await store.byQuestSlug(String(req.params.slug || ''));
++ if (!quest) return notFound(req, res);
++ const b = req.body || {};
++ const out = await store.createReport({ targetKind: 'quest', targetId: quest.id, subject: req.viewer.subject, kind: b.kind || 'safety', reason: b.reason, note: b.note });
++ if (!out.ok) return renderQuest(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/q/${encodeURIComponent(quest.slug)}?reported=1`);
++ });
++
++ // ── Streamer dashboard ──────────────────────────────────
++ r.get('/live', async (req, res) => {
++ const subject = signedIn(req) ? req.viewer.subject : null;
++ const boards = subject ? await store.boardForSubject(subject) : [];
++ const quests = subject ? await store.questsForSubject(subject) : [];
++ return page(req, res, {
++ index: false, title: 'For streamers',
++ body: raw(views.livePage({ boards, quests, signedIn: Boolean(subject) })),
++ });
++ });
++
++ // ── Group plans ─────────────────────────────────────────
++ r.get('/plans', async (req, res) => {
++ const out = await store.listPlans({ limit: 30 });
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'Group plans',
++ description: 'Two or three options, one link, one vote each. The group picks the plan and the winner can go to Today.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Group plans on OpenVibe.Fun', url: `${base()}/plans`, description: 'Decide a plan together and send the winner to Today.', siteUrl: base() })],
++ body: raw(views.plans({ plans: out.plans })),
++ });
++ });
++
++ r.get('/plans/new', (req, res) => {
++ if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: '/plans/new' })) }, 401);
++ return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: { timezone: 'UTC' } })) });
++ });
++
++ r.post('/plans', guard, async (req, res) => {
++ const b = req.body || {};
++ const options = [1, 2, 3].map((i) => String(b[`option${i}_title`] || '').trim()).filter(Boolean).map((title) => ({ title }));
++ const out = await store.createPlan({
++ ownerSubject: req.viewer.subject, ownerUsername: req.viewer.username,
++ title: b.title, summary: b.summary, city: b.city, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,
++ visibility: b.visibility, chatUsername: b.chat_username, options,
++ });
++ if (!out.ok) {
++ const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username']) };
++ for (const i of [1, 2, 3]) values[`option${i}_title`] = b[`option${i}_title`] || '';
++ return page(req, res, { title: 'Start a group plan', body: raw(views.planNew({ values, problem: out.message })) }, out.status || 400);
++ }
++ return res.redirect(303, `/plans/${encodeURIComponent(out.plan.slug)}`);
++ });
++
++ async function renderPlan(req, res, { problem = null, status = 200, flash = null } = {}) {
++ const out = await store.planForPage(String(req.params.slug || ''), { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
++ if (!out) return notFound(req, res);
++ return page(req, res, {
++ index: out.plan.visibility === 'public', cache: out.plan.visibility === 'public' ? PUBLIC_CACHE : null,
++ title: out.plan.title,
++ description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
++ jsonLd: out.plan.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.plan.title, url: `${base()}/plans/${out.plan.slug}`, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160), siteUrl: base() })] : [],
++ body: raw(views.plan({ p: out.plan, options: out.options, winner: out.winner, signedIn: Boolean(signedIn(req)), mine: out.isOwner, staff: staffViewer(req), flash, problem, myOptionId: out.plan.my_option_id, totalVotes: out.plan.total_votes })),
++ }, status);
++ }
++ r.get('/plans/:slug', (req, res) => renderPlan(req, res, { flash: req.query.reported === '1' ? 'Thank you. Staff will look at the report.' : req.query.decided === '1' ? 'The winner is chosen.' : null }));
++
++ r.post('/plans/:slug/options', guard, async (req, res) => {
++ const b = req.body || {};
++ const out = await store.addPlanOption({
++ slug: String(req.params.slug || ''), subject: req.viewer.subject, staff: staffViewer(req),
++ title: b.title, placeName: b.place_name, accessible: b.accessible === '1',
++ });
++ if (!out.ok) return renderPlan(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/plans/${encodeURIComponent(String(req.params.slug || ''))}`);
++ });
++
++ r.post('/plans/:slug/vote', guard, async (req, res) => {
++ const b = req.body || {};
++ const out = await store.castPlanVote({ slug: String(req.params.slug || ''), subject: req.viewer.subject, optionId: b.option_id || null, position: b.position || null, via: 'web' });
++ if (!out.ok) return renderPlan(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/plans/${encodeURIComponent(String(req.params.slug || ''))}?voted=1`);
++ });
++
++ r.post('/plans/:slug/decide', guard, async (req, res) => {
++ const out = await store.decidePlan({ slug: String(req.params.slug || ''), subject: req.viewer.subject, staff: staffViewer(req) });
++ if (!out.ok) return renderPlan(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/plans/${encodeURIComponent(String(req.params.slug || ''))}?decided=1`);
++ });
++
++ r.post('/plans/:slug/today', guard, async (req, res) => {
++ const out = await store.planForToday(String(req.params.slug || ''));
++ if (!out) return notFound(req, res);
++ if (out.error === 'not_decided') return renderPlan(req, res, { problem: 'Decide the plan before adding it to Today.', status: 409 });
++ if (!out.winner) return renderPlan(req, res, { problem: 'This plan has no chosen option.', status: 409 });
++ if (new Date(out.plan.starts_at).getTime() < Date.now() + 5 * 60_000) return renderPlan(req, res, { problem: 'The plan starts too soon to add it to Today.', status: 409 });
++ if (!req.viewer.token) return renderPlan(req, res, { problem: 'Sign in again to add the plan to Today.', status: 401 });
++ try {
++ const created = await upstreams.todayCreate({ token: req.viewer.token, body: planEvent(config, out.plan, out.winner), idempotencyKey: `fun-plan-${out.plan.id}` });
++ return res.redirect(303, created.url);
++ } catch (err) {
++ return renderPlan(req, res, { problem: err.message || 'OpenVibe.Today did not answer.', status: err.status || 502 });
++ }
++ });
++
++ r.post('/plans/:slug/hide', guard, async (req, res) => {
++ if (!staffViewer(req)) return notFromHere(req, res);
++ const out = await store.hidePlan({ slug: String(req.params.slug || ''), staff: true });
++ if (!out.ok) return renderPlan(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, '/plans');
++ });
++
++ r.post('/plans/:slug/report', guard, async (req, res) => {
++ const plan = await store.planBySlug(String(req.params.slug || ''));
++ if (!plan) return notFound(req, res);
++ const b = req.body || {};
++ const out = await store.createReport({ targetKind: 'plan', targetId: plan.id, subject: req.viewer.subject, kind: b.kind || 'safety', reason: b.reason, note: b.note });
++ if (!out.ok) return renderPlan(req, res, { problem: out.message, status: out.status || 400 });
++ return res.redirect(303, `/plans/${encodeURIComponent(plan.slug)}?reported=1`);
++ });
++
++ // ── OBS overlay, no Frame, no script, meta refresh only ──
++ r.get('/overlay/q/:slug', async (req, res) => {
++ const out = await store.questForPage(String(req.params.slug || ''), {});
++ if (!out || out.quest.visibility !== 'public') { res.status(404).type('text/plain').send('No such public quest.'); return; }
++ res.set('Cache-Control', 'no-store');
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ const live = await liveOf(out.quest.streamer_username);
++ return res.type('html').send(views.overlay({ q: out.quest, votes: out.quest.votes, live }));
++ });
++
++ // ── Developers, discovery, 404 ──────────────────────────
++ r.get('/developers', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'The OpenVibe.Fun API',
++ description: 'The JSON API, the Search documents, the public sources and the safety rules.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Fun API', url: `${base()}/developers`, description: 'The JSON API, the Search documents and the public sources.', siteUrl: base() })],
++ body: raw(views.developers()),
++ }));
++
++ r.use(createDiscoveryRoutes(ctx));
++
++ function notFound(req, res) {
++ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);
++ }
++
++ return r;
++}
++
++module.exports = { createPageRoutes };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..b669e02
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — req.principal:
++ *
++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
++ * a person: their Network user token as a Bearer, or this site's session cookie (fun_at).
++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
++ * a Network app, agent or service token for audience openvibe.fun. No Fun route needs one; the shape is
++ * kept so a future capability route can be added in one place.
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, suggest, plan or report.
++ */
++const { serviceAuth, http, ids } = require('openvibe-contracts');
++
++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++// Fun's routes are a person acting for themself, a public read, and staff acting on a quest or plan. No capability.
++const CAPABILITIES = [];
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++function roleClaim(claims) {
++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
++}
++function usernameClaim(claims) {
++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
++}
++
++function requesterOfSub(sub) {
++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
++ return sub;
++}
++
++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
++function sameOrigin(req, baseUrl) {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin';
++ const origin = req.get('origin');
++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
++ return false;
++}
++
++function createPrincipal({ config, keys }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const publicKey = await keys.pemForToken(token);
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
++ }
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
++ }
++ const viewer = req.viewer;
++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
++ }
++ return { principal: { kind: 'anonymous' } };
++ }
++
++ async function middleware(req, res, next) {
++ const r = await resolve(req);
++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ req.principal = r.principal;
++ return next();
++ }
++
++ function require_(cap) {
++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Fun's capabilities`);
++ return function guard(req, res, next) {
++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.fun, or send a person\'s Network token as a Bearer.', ctx: req.ov });
++ if (req.principal.kind === 'user') {
++ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'fun.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.fun itself.', ctx: req.ov });
++ return next();
++ }
++ return http.sendProblem(res, 403, 'fun.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
++ };
++ }
++
++ return { middleware, resolve, requireCapability: require_ };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..e6e845d
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ */
++const express = require('express');
++
++function asyncRouter() {
++ const r = express.Router();
++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..95dcb4a
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,40 @@
++'use strict';
++
++/**
++ * Account export and deletion → Fun (ADR-033; openvibe-sdk/account-data).
++ *
++ * A person's own rows: board, suggestions, votes, verification checks, badges, OpenCoin reward ledger, group plans
++ * they started, plan options they proposed, their plan votes and the reports they filed.
++ *
++ * A public quest or a shared group plan is the community's record, not only one person's row: on deletion the
++ * owner/subject column becomes NULL (the authorless record stays) and every personal row is deleted. That is why
++ * fun_quests/fun_quest_boards/fun_plans/fun_plan_options are anonymize, while votes, checks, badges, rewards and
++ * reports are delete. No table holds a token. A chat vote/suggestion is keyed by a one-way hash of the public chat
++ * username, not a Network subject, so it has no account to export or erase.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'fun_quest_boards', subject: 'streamer_subject', file: 'quest-boards.json', erase: { anonymize: {} } },
++ { table: 'fun_quests', subject: 'created_by', file: 'quest-suggestions.json', erase: { anonymize: {} } },
++ { table: 'fun_quest_votes', subject: 'subject', file: 'quest-votes.json', erase: 'delete' },
++ { table: 'fun_quest_checks', subject: 'subject', file: 'quest-checks.json', erase: 'delete' },
++ { table: 'fun_badges', subject: 'subject', file: 'badges.json', erase: 'delete' },
++ { table: 'fun_quest_rewards', subject: 'subject', file: 'rewards.json', erase: 'delete' },
++ { table: 'fun_plans', subject: 'owner_subject', file: 'plans.json', erase: { anonymize: { owner_username: null } } },
++ { table: 'fun_plan_options', subject: 'created_by', file: 'plan-options.json', erase: { anonymize: {} } },
++ { table: 'fun_plan_votes', subject: 'subject', file: 'plan-votes.json', erase: 'delete' },
++ { table: 'fun_reports', subject: 'subject', file: 'reports.json', erase: 'delete' },
++];
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db,
++ service: 'fun',
++ tables: TABLES,
++ note: 'A verified IRL quest and a shared group plan stay as the community\'s record with your name removed; your votes, checks, badges, rewards and reports are erased.',
++ log,
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/identity/staff.js b/server/identity/staff.js
nnew file mode 100644
iindex 0000000..51871af
---- /dev/null
++++ b/server/identity/staff.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++/**
++ * Who is staff at OpenVibe.Fun: the Network role claim on the person's session (server/auth/sso.js puts `role`
++ * on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's
++ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.
++ *
++ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.
++ */
++const { staff } = require('openvibe-contracts');
++
++const STAFF_MIN = 'global_mod';
++const STAFF_ROLES = ['global_mod', 'admin', 'owner'];
++
++/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */
++function isStaff(who) {
++ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;
++ if (typeof who.role !== 'string' || !who.role) return false;
++ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }
++}
++
++module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..e1c6eee
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++/**
++ * OpenVibe.Fun — process entry. `node server/index.js`
++ * Listens on PORT (5140) behind nginx (deploy/).
++ */
++const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
++
++/**
++ * The process stop: the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the store closes and the
++ * Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
++ return gracefulStop({
++ name: 'OpenVibe.Fun', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
++ close: [
++ () => { for (const t of timers) clearInterval(t); },
++ () => ctx.keys.client.stop(),
++ () => ctx.chatSync.stop(),
++ () => ctx.coins.stop(),
++ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
++ () => ctx.s.close(),
++ ...extra,
++ ],
++ });
++}
++
++async function start() {
++ const { app, ctx } = await createApp();
++ const { config } = ctx;
++
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[OpenVibe.Fun] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
++ ctx.keys.ensure().catch(() => {});
++ });
++ server.keepAliveTimeout = 65_000;
++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
++ ctx.telemetryTick.unref();
++ ctx.keys.client.start();
++
++ // Jobs. Chat votes are read-only; both refuse to start under FUN_DRILL, where tests call runOnce/retryPending.
++ ctx.chatSync.start();
++ ctx.coins.start();
++
++ // The two account topics at OpenVibe.Events: idempotent; off without the URL, the delivery secret and the client
++ // secret. The consumer itself is mounted in server/app.js.
++ const subs = config.drill ? null : startSubscriptions({
++ eventsUrl: config.events.url,
++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
++ secret: config.events.secrets[0] || '',
++ networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ log: ctx.log || console,
++ });
++ const extra = [() => { if (subs) subs.stop(); }];
++ createLifecycle({ server, ctx, extra });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[OpenVibe.Fun] failed to start:', err); process.exit(1); });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..215156f
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
++ * the OAuth client, Valkey and the optional guide/coins upstreams are optional. The key store is warmed right after
++ * listen, so a service nobody has signed in to yet is not degraded only because the first fetch had not happened.
++ */
++const { jwksStatus } = require('openvibe-sdk/auth');
++const { createReadiness } = require('openvibe-shared/ready');
++
++function createServiceReadiness({ s, config, valkey = null, release = null }) {
++ return createReadiness({
++ service: 'fun',
++ release,
++ checks: [
++ {
++ name: 'db', required: true,
++ check: async () => {
++ const r = await s.db.ready();
++ return r.ok ? { ok: true, detail: r.detail } : r.error;
++ },
++ },
++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ const statuses = jwksStatus();
++ if (!statuses.length) return 'no JWKS clients (misconfigured)';
++ const first = statuses[0];
++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
++ },
++ },
++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
++ { name: 'ticketmaster', required: false, check: () => (config.ticketmaster.enabled ? true : { skipped: 'TICKETMASTER_API_KEY unset: the guide uses OpenStreetMap only' }) },
++ { name: 'coins', required: false, check: () => (config.coins.mode === 'on' ? (config.oauth.clientSecret ? true : 'FUN_COINS=on but OV_OAUTH_CLIENT_SECRET is unset') : { skipped: 'FUN_COINS is off: badges are paid, OpenCoins are not credited' }) },
++ ],
++ });
++}
++
++module.exports = { createServiceReadiness };
ddiff --git a/server/quests/ideas.js b/server/quests/ideas.js
nnew file mode 100644
iindex 0000000..433ad9b
---- /dev/null
++++ b/server/quests/ideas.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++/**
++ * The safe starter quests. These are a small catalogue in code, not an AI generation: the only "generation" in v1
++ * is choosing one of these for a streamer (POST /quests/:slug/idea), so no model call and no new AI capability is
++ * involved. Every one passes server/quests/safety.js, and they are written without describing anyone's looks.
++ */
++const IDEAS = Object.freeze([
++ { title: 'Find the oldest bar on this street', brief: 'Ask a local or look at the date over the door; tell chat what you found.', category: 'near_me', accessible: false },
++ { title: 'Get a stranger to beatbox', brief: 'Ask politely, keep it short, and stop if they are not up for it.', category: 'silly', accessible: true },
++ { title: 'Find the best bench with a view', brief: 'Walk ten minutes, sit for one minute, and show chat the view.', category: 'chill', accessible: true },
++ { title: 'Find a mural nobody has shown chat', brief: 'Take a photo from the pavement, not from the road.', category: 'outside', accessible: true },
++ { title: 'Order the cheapest thing on the menu', brief: 'Ask the staff what it is and tell chat how it tasted.', category: 'brainy', accessible: true },
++ { title: 'Find a shop that sells only one thing', brief: 'Ask what it is and why; no purchase needed.', category: 'near_me', accessible: true },
++ { title: 'Follow the next left turn three times', brief: 'Stay on public pavements and let chat see where you end up.', category: 'outside', accessible: false },
++ { title: 'Learn one word in a language you do not speak', brief: 'Ask someone at a bus stop or a shop, politely, and use it once.', category: 'brainy', accessible: true },
++]);
++
++function forBoard({ accessibleOnly = false, excludeTitles = [] } = {}) {
++ const seen = new Set((excludeTitles || []).map((t) => String(t).toLowerCase()));
++ return IDEAS.filter((i) => (!accessibleOnly || i.accessible) && !seen.has(i.title.toLowerCase()));
++}
++
++module.exports = { IDEAS, forBoard };
ddiff --git a/server/quests/safety.js b/server/quests/safety.js
nnew file mode 100644
iindex 0000000..8015e65
---- /dev/null
++++ b/server/quests/safety.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++/**
++ * The IRL quest safety filter. A quest may be playful and social — the plan's own example, "get a stranger to
++ * beatbox", is allowed — but it may never be dangerous, illegal, trespassing or harassment. Suggestions and chat
++ * commands are checked here before a row is written; the streamer can set further limits on the board
++ * (accessible_only, max_suggestions), and a person can report a public quest for staff review.
++ *
++ * This is a deliberately small, explainable list, not a model: a false negative is a report, and a false positive
++ * is a refusal with the rule named. It never inspects or describes a person.
++ */
++
++const MAX_TITLE = 120;
++const MAX_BRIEF = 600;
++const CATEGORIES = Object.freeze(['near_me', 'outside', 'brainy', 'silly', 'chill']);
++
++const RULES = Object.freeze([
++ {
++ code: 'danger',
++ why: 'That challenge could put someone in danger.',
++ re: /\b(trespass(?:ing)?|break(?:ing)?\s+(?:in|into)|sneak(?:ing)?\s+into|rooftops?|roof(?:s|top)?|train\s?surf(?:ing)?|jump(?:ing)?\s+off|climb(?:ing)?\s+(?:a\s+)?(?:bridge|building|crane|tower)|highways?|freeways?|oncoming\s+traffic|set\s+(?:it|something)\s+on\s+fire|play(?:ing)?\s+with\s+fire|fireworks?\s+at|guns?|weapons?|knives|knife|bleach|poison|swim(?:ming)?\s+across|drunk|drugs?|dare(?:s|d)?\s+(?:someone|a\s+stranger))\b/i,
++ },
++ {
++ code: 'harassment',
++ why: 'That challenge asks someone to harass or touch another person.',
++ re: /\b(harass(?:ing|ment)?|catcall(?:ing)?|follow(?:ing)?\s+(?:a\s+)?stranger|record(?:ing)?\s+(?:a\s+)?stranger|upskirt|grope|touch(?:ing)?\s+(?:a\s+)?(?:stranger|someone)|kiss(?:ing)?\s+(?:a\s+)?stranger|fight(?:ing)?|punch(?:ing)?|slap(?:ping)?|prank(?:ing)?\s+(?:a\s+)?stranger)\b/i,
++ },
++ {
++ code: 'illegal',
++ why: 'That challenge asks for something illegal.',
++ re: /\b(steal(?:ing)?|shoplift(?:ing)?|vandal(?:ism|ize|izing)?|graffiti|spray\s?paint(?:ing)?|trespass(?:ing)?|break(?:ing)?\s+(?:in|into)|jaywalk(?:ing)?)\b/i,
++ },
++ {
++ code: 'adult',
++ why: 'That challenge is not suitable for a public stream.',
++ re: /\b(naked|nude|strip(?:ping|tease)?|porn(?:ography)?|sex(?:ual)?|escort)\b/i,
++ },
++]);
++
++function normalize(value) {
++ return String(value == null ? '' : value).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
++}
++
++/** The refusal for text, or null when it is safe. Never echoes the text back in the code. */
++function refusal(text) {
++ for (const rule of RULES) if (rule.re.test(text)) return { code: `fun.quest.${rule.code}`, why: rule.why };
++ return null;
++}
++
++/** Check one suggestion title/brief pair. Returns { ok, title, brief } or { ok:false, status, code, message, field }. */
++function checkSuggestion({ title, brief } = {}) {
++ const t = normalize(title).slice(0, MAX_TITLE + 1);
++ const b = normalize(brief || title).slice(0, MAX_BRIEF + 1);
++ if (t.length < 3 || t.length > MAX_TITLE) return { ok: false, status: 400, code: 'fun.quest.invalid', field: 'title', message: `title must be 3 to ${MAX_TITLE} characters` };
++ if (b.length < 3 || b.length > MAX_BRIEF) return { ok: false, status: 400, code: 'fun.quest.invalid', field: 'brief', message: `brief must be 3 to ${MAX_BRIEF} characters` };
++ const hit = refusal(`${t} ${b}`);
++ if (hit) return { ok: false, status: 422, code: hit.code, field: 'title', message: hit.why };
++ return { ok: true, title: t, brief: b };
++}
++
++/** A chat command's text is a suggestion title until a board max applies; same rules. */
++function checkChatSuggestion(text) {
++ return checkSuggestion({ title: text, brief: text });
++}
++
++function checkCategory(value, allowed = CATEGORIES) {
++ const c = normalize(value).toLowerCase().replace(/[\s-]+/g, '_');
++ if (!allowed.includes(c)) return { ok: false, status: 400, code: 'fun.quest.invalid', field: 'category', message: `category must be one of ${allowed.join(', ')}` };
++ return { ok: true, value: c };
++}
++
++module.exports = { checkSuggestion, checkChatSuggestion, checkCategory, normalize, refusal, RULES, CATEGORIES, MAX_TITLE, MAX_BRIEF };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..2769b87
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
++const raw = (s) => new Raw(s);
++function html(strings, ...values) {
++ let out = strings[0];
++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
++ return raw(out);
++}
++function render(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ return esc(v);
++}
++
++const code = (s) => html`<code>${s}</code>`;
++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++
++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
++ if (!rows.length) return html`<p class="muted">${empty}</p>`;
++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..5ca0772
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
++ * This is Meme's layout with Fun's name, navigation, room accent and assets.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const shell = require('openvibe-shared/shell');
++const rooms = require('openvibe-shared/rooms');
++const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
++const { render, esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Fun';
++const TAGLINE = 'Something to do, alone, together, or live.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Home', href: '/' },
++ { label: 'Local guide', href: '/guide' },
++ { label: 'IRL quests', href: '/quests' },
++ { label: 'Group plans', href: '/plans' },
++ { label: 'For streamers', href: '/live' },
++ { label: 'Developers', href: '/developers' },
++];
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++/**
++ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles
++ */
++function renderPage(o) {
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
++ const pathNow = o.path || '/';
++ const loginNext = encodeURIComponent(pathNow);
++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const nav = {
++ service: 'fun',
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ notificationsRealtime: true,
++ };
++ const footer = { service: 'fun', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ const account = signedIn
++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ const crumbs = (o.crumbs || []).length
++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
++ : '';
++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
++ canonical,
++ robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ home: '/',
++ navLinks: NAV,
++ navbar: nav,
++ footer,
++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
++${appIcon.headTags({ site: 'fun' })}
++<link rel="stylesheet" href="${ovServe.url('kit.css')}">
++<link rel="stylesheet" href="${ovServe.url('room.css')}">
++<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
++<link rel="stylesheet" href="${asset('css/fun.css')}">
++<script src="${ovServe.url('motion.js')}" defer></script>
++<script src="${asset('js/fun.js')}" defer></script>
++${render(scripts)}
++<meta name="ov-boost" content="fun@${require('./html').esc(RELEASE)}">
++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
++<main id="main" class="fu ov-room" style="${rooms.style('fun')}">
++${crumbs}
++${render(o.body)}
++</main>
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
++});
++</script>`,
++ });
++}
++
++/** Send a page; a page rendered for a signed-in person is private and never cached. */
++function send(res, status, o) {
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
ddiff --git a/server/search.js b/server/search.js
nnew file mode 100644
iindex 0000000..cd993ce
---- /dev/null
++++ b/server/search.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++/**
++ * OpenVibe.Fun's Search Fabric feed. A public IRL quest is one `search.index-document@1` (owner `fun`, type `quest`,
++ * page `/q/<slug>`); a public group plan is another (type `plan`, page `/plans/<slug>`). They are queued in
++ * `fun_outbox` in the same transaction as the change; hiding or cancelling one queues a tombstone, which wins over
++ * any older revision. The relay is deliberately not part of v1: rows wait until OpenVibe.Search's document writer
++ * is configured for `fun` (it needs the search.document.write grant).
++ *
++ * A quest/plan body is the public text the person wrote plus the public facts around it. No voter, subject, chat
++ * author or token is ever part of a document.
++ */
++const { ids } = require('openvibe-contracts');
++const { cleanSummary } = require('./text/no-looks');
++
++const OWNER = 'fun';
++const EVENT_UPSERTED = 'fun.index_document.upserted';
++const EVENT_DELETED = 'fun.index_document.deleted';
++const QUEST_STATUSES = new Set(['accepted', 'live', 'completed', 'verified']);
++
++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
++const baseOf = (config) => String(config.baseUrl).replace(/\/+$/, '');
++
++function questDocument(q, { baseUrl = 'https://openvibe.fun' } = {}) {
++ const revision = Math.max(1, Number(q.revision) || 1);
++ if (!q || q.visibility !== 'public' || !QUEST_STATUSES.has(q.status)) return { owner: OWNER, type: 'quest', id: q.id, revision, deleted: true };
++ // The curated idea deck is the one place Fun writes public text for a quest; a look-naming summary would
++ // become the title. A person's own suggestion is never rewritten here.
++ const summary = q.source === 'idea' ? cleanSummary(q.title, q.brief) : q.brief;
++ const body = [
++ q.title,
++ q.brief,
++ q.place_name ? `Place: ${q.place_name}` : '',
++ q.city ? `City: ${q.city}` : '',
++ q.accessible ? 'Accessible activity' : '',
++ q.streamer_username ? `On OpenVibe.Live: @${q.streamer_username}` : '',
++ q.status === 'verified' && q.reward_badge ? `Badge: ${q.reward_badge}` : '',
++ ].filter(Boolean).join('\n').slice(0, 48_000);
++ return {
++ owner: OWNER,
++ type: 'quest',
++ id: q.id,
++ revision,
++ deleted: false,
++ visibility: 'public',
++ canonical_url: `${baseUrl}/q/${q.slug}`,
++ title: q.title,
++ ...(summary ? { summary: summary.slice(0, 4000) } : {}),
++ body,
++ facets: {
++ status: q.status,
++ category: q.category,
++ ...(q.city ? { city: q.city } : {}),
++ accessible: Boolean(q.accessible),
++ streamer: q.streamer_username,
++ source: q.source,
++ },
++ authorship: 'human',
++ provenance: q.streamer_username ? [{
++ service: 'live',
++ type: 'channel',
++ id: String(q.streamer_username).slice(0, 128),
++ label: `OpenVibe.Live @${q.streamer_username}`,
++ url: q.stream_url,
++ ...(q.updated_at ? { retrieved_at: iso(q.updated_at) } : {}),
++ }] : [],
++ publication_state: 'published',
++ published_at: iso(q.created_at),
++ updated_at: iso(q.updated_at),
++ indexability: { decision: 'index' },
++ };
++}
++
++function planDocument(p, options = [], { baseUrl = 'https://openvibe.fun' } = {}) {
++ const revision = Math.max(1, Number(p.revision) || 1);
++ if (!p || p.visibility !== 'public' || p.status === 'cancelled') return { owner: OWNER, type: 'plan', id: p.id, revision, deleted: true };
++ const winner = (options || []).find((o) => o.id === p.winner_option_id) || null;
++ const body = [
++ p.title,
++ p.summary || '',
++ p.city ? `City: ${p.city}` : '',
++ ...(options || []).map((o) => `${o.position}. ${o.title}${o.accessible ? ' (accessible)' : ''}${o.details ? ` — ${o.details}` : ''}`),
++ winner ? `Chosen: ${winner.title}` : '',
++ ].filter(Boolean).join('\n').slice(0, 48_000);
++ return {
++ owner: OWNER,
++ type: 'plan',
++ id: p.id,
++ revision,
++ deleted: false,
++ visibility: 'public',
++ canonical_url: `${baseUrl}/plans/${p.slug}`,
++ title: p.title,
++ ...(p.summary ? { summary: p.summary.slice(0, 4000) } : {}),
++ body,
++ facets: {
++ status: p.status,
++ ...(p.city ? { city: p.city } : {}),
++ accessible: (options || []).some((o) => o.accessible),
++ options: (options || []).length,
++ },
++ authorship: 'human',
++ provenance: [],
++ publication_state: 'published',
++ published_at: iso(p.created_at),
++ updated_at: iso(p.updated_at),
++ indexability: { decision: 'index' },
++ };
++}
++
++function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
++ const nowIso = () => new Date(s.now()).toISOString();
++ const base = String(baseUrl).replace(/\/+$/, '');
++
++ async function enqueue(topic, payload, t = null) {
++ const db = t || s.db;
++ const eventId = ids.newId('event', s.now());
++ await db.exec(
++ `INSERT INTO fun_outbox (event_id, topic, payload, created_at, next_attempt_at)
++ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
++ [eventId, topic, JSON.stringify(payload), nowIso()],
++ );
++ return eventId;
++ }
++
++ async function sync(t, type, record, options = []) {
++ const doc = type === 'quest' ? questDocument(record, { baseUrl: base }) : planDocument(record, options, { baseUrl: base });
++ return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);
++ }
++
++ async function remove(t, type, id, revision) {
++ return await enqueue(EVENT_DELETED, { owner: OWNER, type, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);
++ }
++
++ async function pending() {
++ return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM fun_outbox WHERE published_at IS NULL'));
++ }
++
++ return { sync, remove, enqueue, pending, baseUrl: base, questDocument, planDocument };
++}
++
++module.exports = {
++ createSearchIndex, questDocument, planDocument, OWNER, EVENT_UPSERTED, EVENT_DELETED, QUEST_STATUSES,
++};
ddiff --git a/server/store.js b/server/store.js
nnew file mode 100644
iindex 0000000..39e48a7
---- /dev/null
++++ b/server/store.js
@@@ -0,0 +1,713 @@
++'use strict';
++
++/**
++ * Fun's data: IRL quest boards and suggestions, their votes and chat checks, badges and reward ledger; group plans
++ * with options and votes; reports; the OSM/Ticketmaster cache; and the Search outbox.
++ *
++ * Every write that makes a public quest or plan writes its Search document (or tombstone) in the same transaction
++ * (server/search.js). A suggestion is born unlisted and only the streamer (or staff) can accept it; a plan's owner
++ * (or staff) decides its winner. No function here stores a token, a person's looks or a private message.
++ */
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const safety = require('./quests/safety');
++const { cleanSummary } = require('./text/no-looks');
++
++const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
++const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const QUEST_STATUSES = Object.freeze(['suggested', 'accepted', 'live', 'completed', 'verified', 'rejected', 'hidden']);
++const PUBLIC_QUEST_STATUSES = Object.freeze(['accepted', 'live', 'completed', 'verified']);
++const VISIBILITIES = Object.freeze(['public', 'unlisted', 'hidden']);
++const PLAN_STATUSES = Object.freeze(['voting', 'decided', 'cancelled']);
++const REPORT_KINDS = Object.freeze(['safety', 'spam', 'other']);
++
++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
++const num = (v) => (v == null ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
++const clean = (v, max = 1000) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++/** A chat voter is a one-way key, not the public username: distinct, idempotent and not a new copy of the name. */
++const chatVoter = (username) => `chat:${crypto.createHash('sha256').update(`fun-chat:${String(username)}`).digest('hex').slice(0, 32)}`;
++const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
++
++function wireBoard(row) {
++ if (!row) return null;
++ return {
++ id: row.id, title: row.title, live_username: row.live_username, chat_username: row.chat_username,
++ city: row.city || null, lat: num(row.lat), lon: num(row.lon),
++ accessible_only: Boolean(row.accessible_only), max_suggestions: Number(row.max_suggestions) || 30,
++ status: row.status, accepted_quest_id: row.accepted_quest_id || null,
++ opens_at: iso(row.opens_at), closes_at: iso(row.closes_at), created_at: iso(row.created_at), updated_at: iso(row.updated_at),
++ };
++}
++
++function wireQuest(row, extra = {}) {
++ if (!row) return null;
++ return {
++ id: row.id, slug: row.slug, board_id: row.board_id, title: row.title, brief: row.brief,
++ category: row.category, source: row.source, status: row.status, visibility: row.visibility,
++ city: row.city || null, lat: num(row.lat), lon: num(row.lon), place_name: row.place_name || null,
++ accessible: Boolean(row.accessible), streamer_username: row.streamer_username, stream_url: row.stream_url,
++ revision: Number(row.revision) || 1,
++ accepted_at: iso(row.accepted_at), completed_at: iso(row.completed_at), verified_at: iso(row.verified_at),
++ verification: row.verification || null, reward_badge: row.reward_badge || null, reward_coins: Number(row.reward_coins) || 0,
++ created_at: iso(row.created_at), updated_at: iso(row.updated_at),
++ ...extra,
++ };
++}
++
++function wirePlan(row, extra = {}) {
++ if (!row) return null;
++ return {
++ id: row.id, slug: row.slug, title: row.title, summary: row.summary || null,
++ city: row.city || null, lat: num(row.lat), lon: num(row.lon),
++ timezone: row.timezone, starts_at: iso(row.starts_at), closes_at: iso(row.closes_at),
++ status: row.status, winner_option_id: row.winner_option_id || null, visibility: row.visibility,
++ chat_username: row.chat_username || null, owner_username: row.owner_username || null,
++ revision: Number(row.revision) || 1, created_at: iso(row.created_at), updated_at: iso(row.updated_at),
++ ...extra,
++ };
++}
++
++function wireOption(row, extra = {}) {
++ if (!row) return null;
++ return {
++ id: row.id, plan_id: row.plan_id, title: row.title, details: row.details || null,
++ place_name: row.place_name || null, lat: num(row.lat), lon: num(row.lon),
++ accessible: Boolean(row.accessible), price_note: row.price_note || null,
++ starts_at: iso(row.starts_at), position: Number(row.position) || 1, created_at: iso(row.created_at),
++ ...extra,
++ };
++}
++
++function wireReport(row) {
++ if (!row) return null;
++ return {
++ id: row.id, target_kind: row.target_kind, target_id: row.target_id,
++ kind: row.kind, reason: row.reason, note: row.note || null, status: row.status,
++ created_at: iso(row.created_at), handled_at: iso(row.handled_at), handled_by: row.handled_by || null,
++ ...(row.target_title ? { target_title: row.target_title } : {}),
++ ...(row.target_slug ? { target_slug: row.target_slug } : {}),
++ };
++}
++
++function validTimezone(tz) {
++ try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return true; } catch { return false; }
++}
++
++function parseWhen(v) {
++ if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
++ if (typeof v !== 'string' || !v.trim()) return null;
++ const d = new Date(v.trim());
++ return Number.isNaN(d.getTime()) ? null : d;
++}
++
++function createStore(s, { search = { sync: async () => null }, config = {} } = {}) {
++ const db = s.db;
++ const q = (t) => t || db;
++ const questConfig = config.quests || {};
++
++ async function byQuestSlug(slug, t) {
++ return await q(t).maybe('SELECT * FROM fun_quests WHERE slug = $1', [String(slug || '')]);
++ }
++ async function byQuestId(id, t) {
++ return await q(t).maybe('SELECT * FROM fun_quests WHERE id = $1', [String(id || '')]);
++ }
++ async function boardById(id, t) {
++ return await q(t).maybe('SELECT * FROM fun_quest_boards WHERE id = $1', [String(id || '')]);
++ }
++ async function planBySlug(slug, t) {
++ return await q(t).maybe('SELECT * FROM fun_plans WHERE slug = $1', [String(slug || '')]);
++ }
++ async function planById(id, t) {
++ return await q(t).maybe('SELECT * FROM fun_plans WHERE id = $1', [String(id || '')]);
++ }
++
++ async function uniqueSlug(base, t) {
++ const root = String(base || 'fun').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 65) || 'fun';
++ let slug = root;
++ for (let i = 2; i < 200; i++) {
++ const found = await q(t).maybe('SELECT id FROM fun_quests WHERE slug = $1 UNION ALL SELECT id FROM fun_plans WHERE slug = $1', [slug]);
++ if (!found) return slug;
++ slug = `${root}-${i}`;
++ }
++ return `${root}-${Date.now().toString(36)}`;
++ }
++
++ async function voteCounts(questIds, t) {
++ const out = {};
++ const wanted = [...new Set((questIds || []).filter(Boolean))];
++ if (!wanted.length) return out;
++ const rows = await q(t).many('SELECT quest_id, COUNT(*)::int AS n FROM fun_quest_votes WHERE quest_id = ANY($1::text[]) GROUP BY quest_id', [wanted]);
++ for (const row of rows) out[row.quest_id] = Number(row.n) || 0;
++ return out;
++ }
++
++ async function countsForQuest(id, t) {
++ const row = await q(t).maybe(
++ `SELECT (SELECT COUNT(*)::int FROM fun_quest_votes WHERE quest_id = $1) AS votes,
++ (SELECT COUNT(*)::int FROM fun_quest_checks WHERE quest_id = $1) AS checks`, [id]);
++ return { votes: Number(row && row.votes) || 0, checks: Number(row && row.checks) || 0 };
++ }
++
++ // ── Quest boards and suggestions ─────────────────────────
++ async function createBoard({ streamerSubject, title, liveUsername, chatUsername, city = null, lat = null, lon = null, accessibleOnly = false, maxSuggestions = null, closesAt = null }) {
++ if (!SUBJECT_RE.test(String(streamerSubject || ''))) return refusal(403, 'fun.person_required', 'A board belongs to a signed-in person.');
++ if (!USERNAME_RE.test(String(liveUsername || ''))) return refusal(400, 'fun.quest.invalid', 'live_username is required', 'live_username');
++ if (!USERNAME_RE.test(String(chatUsername || ''))) return refusal(400, 'fun.quest.invalid', 'chat_username is required', 'chat_username');
++ const t = clean(title, 120);
++ if (t.length < 3 || t.length > 120) return refusal(400, 'fun.quest.invalid', 'title must be 3 to 120 characters', 'title');
++ const cityName = city == null || city === '' ? null : clean(city, 120);
++ if (cityName !== null && !cityName) return refusal(400, 'fun.quest.invalid', 'city must be 1 to 120 characters', 'city');
++ const la = num(lat); const lo = num(lon);
++ if (la != null && (la < -90 || la > 90)) return refusal(400, 'fun.quest.invalid', 'lat must be -90 to 90', 'lat');
++ if (lo != null && (lo < -180 || lo > 180)) return refusal(400, 'fun.quest.invalid', 'lon must be -180 to 180', 'lon');
++ const close = closesAt ? parseWhen(closesAt) : new Date(s.now() + 4 * 60 * 60_000);
++ if (!close || close.getTime() <= s.now() + 60_000) return refusal(400, 'fun.quest.invalid', 'closes_at must be in the future', 'closes_at');
++ const max = Math.min(100, Math.max(1, Number(maxSuggestions) || questConfig.maxSuggestions || 30));
++ const id = s.newId('qbd');
++ const row = await db.maybe(
++ `INSERT INTO fun_quest_boards (id, streamer_subject, live_username, chat_username, title, city, lat, lon, accessible_only, max_suggestions, status, opens_at, closes_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'open', now(), $11::timestamptz) RETURNING *`,
++ [id, streamerSubject, String(liveUsername), String(chatUsername), t, cityName, la, lo, Boolean(accessibleOnly), max, close.toISOString()],
++ );
++ return { ok: true, board: wireBoard(row) };
++ }
++
++ async function boardForSubject(subject) {
++ const rows = await db.many('SELECT * FROM fun_quest_boards WHERE streamer_subject = $1 ORDER BY created_at DESC LIMIT 50', [subject]);
++ return rows.map(wireBoard);
++ }
++
++ async function boardPage(id) {
++ const board = await boardById(id);
++ if (!board) return null;
++ const rows = await db.many('SELECT * FROM fun_quests WHERE board_id = $1 ORDER BY created_at ASC', [id]);
++ const counts = await voteCounts(rows.map((r) => r.id));
++ return {
++ board: wireBoard(board),
++ quests: rows.map((r) => wireQuest(r, { votes: counts[r.id] || 0 })),
++ };
++ }
++
++ async function questsForSubject(subject) {
++ const rows = await db.many(
++ `SELECT q.* FROM fun_quests q JOIN fun_quest_boards b ON b.id = q.board_id
++ WHERE b.streamer_subject = $1 ORDER BY q.created_at DESC LIMIT 40`, [subject]);
++ const counts = await voteCounts(rows.map((r) => r.id));
++ return rows.map((r) => wireQuest(r, { votes: counts[r.id] || 0 }));
++ }
++
++ async function openBoardsForChat(chatUsername) {
++ const rows = await db.many("SELECT * FROM fun_quest_boards WHERE status = 'open' AND closes_at > now() AND lower(chat_username) = lower($1) ORDER BY created_at ASC LIMIT 20", [String(chatUsername || '')]);
++ return rows.map(wireBoard);
++ }
++
++ async function createSuggestion({ boardId, subject = null, chatUsername = null, title, brief = null, category = 'near_me', placeName = null, lat = null, lon = null, accessible = false, source = 'suggestion' }) {
++ const checked = safety.checkSuggestion({ title, brief: brief || title });
++ if (!checked.ok) return checked;
++ const cat = safety.checkCategory(category);
++ if (!cat.ok) return cat;
++ if (!['suggestion', 'chat', 'idea', 'streamer'].includes(source)) return refusal(400, 'fun.quest.invalid', 'source is not one of suggestion, chat, idea, streamer', 'source');
++ const la = num(lat); const lo = num(lon);
++ if (la != null && (la < -90 || la > 90)) return refusal(400, 'fun.quest.invalid', 'lat must be -90 to 90', 'lat');
++ if (lo != null && (lo < -180 || lo > 180)) return refusal(400, 'fun.quest.invalid', 'lon must be -180 to 180', 'lon');
++ return await s.tx(async (t) => {
++ const board = await boardById(boardId, t);
++ if (!board) return refusal(404, 'fun.board_not_found', 'No such quest board.');
++ if (board.status !== 'open') return refusal(409, 'fun.board_closed', 'This board is closed.');
++ if (new Date(board.closes_at).getTime() <= s.now()) return refusal(409, 'fun.board_closed', 'This board has closed.');
++ const n = Number(await t.value('SELECT COUNT(*)::int AS n FROM fun_quests WHERE board_id = $1', [board.id])) || 0;
++ if (n >= Number(board.max_suggestions)) return refusal(409, 'fun.board_full', 'This board has all the suggestions it takes.');
++ if (board.accessible_only && !accessible) return refusal(422, 'fun.quest.accessible_only', 'This board only takes accessible activities.', 'accessible');
++ if (source === 'chat' && !USERNAME_RE.test(String(chatUsername || ''))) return refusal(400, 'fun.quest.invalid', 'a chat suggestion needs its public username', 'chat_username');
++ const slug = await uniqueSlug(checked.title, t);
++ const id = s.newId('qst');
++ const row = await t.maybe(
++ `INSERT INTO fun_quests (id, board_id, slug, title, brief, category, source, status, visibility, city, lat, lon, place_name, accessible,
++ streamer_username, chat_username, stream_url, created_by, chat_author, revision, reward_coins)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'suggested', 'unlisted', $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 1, 0) RETURNING *`,
++ [id, board.id, slug, checked.title, checked.brief, cat.value, source, board.city || null, la == null ? board.lat : la,
++ lo == null ? board.lon : lo, placeName ? clean(placeName, 200) : null, Boolean(accessible),
++ board.live_username, board.chat_username, `https://openvibe.live/@${encodeURIComponent(board.live_username)}`,
++ subject && SUBJECT_RE.test(String(subject)) ? subject : null, chatUsername ? chatVoter(chatUsername).slice(5) : null],
++ );
++ return { ok: true, quest: wireQuest(row, { votes: 0 }) };
++ });
++ }
++
++ async function castQuestVote({ slug, subject = null, chatUsername = null, via = 'web', chatMessageId = null }) {
++ if (via !== 'web' && via !== 'chat') return refusal(400, 'fun.quest.invalid', 'via must be web or chat', 'via');
++ const voter = via === 'chat'
++ ? (chatUsername && USERNAME_RE.test(String(chatUsername)) ? chatVoter(chatUsername) : null)
++ : (subject && SUBJECT_RE.test(String(subject)) ? subject : null);
++ if (!voter) return refusal(403, 'fun.person_required', 'A vote needs a signed-in person or a public chat username.');
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest || quest.visibility === 'hidden' || quest.status === 'hidden' || quest.status === 'rejected') return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ if (quest.status === 'verified') return refusal(409, 'fun.quest_closed', 'A verified quest no longer takes votes.');
++ await t.exec(
++ `INSERT INTO fun_quest_votes (quest_id, voter, subject, via, chat_message_id, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, now(), now())
++ ON CONFLICT (quest_id, voter) DO UPDATE SET via = EXCLUDED.via, subject = EXCLUDED.subject,
++ chat_message_id = COALESCE(EXCLUDED.chat_message_id, fun_quest_votes.chat_message_id), updated_at = now()`,
++ [quest.id, voter, via === 'web' ? subject : null, via, chatMessageId ? String(chatMessageId).slice(0, 80) : null],
++ );
++ const counts = await countsForQuest(quest.id, t);
++ return { ok: true, quest_id: quest.id, slug: quest.slug, votes: counts.votes, voter };
++ });
++ }
++
++ async function recordQuestCheck({ slug, subject = null, chatUsername = null, via = 'chat', chatMessageId = null }) {
++ const voter = via === 'moderator' && subject ? `staff:${String(subject).slice(0, 64)}`
++ : (via === 'chat' && chatUsername && USERNAME_RE.test(String(chatUsername)) ? chatVoter(chatUsername) : null);
++ if (!voter) return refusal(403, 'fun.person_required', 'A verification check needs a moderator or a public chat username.');
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ if (quest.status !== 'completed' && quest.status !== 'verified') return refusal(409, 'fun.quest_not_completed', 'A quest is checked only after the streamer marks it completed.');
++ await t.exec(
++ `INSERT INTO fun_quest_checks (quest_id, voter, subject, via, chat_message_id, created_at)
++ VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (quest_id, voter) DO NOTHING`,
++ [quest.id, voter, via === 'moderator' ? subject : null, via, chatMessageId ? String(chatMessageId).slice(0, 80) : null],
++ );
++ const checks = Number(await t.value('SELECT COUNT(*)::int FROM fun_quest_checks WHERE quest_id = $1', [quest.id])) || 0;
++ return { ok: true, quest_id: quest.id, slug: quest.slug, checks };
++ });
++ }
++
++ async function acceptQuest({ slug, subject, staff = false }) {
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ const board = await boardById(quest.board_id, t);
++ if (!board) return refusal(404, 'fun.board_not_found', 'No such quest board.');
++ if (!staff && board.streamer_subject !== subject) return refusal(403, 'fun.streamer_required', 'Only the streamer whose board this is can accept a quest.');
++ if (quest.status === 'accepted' || quest.status === 'live' || quest.status === 'completed' || quest.status === 'verified') return { ok: true, quest: wireQuest(quest), unchanged: true };
++ if (quest.status !== 'suggested') return refusal(409, 'fun.quest_closed', 'That quest cannot be accepted now.');
++ if (board.status !== 'open') return refusal(409, 'fun.board_closed', 'This board is already closed.');
++ const updated = await t.maybe(
++ `UPDATE fun_quests SET status = 'accepted', visibility = 'public', accepted_at = now(), reward_coins = $2,
++ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
++ [quest.id, Number(questConfig.coins) || 0],
++ );
++ await t.exec("UPDATE fun_quests SET status = 'rejected', visibility = 'hidden', revision = revision + 1, updated_at = now() WHERE board_id = $1 AND id <> $2 AND status = 'suggested'", [board.id, quest.id]);
++ await t.exec("UPDATE fun_quest_boards SET status = 'accepted', accepted_quest_id = $2, updated_at = now() WHERE id = $1", [board.id, quest.id]);
++ const out = wireQuest(updated, { votes: (await countsForQuest(quest.id, t)).votes });
++ await search.sync(t, 'quest', out);
++ return { ok: true, quest: out, unchanged: false };
++ });
++ }
++
++ async function setQuestStatus({ slug, subject, staff = false, to }) {
++ if (!['live', 'completed'].includes(to)) return refusal(400, 'fun.quest.invalid', 'to must be live or completed');
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ const board = await boardById(quest.board_id, t);
++ if (!staff && (!board || board.streamer_subject !== subject)) return refusal(403, 'fun.streamer_required', 'Only the streamer can change a quest\'s state.');
++ const from = to === 'live' ? ['accepted'] : ['live'];
++ if (!from.includes(quest.status)) return refusal(409, 'fun.quest_state', `A quest must be ${from.join(' or ')} before it can be ${to}.`);
++ const updated = await t.maybe(
++ `UPDATE fun_quests SET status = $2, ${to === 'completed' ? 'completed_at = now(),' : ''} revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
++ [quest.id, to],
++ );
++ const out = wireQuest(updated, { votes: (await countsForQuest(quest.id, t)).votes });
++ await search.sync(t, 'quest', out);
++ return { ok: true, quest: out };
++ });
++ }
++
++ async function verifyQuest({ slug, subject, staff = false, via = 'moderator' }) {
++ if (!['chat', 'moderator'].includes(via)) return refusal(400, 'fun.quest.invalid', 'via must be chat or moderator', 'via');
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ const board = await boardById(quest.board_id, t);
++ if (!staff && (!board || board.streamer_subject !== subject)) return refusal(403, 'fun.streamer_required', 'Only the streamer can verify this quest.');
++ if (quest.status === 'verified') return { ok: true, quest: wireQuest(quest), unchanged: true };
++ if (quest.status !== 'completed') return refusal(409, 'fun.quest_not_completed', 'The quest must be completed before it is verified.');
++ if (via === 'moderator' && !staff) return refusal(403, 'fun.staff_required', 'A moderator verification needs a Network role global_mod or above.');
++ const checks = Number(await t.value("SELECT COUNT(*)::int FROM fun_quest_checks WHERE quest_id = $1 AND via = 'chat'", [quest.id])) || 0;
++ const quorum = Math.max(1, Number(questConfig.verifyVotes) || 3);
++ if (via === 'chat' && checks < quorum) {
++ return refusal(409, 'fun.quest_quorum', `The chat has ${checks} of ${quorum} verification votes.`);
++ }
++ if (via === 'moderator' && subject && SUBJECT_RE.test(String(subject))) {
++ await t.exec("INSERT INTO fun_quest_checks (quest_id, voter, subject, via) VALUES ($1, $2, $3, 'moderator') ON CONFLICT (quest_id, voter) DO NOTHING", [quest.id, `staff:${String(subject).slice(0, 64)}`, subject]);
++ }
++ const badgeId = `fun_irl_${String(quest.slug).replace(/-/g, '_').slice(0, 40)}`;
++ const badgeName = `IRL quest: ${quest.title}`.slice(0, 120);
++ const streamer = board && board.streamer_subject ? board.streamer_subject : null;
++ const reward = await t.maybe(
++ `INSERT INTO fun_quest_rewards (id, quest_id, subject, coins, state, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, 'pending', now(), now()) ON CONFLICT (quest_id, subject) DO NOTHING RETURNING *`,
++ [s.newId('qrw'), quest.id, streamer || subject, Number(quest.reward_coins) || 0],
++ );
++ if (streamer && SUBJECT_RE.test(String(streamer))) {
++ await t.exec(
++ `INSERT INTO fun_badges (quest_id, subject, badge_id, name, awarded_at) VALUES ($1, $2, $3, $4, now())
++ ON CONFLICT (quest_id, subject) DO NOTHING`,
++ [quest.id, streamer, badgeId, badgeName],
++ );
++ }
++ const updated = await t.maybe(
++ `UPDATE fun_quests SET status = 'verified', verification = $2, verified_by = $3, verified_at = now(),
++ reward_badge = $4, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
++ [quest.id, via, subject ? (staff ? `staff:${subject}` : subject) : null, badgeId],
++ );
++ const out = wireQuest(updated, { votes: (await countsForQuest(quest.id, t)).votes, checks });
++ await search.sync(t, 'quest', out);
++ return { ok: true, quest: out, reward: reward ? wireReward(reward) : null, quorum: checks };
++ });
++ }
++
++ function wireReward(row) {
++ if (!row) return null;
++ return { id: row.id, quest_id: row.quest_id, subject: row.subject, coins: Number(row.coins) || 0, state: row.state, detail: row.detail || null, attempts: Number(row.attempts) || 0, created_at: iso(row.created_at), updated_at: iso(row.updated_at) };
++ }
++
++ async function setRewardState(id, { state, detail = null }) {
++ if (!['pending', 'credited', 'skipped', 'failed'].includes(state)) return null;
++ const row = await db.maybe(
++ `UPDATE fun_quest_rewards SET state = $2, detail = $3, attempts = attempts + 1, updated_at = now()
++ WHERE id = $1 RETURNING *`, [String(id), state, detail == null ? null : String(detail).slice(0, 500)]);
++ return wireReward(row);
++ }
++
++ async function hideQuest({ slug, staff = false }) {
++ if (!staff) return refusal(403, 'fun.staff_required', 'Only staff can hide a quest.');
++ return await s.tx(async (t) => {
++ const quest = await byQuestSlug(slug, t);
++ if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
++ if (quest.visibility === 'hidden') return { ok: true, quest: wireQuest(quest), unchanged: true };
++ const updated = await t.maybe("UPDATE fun_quests SET visibility = 'hidden', status = 'hidden', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [quest.id]);
++ await search.sync(t, 'quest', wireQuest(updated));
++ return { ok: true, quest: wireQuest(updated) };
++ });
++ }
++
++ async function questForPage(slug, { subject = null, staff = false } = {}) {
++ const quest = await byQuestSlug(slug);
++ if (!quest) return null;
++ const board = await boardById(quest.board_id);
++ const isOwner = Boolean(subject && board && board.streamer_subject === subject);
++ if (quest.visibility === 'hidden' && !staff && !isOwner) return null;
++ if (quest.visibility === 'unlisted' && !staff && !isOwner && !subject) {
++ // An unlisted suggestion is visible by its board link, not by the public quest URL, unless signed in.
++ // The board page is the shareable place; the quest page still works for a signed-in viewer by design.
++ }
++ const counts = await countsForQuest(quest.id);
++ const voted = subject ? Boolean(await db.maybe('SELECT 1 AS x FROM fun_quest_votes WHERE quest_id = $1 AND voter = $2', [quest.id, subject])) : false;
++ return { quest: wireQuest(quest, { votes: counts.votes, checks: counts.checks, mine: isOwner, voted }), board: wireBoard(board) };
++ }
++
++ async function listQuests({ limit = 20, cursor = null, city = null, status = null } = {}) {
++ const n = Math.min(50, Math.max(1, Number(limit) || 20));
++ const args = [];
++ let where = `visibility = 'public' AND status = ANY($1::text[])`;
++ args.push(PUBLIC_QUEST_STATUSES);
++ if (status) {
++ if (!PUBLIC_QUEST_STATUSES.includes(status)) return { quests: [], next_cursor: null, error: 'status' };
++ args.push(status); where += ` AND status = $${args.length}`;
++ }
++ if (city) { args.push(String(city).slice(0, 120)); where += ` AND lower(city) = lower($${args.length})`; }
++ let pos = null;
++ if (cursor) {
++ try {
++ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
++ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;
++ } catch { /* refused below */ }
++ if (!pos) return { quests: [], next_cursor: null, error: 'cursor' };
++ args.push(pos.t, pos.i); where += ` AND (created_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;
++ }
++ args.push(n + 1);
++ const rows = await db.many(`SELECT * FROM fun_quests WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT $${args.length}`, args);
++ const hasMore = rows.length > n;
++ const page = rows.slice(0, n);
++ const counts = await voteCounts(page.map((r) => r.id));
++ const last = page[page.length - 1];
++ return {
++ quests: page.map((r) => wireQuest(r, { votes: counts[r.id] || 0 })),
++ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.created_at), i: last.id })).toString('base64url') : null,
++ };
++ }
++
++ async function featuredQuests(limit = 6) {
++ const rows = await db.many(
++ `SELECT * FROM fun_quests WHERE visibility = 'public' AND status = ANY($1::text[])
++ ORDER BY CASE status WHEN 'live' THEN 0 WHEN 'accepted' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END, created_at DESC LIMIT $2`,
++ [PUBLIC_QUEST_STATUSES, Math.min(20, Math.max(1, Number(limit) || 6))],
++ );
++ const counts = await voteCounts(rows.map((r) => r.id));
++ return rows.map((r) => wireQuest(r, { votes: counts[r.id] || 0 }));
++ }
++
++ // ── Group plans ─────────────────────────────────────────
++ function planFields({ title, summary, city, lat, lon, timezone, startsAt, closesAt, visibility = 'unlisted', chatUsername = null }) {
++ const t = clean(title, 120);
++ if (t.length < 3 || t.length > 120) return refusal(400, 'fun.plan.invalid', 'title must be 3 to 120 characters', 'title');
++ const sum = summary == null || summary === '' ? null : clean(summary, 1200);
++ if (sum !== null && !sum) return refusal(400, 'fun.plan.invalid', 'summary must be at most 1200 characters', 'summary');
++ const c = city == null || city === '' ? null : clean(city, 120);
++ if (c !== null && !c) return refusal(400, 'fun.plan.invalid', 'city must be 1 to 120 characters', 'city');
++ const la = num(lat); const lo = num(lon);
++ if (la != null && (la < -90 || la > 90)) return refusal(400, 'fun.plan.invalid', 'lat must be -90 to 90', 'lat');
++ if (lo != null && (lo < -180 || lo > 180)) return refusal(400, 'fun.plan.invalid', 'lon must be -180 to 180', 'lon');
++ const tz = String(timezone || 'UTC');
++ if (!validTimezone(tz)) return refusal(400, 'fun.plan.invalid', 'timezone must be a valid IANA zone', 'timezone');
++ const start = parseWhen(startsAt);
++ if (!start) return refusal(400, 'fun.plan.invalid', 'starts_at must be an ISO time', 'starts_at');
++ if (start.getTime() < s.now() + 5 * 60_000) return refusal(400, 'fun.plan.invalid', 'starts_at must be at least 5 minutes ahead', 'starts_at');
++ if (start.getTime() > s.now() + 366 * 86_400_000) return refusal(400, 'fun.plan.invalid', 'starts_at must be within a year', 'starts_at');
++ const close = closesAt ? parseWhen(closesAt) : new Date(start.getTime() - 60 * 60_000);
++ if (!close) return refusal(400, 'fun.plan.invalid', 'closes_at must be an ISO time', 'closes_at');
++ if (close.getTime() <= s.now() + 60_000) return refusal(400, 'fun.plan.invalid', 'closes_at must be in the future', 'closes_at');
++ if (close.getTime() > start.getTime()) return refusal(400, 'fun.plan.invalid', 'closes_at must not be after starts_at', 'closes_at');
++ if (!VISIBILITIES.includes(visibility)) return refusal(400, 'fun.plan.invalid', 'visibility must be public, unlisted or hidden', 'visibility');
++ if (chatUsername != null && chatUsername !== '' && !USERNAME_RE.test(String(chatUsername))) return refusal(400, 'fun.plan.invalid', 'chat_username is not a Live/Chat username', 'chat_username');
++ return { ok: true, fields: { title: t, summary: sum, city: c, lat: la, lon: lo, timezone: tz, starts_at: start.toISOString(), closes_at: close.toISOString(), visibility, chat_username: chatUsername ? String(chatUsername) : null } };
++ }
++
++ async function createPlan({ ownerSubject, ownerUsername = null, title, summary = null, city = null, lat = null, lon = null, timezone = 'UTC', startsAt, closesAt = null, visibility = 'unlisted', chatUsername = null, options = [] }) {
++ if (!SUBJECT_RE.test(String(ownerSubject || ''))) return refusal(403, 'fun.person_required', 'A plan belongs to a signed-in person.');
++ const checked = planFields({ title, summary, city, lat, lon, timezone, startsAt, closesAt, visibility, chatUsername });
++ if (!checked.ok) return checked;
++ const opts = Array.isArray(options) ? options.slice(0, 10) : [];
++ const cleaned = [];
++ for (const [i, o] of opts.entries()) {
++ const titleOk = clean(o && o.title, 120);
++ if (!titleOk) continue;
++ cleaned.push({ ...o, title: titleOk, position: i + 1 });
++ }
++ return await s.tx(async (t) => {
++ const slug = await uniqueSlug(checked.fields.title, t);
++ const id = s.newId('pln');
++ const row = await t.maybe(
++ `INSERT INTO fun_plans (id, slug, owner_subject, owner_username, title, summary, city, lat, lon, timezone, starts_at, closes_at, status, visibility, chat_username, revision)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::timestamptz, $12::timestamptz, 'voting', $13, $14, 1) RETURNING *`,
++ [id, slug, ownerSubject, ownerUsername ? clean(ownerUsername, 64) : null, checked.fields.title, checked.fields.summary, checked.fields.city,
++ checked.fields.lat, checked.fields.lon, checked.fields.timezone, checked.fields.starts_at, checked.fields.closes_at,
++ checked.fields.visibility, checked.fields.chat_username],
++ );
++ const saved = [];
++ for (const o of cleaned) {
++ const opt = await t.maybe(
++ `INSERT INTO fun_plan_options (id, plan_id, title, details, place_name, lat, lon, accessible, price_note, starts_at, created_by, position)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11, $12) RETURNING *`,
++ [s.newId('pop'), id, o.title, o.details ? clean(o.details, 1200) : null, o.place_name ? clean(o.place_name, 200) : null,
++ num(o.lat), num(o.lon), Boolean(o.accessible), o.price_note ? clean(o.price_note, 80) : null,
++ o.starts_at ? parseWhen(o.starts_at)?.toISOString() || null : null, ownerSubject, o.position],
++ );
++ saved.push(wireOption(opt));
++ }
++ const plan = wirePlan(row, { options: saved });
++ if (row.visibility === 'public') await search.sync(t, 'plan', plan, saved);
++ return { ok: true, plan };
++ });
++ }
++
++ async function addPlanOption({ slug, subject, staff = false, title, details = null, placeName = null, lat = null, lon = null, accessible = false, priceNote = null, startsAt = null }) {
++ const t = clean(title, 120);
++ if (t.length < 1 || t.length > 120) return refusal(400, 'fun.plan.invalid', 'option title must be 1 to 120 characters', 'title');
++ return await s.tx(async (tx) => {
++ const plan = await planBySlug(slug, tx);
++ if (!plan) return refusal(404, 'fun.plan_not_found', 'No such plan.');
++ if (!staff && plan.owner_subject !== subject) return refusal(403, 'fun.plan_owner', 'Only the plan owner can add an option.');
++ if (plan.status !== 'voting') return refusal(409, 'fun.plan_closed', 'This plan is not voting.');
++ const n = Number(await tx.value('SELECT COUNT(*)::int FROM fun_plan_options WHERE plan_id = $1', [plan.id])) || 0;
++ if (n >= 10) return refusal(409, 'fun.plan_full', 'A plan has at most 10 options.');
++ const opt = await tx.maybe(
++ `INSERT INTO fun_plan_options (id, plan_id, title, details, place_name, lat, lon, accessible, price_note, starts_at, created_by, position)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11, $12) RETURNING *`,
++ [s.newId('pop'), plan.id, t, details ? clean(details, 1200) : null, placeName ? clean(placeName, 200) : null,
++ num(lat), num(lon), Boolean(accessible), priceNote ? clean(priceNote, 80) : null,
++ startsAt ? parseWhen(startsAt)?.toISOString() || null : null, subject, n + 1],
++ );
++ const updated = await tx.maybe('UPDATE fun_plans SET revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [plan.id]);
++ const options = (await tx.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
++ if (updated.visibility === 'public') await search.sync(tx, 'plan', wirePlan(updated, { options }), options);
++ return { ok: true, option: wireOption(opt) };
++ });
++ }
++
++ async function castPlanVote({ slug, subject = null, chatUsername = null, optionId = null, position = null, via = 'web', chatMessageId = null }) {
++ if (!['web', 'chat'].includes(via)) return refusal(400, 'fun.plan.invalid', 'via must be web or chat', 'via');
++ const voter = via === 'chat'
++ ? (chatUsername && USERNAME_RE.test(String(chatUsername)) ? chatVoter(chatUsername) : null)
++ : (subject && SUBJECT_RE.test(String(subject)) ? subject : null);
++ if (!voter) return refusal(403, 'fun.person_required', 'A vote needs a signed-in person or a public chat username.');
++ return await s.tx(async (t) => {
++ const plan = await planBySlug(slug, t);
++ if (!plan || plan.visibility === 'hidden') return refusal(404, 'fun.plan_not_found', 'No such plan.');
++ if (plan.status !== 'voting') return refusal(409, 'fun.plan_closed', 'This plan is not voting.');
++ if (new Date(plan.closes_at).getTime() <= s.now()) return refusal(409, 'fun.plan_closed', 'Voting has closed.');
++ let opt = null;
++ if (optionId) opt = await t.maybe('SELECT * FROM fun_plan_options WHERE id = $1 AND plan_id = $2', [String(optionId), plan.id]);
++ else if (position != null) opt = await t.maybe('SELECT * FROM fun_plan_options WHERE plan_id = $1 AND position = $2', [plan.id, Number(position)]);
++ if (!opt) return refusal(404, 'fun.plan_option_not_found', 'No such option on this plan.');
++ await t.exec(
++ `INSERT INTO fun_plan_votes (plan_id, voter, subject, option_id, via, chat_message_id, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, now(), now())
++ ON CONFLICT (plan_id, voter) DO UPDATE SET option_id = EXCLUDED.option_id, via = EXCLUDED.via, subject = EXCLUDED.subject,
++ chat_message_id = COALESCE(EXCLUDED.chat_message_id, fun_plan_votes.chat_message_id), updated_at = now()`,
++ [plan.id, voter, via === 'web' ? subject : null, opt.id, via, chatMessageId ? String(chatMessageId).slice(0, 80) : null],
++ );
++ return { ok: true, plan_id: plan.id, option_id: opt.id, position: Number(opt.position), voter };
++ });
++ }
++
++ async function decidePlan({ slug, subject, staff = false }) {
++ return await s.tx(async (t) => {
++ const plan = await planBySlug(slug, t);
++ if (!plan) return refusal(404, 'fun.plan_not_found', 'No such plan.');
++ if (!staff && plan.owner_subject !== subject) return refusal(403, 'fun.plan_owner', 'Only the plan owner can decide it.');
++ if (plan.status !== 'voting') return refusal(409, 'fun.plan_closed', 'This plan is not voting.');
++ const rows = await t.many('SELECT option_id, COUNT(*)::int AS votes FROM fun_plan_votes WHERE plan_id = $1 GROUP BY option_id ORDER BY votes DESC, option_id ASC', [plan.id]);
++ if (!rows.length) return refusal(409, 'fun.plan_no_votes', 'Nobody has voted yet.');
++ const votes = rows.map((r) => ({ option_id: r.option_id, votes: Number(r.votes) || 0 }));
++ if (votes.length > 1 && votes[0].votes === votes[1].votes) return refusal(409, 'fun.plan_tie', 'The vote is tied; add a tie-break or ask one more person to vote.');
++ const updated = await t.maybe("UPDATE fun_plans SET status = 'decided', winner_option_id = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id, votes[0].option_id]);
++ const options = (await t.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
++ const out = wirePlan(updated, { options, votes });
++ if (updated.visibility === 'public') await search.sync(t, 'plan', out, options);
++ return { ok: true, plan: out, winner: options.find((o) => o.id === votes[0].option_id) || null };
++ });
++ }
++
++ async function planForPage(slug, { subject = null, staff = false } = {}) {
++ const plan = await planBySlug(slug);
++ if (!plan) return null;
++ const isOwner = Boolean(subject && plan.owner_subject === subject);
++ if (plan.visibility === 'hidden' && !staff && !isOwner) return null;
++ const rows = await db.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id]);
++ const voteRows = await db.many('SELECT option_id, COUNT(*)::int AS votes FROM fun_plan_votes WHERE plan_id = $1 GROUP BY option_id', [plan.id]);
++ const byOption = Object.fromEntries(voteRows.map((r) => [r.option_id, Number(r.votes) || 0]));
++ const mine = subject ? await db.maybe('SELECT option_id FROM fun_plan_votes WHERE plan_id = $1 AND voter = $2', [plan.id, subject]) : null;
++ const options = rows.map((r) => wireOption(r, { votes: byOption[r.id] || 0 }));
++ const total = voteRows.reduce((n, r) => n + (Number(r.votes) || 0), 0);
++ return {
++ plan: wirePlan(plan, { options, total_votes: total, mine: isOwner, my_option_id: mine ? mine.option_id : null }),
++ options,
++ winner: plan.winner_option_id ? options.find((o) => o.id === plan.winner_option_id) || null : null,
++ isOwner,
++ };
++ }
++
++ async function listPlans({ limit = 20, cursor = null, city = null } = {}) {
++ const n = Math.min(50, Math.max(1, Number(limit) || 20));
++ const args = [];
++ let where = "visibility = 'public' AND status IN ('voting', 'decided')";
++ if (city) { args.push(String(city).slice(0, 120)); where += ` AND lower(city) = lower($${args.length})`; }
++ let pos = null;
++ if (cursor) {
++ try {
++ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
++ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;
++ } catch { /* refused */ }
++ if (!pos) return { plans: [], next_cursor: null, error: 'cursor' };
++ args.push(pos.t, pos.i); where += ` AND (created_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;
++ }
++ args.push(n + 1);
++ const rows = await db.many(`SELECT * FROM fun_plans WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT $${args.length}`, args);
++ const hasMore = rows.length > n;
++ const page = rows.slice(0, n);
++ const last = page[page.length - 1];
++ return {
++ plans: page.map(wirePlan),
++ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.created_at), i: last.id })).toString('base64url') : null,
++ };
++ }
++
++ async function planForToday(slug) {
++ const plan = await planBySlug(slug);
++ if (!plan) return null;
++ if (plan.status !== 'decided' || !plan.winner_option_id) return { plan: wirePlan(plan), error: 'not_decided' };
++ const options = (await db.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
++ return { plan: wirePlan(plan, { options }), winner: options.find((o) => o.id === plan.winner_option_id) || null };
++ }
++
++ async function hidePlan({ slug, staff = false }) {
++ if (!staff) return refusal(403, 'fun.staff_required', 'Only staff can hide a plan.');
++ return await s.tx(async (t) => {
++ const plan = await planBySlug(slug, t);
++ if (!plan) return refusal(404, 'fun.plan_not_found', 'No such plan.');
++ if (plan.visibility === 'hidden') return { ok: true, plan: wirePlan(plan), unchanged: true };
++ const updated = await t.maybe("UPDATE fun_plans SET visibility = 'hidden', status = 'cancelled', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id]);
++ await search.sync(t, 'plan', wirePlan(updated), []);
++ return { ok: true, plan: wirePlan(updated) };
++ });
++ }
++
++ // ── Reports ──────────────────────────────────────────────
++ async function createReport({ targetKind, targetId, subject, kind, reason, note = null }) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(403, 'fun.person_required', 'A report belongs to a signed-in person.');
++ if (!['quest', 'plan'].includes(targetKind)) return refusal(400, 'fun.report.invalid', 'target_kind must be quest or plan', 'target_kind');
++ if (!REPORT_KINDS.includes(kind)) return refusal(400, 'fun.report.invalid', 'kind must be safety, spam or other', 'kind');
++ const r = clean(reason, 2000);
++ if (r.length < 3) return refusal(400, 'fun.report.invalid', 'reason must be 3 to 2000 characters', 'reason');
++ const n = note == null || note === '' ? null : clean(note, 4000);
++ const target = targetKind === 'quest' ? await byQuestId(targetId) : await planById(targetId);
++ if (!target) return refusal(404, 'fun.not_found', 'No such quest or plan.');
++ const row = await db.maybe(
++ `INSERT INTO fun_reports (id, target_kind, target_id, subject, kind, reason, note, status, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'open', now()) RETURNING *`,
++ [s.newId('frp'), targetKind, targetId, subject, kind, r, n],
++ );
++ return { ok: true, report: wireReport(row) };
++ }
++
++ async function listOpenReports({ limit = 100 } = {}) {
++ const rows = await db.many(
++ `SELECT r.*, COALESCE(q.title, p.title) AS target_title, COALESCE(q.slug, p.slug) AS target_slug
++ FROM fun_reports r
++ LEFT JOIN fun_quests q ON r.target_kind = 'quest' AND q.id = r.target_id
++ LEFT JOIN fun_plans p ON r.target_kind = 'plan' AND p.id = r.target_id
++ WHERE r.status = 'open' ORDER BY r.created_at ASC LIMIT $1`,
++ [Math.min(200, Math.max(1, Number(limit) || 100))],
++ );
++ return rows.map(wireReport);
++ }
++
++ async function counts() {
++ const row = await db.maybe(
++ `SELECT (SELECT COUNT(*)::int FROM fun_quests WHERE visibility = 'public' AND status = ANY($1::text[])) AS quests,
++ (SELECT COUNT(*)::int FROM fun_plans WHERE visibility = 'public' AND status IN ('voting','decided')) AS plans,
++ (SELECT COUNT(*)::int FROM fun_badges) AS badges`, [PUBLIC_QUEST_STATUSES]);
++ return { quests: Number(row && row.quests) || 0, plans: Number(row && row.plans) || 0, badges: Number(row && row.badges) || 0 };
++ }
++
++ async function publicQuestsForSitemap(limit = 5000) {
++ return await db.many("SELECT slug, updated_at FROM fun_quests WHERE visibility = 'public' AND status = ANY($1::text[]) ORDER BY updated_at DESC LIMIT $2", [PUBLIC_QUEST_STATUSES, limit]);
++ }
++ async function publicPlansForSitemap(limit = 5000) {
++ return await db.many("SELECT slug, updated_at FROM fun_plans WHERE visibility = 'public' AND status IN ('voting','decided') ORDER BY updated_at DESC LIMIT $1", [limit]);
++ }
++ async function publicQuestsForLlms(limit = 2000) {
++ return await db.many("SELECT slug, title, brief, category, status, city FROM fun_quests WHERE visibility = 'public' AND status = ANY($1::text[]) ORDER BY created_at DESC LIMIT $2", [PUBLIC_QUEST_STATUSES, limit]);
++ }
++ async function publicPlansForLlms(limit = 2000) {
++ return await db.many("SELECT slug, title, summary, status, city, starts_at FROM fun_plans WHERE visibility = 'public' AND status IN ('voting','decided') ORDER BY starts_at ASC LIMIT $1", [limit]);
++ }
++
++ return {
++ wireBoard, wireQuest, wirePlan, wireOption, wireReport, wireReward,
++ byQuestSlug, byQuestId, boardById, planBySlug, planById, uniqueSlug,
++ createBoard, boardForSubject, boardPage, openBoardsForChat, createSuggestion, questsForSubject,
++ castQuestVote, recordQuestCheck, acceptQuest, setQuestStatus, verifyQuest, hideQuest, questForPage,
++ listQuests, featuredQuests,
++ createPlan, addPlanOption, castPlanVote, decidePlan, planForPage, listPlans, planForToday, hidePlan,
++ createReport, listOpenReports, counts, setRewardState,
++ publicQuestsForSitemap, publicPlansForSitemap, publicQuestsForLlms, publicPlansForLlms,
++ QUEST_STATUSES, PUBLIC_QUEST_STATUSES, VISIBILITIES, PLAN_STATUSES, REPORT_KINDS,
++ };
++}
++
++function slugify(title) {
++ const s = String(title || '')
++ .toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 65);
++ return s || 'fun';
++}
++
++module.exports = { createStore, slugify, wireBoard, wireQuest, wirePlan, wireOption, wireReport, SLUG_RE, USERNAME_RE, SUBJECT_RE, QUEST_STATUSES, PUBLIC_QUEST_STATUSES, VISIBILITIES, PLAN_STATUSES, REPORT_KINDS };
ddiff --git a/server/text/no-looks.js b/server/text/no-looks.js
nnew file mode 100644
iindex 0000000..c18f2f8
---- /dev/null
++++ b/server/text/no-looks.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++/**
++ * No looks in public AI/curated text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and
++ * MediaHub): a summary we write never describes a person's body, clothing, hair, face or guessed gender. A summary
++ * that names looks at all is not repaired word by word — it is replaced by the item's title. A person's own
++ * suggestion or plan text is not passed through this (that would rewrite what they wrote); the safety filter and
++ * reports handle those.
++ */
++const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');
++
++/** The summary to store for text Fun generated or curated. */
++function cleanSummary(title, summary) {
++ const t = String(title || '').trim() || 'Something to do';
++ const s = String(summary == null ? '' : summary).trim();
++ if (!s) return t;
++ if (hasLooks(s)) return t;
++ const cleaned = String(noLooks(s) || '').trim();
++ return cleaned || t;
++}
++
++module.exports = { cleanSummary, noLooks, hasLooks };
ddiff --git a/server/today.js b/server/today.js
nnew file mode 100644
iindex 0000000..a97e29b
---- /dev/null
++++ b/server/today.js
@@@ -0,0 +1,21 @@
++'use strict';
++
++/**
++ * The one body Fun sends to OpenVibe.Today's public event API. It is the person's own event, created with their own
++ * Network token; the title and description are the plan's public words, never a look description, and the link back
++ * is the plan page. No service token, no calendar fan-out.
++ */
++function planEvent(config, plan, winner) {
++ const start = new Date(plan.starts_at);
++ return {
++ kind: 'event',
++ title: `Fun: ${plan.title}`.slice(0, 120),
++ description: `${winner.title}${winner.place_name ? ` at ${winner.place_name}` : ''}. Chosen on OpenVibe.Fun: ${String(config.baseUrl).replace(/\/+$/, '')}/plans/${plan.slug}`.slice(0, 1000),
++ starts_at: start.toISOString(),
++ ends_at: new Date(start.getTime() + 2 * 60 * 60_000).toISOString(),
++ tz: plan.timezone,
++ visibility: 'public',
++ };
++}
++
++module.exports = { planEvent };
ddiff --git a/server/upstreams.js b/server/upstreams.js
nnew file mode 100644
iindex 0000000..b9f3c49
---- /dev/null
++++ b/server/upstreams.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * The public reads and the one person-authorized write Fun makes outside itself. No service grant is used here:
++ *
++ * OpenVibe.Live GET /api/streams/channel/:username/live public channel + whether it is live now
++ * OpenVibe.Chat GET /api/chat/global/history?username=… public history; Fun reads `!quest`/`!plan` commands,
++ * it never posts a message
++ * OpenVibe.Today POST /api/v1/events called with the viewer's own Network token as a Bearer
++ * when they press "Add to Today"; no service token
++ *
++ * A 404 from Live means no such channel. A failure is a typed upstream error, never cached, and no token or body is
++ * logged.
++ */
++const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++
++class UpstreamError extends Error {
++ constructor(code, detail, { status = 502 } = {}) { super(detail); this.code = code; this.status = status; }
++}
++
++function createUpstreams({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const { live, chat, today, timeoutMs } = config.upstreams;
++ const base = (u) => String(u).replace(/\/+$/, '');
++ const channels = new Map();
++
++
++ async function call(url, { method = 'GET', headers = {}, body = null, timeout = timeoutMs } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method,
++ headers: { Accept: 'application/json', 'User-Agent': `OpenVibe.Fun/0.1 (+${config.baseUrl})`, ...headers },
++ body,
++ signal: AbortSignal.timeout(timeout),
++ });
++ } catch (err) {
++ throw new UpstreamError('fun.upstream.unreachable', 'the upstream service did not answer', { status: 502 });
++ }
++ return res;
++ }
++
++ /**
++ * The public Live channel. A channel that does not exist answers { found:false }; a live one carries the first
++ * stream's title and viewer count. The answer is kept in memory for 30 s; a failure is not kept.
++ */
++ async function liveChannel(username) {
++ const name = String(username || '').trim();
++ if (!USERNAME_RE.test(name)) throw new UpstreamError('fun.live.invalid', 'a Live username is required', { status: 422 });
++ const key = name.toLowerCase();
++ const hit = channels.get(key);
++ if (hit && now() - hit.at < 30_000) return { ...hit.value, cached: true };
++ const res = await call(`${base(live)}/api/streams/channel/${encodeURIComponent(name)}/live`);
++ if (res.status === 404) return { found: false, username: name };
++ if (!res.ok) throw new UpstreamError('fun.live.unavailable', `OpenVibe.Live answered ${res.status}`);
++ const data = await res.json().catch(() => null);
++ if (!data || !data.channel) throw new UpstreamError('fun.live.unavailable', 'OpenVibe.Live answered no channel');
++ const streams = Array.isArray(data.streams) ? data.streams : [];
++ const first = streams[0] || null;
++ const value = {
++ found: true,
++ username: String(data.channel.username || name),
++ display_name: typeof data.channel.display_name === 'string' ? data.channel.display_name.slice(0, 80) : null,
++ live: streams.length > 0,
++ title: first && typeof first.title === 'string' ? first.title.slice(0, 200) : null,
++ viewers: first && Number.isFinite(Number(first.viewer_count)) ? Number(first.viewer_count) : null,
++ url: `https://openvibe.live/@${encodeURIComponent(String(data.channel.username || name))}`,
++ };
++ channels.set(key, { at: now(), value });
++ if (channels.size > 500) for (const [k, v] of channels) if (now() - v.at > 10 * 60_000) channels.delete(k);
++ return value;
++ }
++
++ /** One public page of a channel's chat history (oldest→newest when after_id is used). Never a write. */
++ async function chatHistory({ username, afterId = null, limit = 200 } = {}) {
++ const name = String(username || '').trim();
++ if (!USERNAME_RE.test(name)) throw new UpstreamError('fun.chat.invalid', 'a Chat username is required', { status: 422 });
++ const q = new URLSearchParams({ username: name, limit: String(Math.min(500, Math.max(1, Number(limit) || 200))) });
++ if (afterId != null && afterId !== '') q.set('after_id', String(afterId));
++ const res = await call(`${base(chat)}/api/chat/global/history?${q.toString()}`);
++ if (res.status === 404) return { messages: [] };
++ if (!res.ok) throw new UpstreamError('fun.chat.unavailable', `OpenVibe.Chat answered ${res.status}`);
++ const data = await res.json().catch(() => null);
++ if (!data) throw new UpstreamError('fun.chat.unavailable', 'OpenVibe.Chat answered no JSON');
++ return { messages: Array.isArray(data.messages) ? data.messages : [] };
++ }
++
++ /**
++ * Create one event in the caller's own Today. `token` is the viewer's Network session token from their cookie or
++ * Bearer; it is sent only here and never stored. The idempotency key is a stable plan id.
++ */
++ async function todayCreate({ token, body, idempotencyKey = null } = {}) {
++ if (!token) throw new UpstreamError('fun.today.sign_in', 'a person\'s Network token is needed to add a plan to Today', { status: 401 });
++ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
++ if (idempotencyKey) headers['Idempotency-Key'] = String(idempotencyKey).slice(0, 100);
++ let res;
++ try {
++ res = await call(`${base(today)}/api/v1/events`, { method: 'POST', headers, body: JSON.stringify(body), timeout: 8000 });
++ } catch (err) {
++ throw new UpstreamError('fun.today.unavailable', 'OpenVibe.Today did not answer');
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const detail = data && (data.message || data.code) ? String(data.message || data.code).slice(0, 200) : `OpenVibe.Today answered ${res.status}`;
++ throw new UpstreamError('fun.today.refused', detail, { status: res.status >= 400 && res.status < 500 ? res.status : 502 });
++ }
++ if (!data || !data.id) throw new UpstreamError('fun.today.unavailable', 'OpenVibe.Today answered no event');
++ return { id: data.id, url: `https://openvibe.today/e/${encodeURIComponent(data.id)}`, event: data };
++ }
++
++ return { liveChannel, chatHistory, todayCreate, UpstreamError, base };
++}
++
++module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..eb9536c
---- /dev/null
++++ b/server/views/index.js
@@@ -0,0 +1,414 @@
++'use strict';
++
++/**
++ * The Fun room's pages (plan 2026-10-28 §5.10). Server-rendered and whole without JavaScript: every action is a
++ * plain form or link. public/css/fun.css and public/js/fun.js are one-comment placeholders the main session
++ * designs; the guide, the quest flow, the vote and the plan all work without them.
++ *
++ * home({ counts, featured, plans }) the room, featured quests, plans and a guide teaser
++ * guide({ values, result, error }) the local guide form and its OSM/Ticketmaster answer
++ * quests({ quests, ideas }) public IRL quests and the safe idea deck
++ * board({ board, quests, live, ... }) one streamer's suggestion board (share link)
++ * quest({ q, board, live, ... }) one quest, its votes/checks and the streamer's actions
++ * live({ boards, quests }) the streamer's dashboard
++ * plans({ plans }) public group plans
++ * plan({ p, options, winner, ... }) one plan, its options and votes
++ * planNew({ values }) start a group plan
++ * developers(), signIn(), notFound(), overlay()
++ * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are stable
++ * and start with `fu-`.
++ */
++const { esc } = require('../render/html');
++const sc = require('openvibe-shared/showcase');
++
++const STATUS_LABEL = { suggested: 'Suggested', accepted: 'Accepted', live: 'Live now', completed: 'Completed', verified: 'Verified', rejected: 'Not chosen', hidden: 'Hidden', voting: 'Voting', decided: 'Decided', cancelled: 'Cancelled' };
++const CATEGORY_LABEL = { near_me: 'Near me', outside: 'Outside', brainy: 'Brainy', silly: 'Silly', chill: 'Chill' };
++const KIND_LABEL = { all: 'Anything', outside: 'Outside', inside: 'Inside (rainy day)', arts: 'Arts', food: 'Food & drink' };
++
++const statusBadge = (s) => `<span class="fu-status fu-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
++const categoryLabel = (c) => esc(CATEGORY_LABEL[c] || c || 'Near me');
++const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');
++const when = (iso) => (iso ? esc(String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')) : '—');
++const pct = (n, total) => (total ? Math.round((Number(n) / Number(total)) * 100) : 0);
++const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);
++
++function empty(text, href = '/guide', label = 'Open the guide') {
++ return `<div class="fu-empty"><p>${esc(text)}</p><a class="fu-btn fu-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;
++}
++
++function questCard(q) {
++ return `<li class="fu-quest-card" data-quest="${esc(q.slug)}">
++<a class="fu-quest-link" href="/q/${esc(q.slug)}">
++<span class="fu-quest-title">${esc(q.title)}</span>
++<span class="fu-quest-meta">${statusBadge(q.status)} · ${categoryLabel(q.category)}${q.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}</span>
++<span class="fu-quest-facts">${q.votes == null ? '' : `<strong class="fu-votes">${Number(q.votes)}</strong> ${Number(q.votes) === 1 ? 'vote' : 'votes'} · `}${q.streamer_username ? `@${esc(q.streamer_username)} on Live` : ''}${q.city ? ` · ${esc(q.city)}` : ''}</span>
++</a></li>`;
++}
++
++function planCard(p) {
++ return `<li class="fu-plan-card" data-plan="${esc(p.slug)}">
++<a class="fu-plan-link" href="/plans/${esc(p.slug)}">
++<span class="fu-plan-title">${esc(p.title)}</span>
++<span class="fu-plan-meta">${statusBadge(p.status)} · ${when(p.starts_at)}${p.city ? ` · ${esc(p.city)}` : ''}</span>
++</a></li>`;
++}
++
++/** The home's picture: a small city block with idea pins and a quest card flipping up. */
++function cityMotif() {
++ return `<div class="fu-map-motif" aria-hidden="true">
++<svg class="fu-map-lines" viewBox="0 0 420 280" preserveAspectRatio="none"><path d="M0 70 L420 96"/><path d="M0 180 L420 150"/><path d="M70 0 L110 280"/><path d="M250 0 L225 280"/><path d="M340 0 L370 280"/></svg>
++<span class="fu-pin fu-pin-1">a bar</span><span class="fu-pin fu-pin-2">a mural</span><span class="fu-pin fu-pin-3">a bench</span><span class="fu-pin fu-pin-4">a gig</span>
++<span class="fu-flip-card"><small>IRL quest</small><b>Get a stranger to beatbox</b><span>chat is voting</span></span>
++</div>`;
++}
++
++function home({ counts = {}, featured = [], plans = [] } = {}) {
++ const live = `<section class="fu-section" id="fu-featured" aria-labelledby="fu-featured-h">
++<div class="fu-head-row"><h2 id="fu-featured-h">IRL quests on Live</h2><a class="fu-more" href="/quests">All quests</a></div>
++${featured.length ? `<ol class="fu-quests">${featured.slice(0, 6).map(questCard).join('')}</ol>` : empty('No IRL quest is public yet. A streamer opens a board and chat suggests the first one.', '/live', 'Open a board on Live')}
++</section>
++<section class="fu-section" id="fu-plans" aria-labelledby="fu-plans-h">
++<div class="fu-head-row"><h2 id="fu-plans-h">Plans, decided together</h2><a class="fu-more" href="/plans">All plans</a></div>
++${plans.length ? `<ol class="fu-plans">${plans.slice(0, 4).map(planCard).join('')}</ol>` : empty('No group plan is public yet. Start one and send the link to the group.', '/plans/new', 'Start a plan')}
++</section>
++<section class="fu-section" id="fu-guide-teaser" aria-labelledby="fu-guide-teaser-h">
++<div class="fu-head-row"><h2 id="fu-guide-teaser-h">What's fun near me?</h2><a class="fu-more" href="/guide">The local guide</a></div>
++<p class="fu-lede">A city, a rainy afternoon or a cheap date: places from OpenStreetMap, local events from Ticketmaster when a key is configured, and a map link for every answer. No model call, no paid provider.</p>
++</section>`;
++ const room = sc.room({
++ room: 'fun',
++ hero: {
++ title: 'Something to do, alone, together, or live',
++ lede: 'IRL quests for streamers and their chat, a local guide that reads OpenStreetMap, and group plans where the vote picks the plan. Everything has a map, a time and a link — and it all works without JavaScript.',
++ start: { href: '/guide', label: 'Find something near me' },
++ chips: [{ label: 'IRL quests', href: '/quests' }, { label: 'Group plans', href: '/plans' }, { label: 'For streamers', href: '/live' }],
++ proof: Number(counts.quests) || Number(counts.plans) ? [
++ ...(Number(counts.quests) ? [{ value: Number(counts.quests), label: Number(counts.quests) === 1 ? 'public quest' : 'public quests' }] : []),
++ ...(Number(counts.plans) ? [{ value: Number(counts.plans), label: Number(counts.plans) === 1 ? 'plan' : 'plans' }] : []),
++ ] : [],
++ motif: cityMotif(),
++ },
++ scenes: [
++ { title: 'Chat suggests, you accept', text: 'A streamer opens a board for one session. Viewers suggest safe IRL challenges, chat votes with !quest vote, and the streamer accepts the one to do on stream.',
++ html: `<div class="fu-demo-board"><span class="fu-status fu-status-accepted">Accepted</span><b>Find the best bench with a view</b><small>12 votes · @ada on Live</small></div>`,
++ action: { href: '/live', label: 'Open a board' } },
++ { title: 'A place becomes a plan', text: 'The guide shows what is open, what is free and what is step-free. One press turns a place into a group plan; the group votes and the winner goes to Today.',
++ html: `<div class="fu-demo-plan"><span class="fu-pin fu-pin-static">a mural</span><span class="fu-arrow">→</span><span class="fu-status fu-status-decided">Decided</span><b>Saturday, 19:00</b></div>`,
++ action: { href: '/guide', label: 'Ask the guide' } },
++ { title: 'Verified, then paid', text: 'After the stream the quest is completed, verified by a moderator or enough chat checks, and the badge is paid. OpenCoins are loyalty points, never money, and only when the wallet is configured.',
++ html: `<div class="fu-demo-verified"><span class="fu-status fu-status-verified">Verified</span><span class="fu-badge">IRL quest badge</span></div>`,
++ action: { href: '/quests', label: 'See the quests' } },
++ ],
++ faq: { items: [
++ ['Is this a social network?', 'No. Fun never posts to Chat or Live. It reads their public pages, and the only write to another service is a plan added to your own Today with your own Network token.'],
++ ['Where does the guide get its places?', 'OpenStreetMap through Nominatim and Overpass, within both usage policies: a contact User-Agent, one request a second per host, a bounded around: query, and a seven-day cache. Every answer credits OSM (ODbL).'],
++ ['Can a quest be dangerous?', 'No. Every suggestion is checked against a small dangerous/illegal/trespassing/harassment filter before it is stored, the streamer sets the limits, and any public quest can be reported to staff.'],
++ ['What is paid on completion?', 'A free local badge, always. OpenCoins are loyalty points, never money; they are credited only when FUN_COINS is on and the Network grant exists, with per-person and network-wide daily caps.'],
++ ] },
++ });
++ const at = room.indexOf('<section class="sc-room-scenes"');
++ return `<div class="fu-wrap fu-home" id="fu-home">${at < 0 ? room : room.slice(0, at) + live + room.slice(at)}</div>`;
++}
++
++/** A small, tile-free map: the answer's pins on a grid, linked to the real map. No third-party tile requests. */
++function miniMap(places, label = 'Map of the results') {
++ const pts = (places || []).filter((p) => Number.isFinite(Number(p.lat)) && Number.isFinite(Number(p.lon))).slice(0, 12);
++ if (!pts.length) return '';
++ const lats = pts.map((p) => Number(p.lat)); const lons = pts.map((p) => Number(p.lon));
++ const minLat = Math.min(...lats); const maxLat = Math.max(...lats); const minLon = Math.min(...lons); const maxLon = Math.max(...lons);
++ const x = (lon) => 20 + ((lon - minLon) / (maxLon - minLon || 1)) * 360;
++ const y = (lat) => 20 + ((maxLat - lat) / (maxLat - minLat || 1)) * 140;
++ return `<figure class="fu-map" id="fu-map"><svg viewBox="0 0 400 180" role="img" aria-label="${esc(label)}">
++<rect x="0" y="0" width="400" height="180" rx="16" class="fu-map-bg"/>
++<path d="M0 60 H400 M0 128 H400 M96 0 V180 M232 0 V180 M318 0 V180" class="fu-map-grid"/>
++${pts.map((p, i) => `<g class="fu-map-pin"><circle cx="${x(Number(p.lon)).toFixed(1)}" cy="${y(Number(p.lat)).toFixed(1)}" r="7"/><text x="${x(Number(p.lon)).toFixed(1)}" y="${(y(Number(p.lat)) - 12).toFixed(1)}" text-anchor="middle">${i + 1}</text></g>`).join('')}
++</svg><figcaption>${pts.length} ${pts.length === 1 ? 'pin' : 'pins'}, nearest first. <a href="https://www.openstreetmap.org/copyright" rel="noopener">© OpenStreetMap contributors</a> · <a href="${esc(pts[0].map_url || `https://www.openstreetmap.org/?mlat=${pts[0].lat}&mlon=${pts[0].lon}`)}" rel="noopener">Open the map</a></figcaption></figure>`;
++}
++
++function placeCard(p, i) {
++ return `<li class="fu-place" data-place="${esc(p.id)}">
++<div class="fu-place-body"><h3>${i + 1}. ${esc(p.name)}</h3>
++<p class="fu-place-type">${esc(p.type_label || p.type)}${p.wheelchair === true ? ' · <span class="fu-accessible">Step-free</span>' : p.wheelchair === false ? ' · not step-free' : ''}${p.fee === false ? ' · free' : p.fee === true ? ' · has a fee' : ''}</p>
++${p.address ? `<p class="fu-place-addr">${esc(p.address)}</p>` : ''}
++<p class="fu-place-facts">${esc(String(p.distance_km))} km away${p.opening_hours ? ` · <span class="fu-hours">${esc(p.opening_hours)}</span>` : ''}</p>
++<div class="fu-place-links">${p.website ? `<a href="${esc(safeHttp(p.website))}" rel="nofollow noopener">Website</a> · ` : ''}<a href="${esc(p.osm_url)}" rel="nofollow noopener">OpenStreetMap</a> · <a href="/plans/new?title=${encodeURIComponent(p.name)}&city=${encodeURIComponent(p.address || '')}&place=${encodeURIComponent(p.name)}&lat=${encodeURIComponent(p.lat)}&lon=${encodeURIComponent(p.lon)}">Make it a plan</a></div></div></li>`;
++}
++
++function eventCard(e) {
++ return `<li class="fu-event" data-event="${esc(e.id)}">
++<div class="fu-event-body"><h3>${esc(e.name)}</h3>
++<p class="fu-event-meta">${when(e.starts_at)}${e.venue ? ` · ${esc(e.venue)}` : ''}${e.city ? ` · ${esc(e.city)}` : ''}</p>
++${e.segment || e.genre ? `<p class="fu-event-type">${esc([e.segment, e.genre].filter(Boolean).join(' · '))}</p>` : ''}
++<div class="fu-event-links">${e.url ? `<a href="${esc(safeHttp(e.url))}" rel="nofollow noopener">Tickets and details</a> · ` : ''}${e.map_url ? `<a href="${esc(e.map_url)}" rel="nofollow noopener">Map</a>` : ''}</div></div></li>`;
++}
++
++function guide({ values = {}, result = null, error = null } = {}) {
++ const v = { q: '', kind: 'all', radius: '', accessible: false, rainy: false, kids: false, cheap: false, ...values };
++ return `<div class="fu-wrap fu-guide-page" id="fu-guide">
++<header class="fu-page-h"><p class="fu-kicker">The local guide</p><h1>What's fun near me?</h1>
++<p class="fu-lede">A town, a rainy afternoon, a cheap date or a playground. The guide reads OpenStreetMap for places and Ticketmaster for events only when a key is configured. It is deterministic: no model call and no paid provider.</p></header>
++<form id="fu-guide-form" class="fu-form fu-card" method="get" action="/guide">
++<label class="fu-field"><span>Where?</span><input name="q" type="text" maxlength="200" value="${esc(v.q)}" placeholder="Portland, Oregon" required></label>
++<label class="fu-field"><span>What kind?</span><select name="kind">${Object.entries(KIND_LABEL).map(([k, l]) => `<option value="${esc(k)}"${v.kind === k ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
++<label class="fu-field"><span>How far? (km)</span><input name="radius" type="number" min="1" max="50" step="1" value="${esc(v.radius)}" placeholder="5"></label>
++<div class="fu-checks"><label><input type="checkbox" name="accessible" value="1"${v.accessible ? ' checked' : ''}> Step-free only</label><label><input type="checkbox" name="rainy" value="1"${v.rainy ? ' checked' : ''}> Rainy day (inside)</label><label><input type="checkbox" name="kids" value="1"${v.kids ? ' checked' : ''}> With kids</label><label><input type="checkbox" name="cheap" value="1"${v.cheap ? ' checked' : ''}> Free only</label></div>
++<div class="fu-actions"><button class="fu-btn" type="submit">Find something</button></div>
++<p class="fu-fine">Places © OpenStreetMap contributors, ODbL. Cached for seven days; one request a second to each OSM host.</p>
++</form>
++${error ? `<p class="fu-note is-bad" role="alert">${esc(error)}</p>` : ''}
++${result ? `<section class="fu-section" id="fu-guide-results" aria-labelledby="fu-guide-results-h">
++<div class="fu-head-row"><h2 id="fu-guide-results-h">${esc(result.location.display_name || result.location.name || 'Near you')}</h2><span class="fu-count">${result.results.length} ${result.results.length === 1 ? 'place' : 'places'}${result.cached ? ' · cached' : ''}</span></div>
++${miniMap(result.results)}
++${result.results.length ? `<ol class="fu-places" id="fu-guide-places">${result.results.map(placeCard).join('')}</ol>` : empty('Nothing matched those filters in this radius. Try a wider radius or another kind.', '/guide', 'Change the filters')}
++<section class="fu-events-sec" id="fu-guide-events" aria-labelledby="fu-guide-events-h"><h2 id="fu-guide-events-h">Events</h2>
++${result.events_configured ? (result.events.length ? `<ol class="fu-events">${result.events.map(eventCard).join('')}</ol>` : '<p class="fu-muted">Ticketmaster is configured but has no events in this radius in the next 30 days.</p>') : '<p class="fu-muted">Ticketmaster is not configured. The guide uses OpenStreetMap only; set <code>TICKETMASTER_API_KEY</code> to add events.</p>'}
++${result.events_error ? `<p class="fu-fine">Event source unavailable just now: ${esc(result.events_error)}</p>` : ''}
++</section>
++<p class="fu-fine">© OpenStreetMap contributors · ODbL 1.0. ${result.events_configured ? 'Event data from Ticketmaster Discovery.' : ''}</p>
++</section>` : ''}
++</div>`;
++}
++
++function quests({ quests = [], ideas: ideaList = [] } = {}) {
++ return `<div class="fu-wrap" id="fu-quests">
++<header class="fu-page-h"><p class="fu-kicker">IRL quests on Live</p><h1>Challenges for the real world</h1>
++<p class="fu-lede">Viewers suggest a safe challenge, chat votes with <code>!quest vote &lt;slug&gt;</code>, and the streamer accepts one on stream. Completion is checked by chat or a moderator, then the badge is paid. No challenge may be dangerous, illegal, trespassing or harassment.</p>
++<div class="fu-actions"><a class="fu-btn" href="/live">Open a board on Live</a><a class="fu-btn fu-btn-quiet" href="/guide">Find a place first</a></div></header>
++${quests.length ? `<ol class="fu-quests" id="fu-public-quests">${quests.map(questCard).join('')}</ol>` : empty('No IRL quest is public yet. The first one starts when a streamer opens a board.', '/live', 'Open a board')}
++<section class="fu-section" id="fu-ideas" aria-labelledby="fu-ideas-h"><div class="fu-head-row"><h2 id="fu-ideas-h">The safe idea deck</h2><span class="fu-count">curated, not model output</span></div>
++<ol class="fu-ideas">${ideaList.map((i) => `<li class="fu-idea"><b>${esc(i.title)}</b><span>${esc(i.brief)}</span><small>${categoryLabel(i.category)}${i.accessible ? ' · accessible' : ''}</small></li>`).join('')}</ol></section>
++<section class="fu-section" id="fu-quest-safety" aria-labelledby="fu-quest-safety-h"><h2 id="fu-quest-safety-h">The rules</h2>
++<ul class="fu-list"><li>No dangerous, illegal, trespassing or harassment challenges. The filter refuses them before they are stored.</li><li>The streamer sets the board's limits, including accessible-only when that is the promise.</li><li>A completed quest needs a chat quorum or a Network moderator before it is verified.</li><li>Anyone can report a public quest; staff can hide it.</li></ul></section>
++</div>`;
++}
++
++function questNew({ values = {}, problem = null } = {}) {
++ const v = values;
++ return `<div class="fu-wrap fu-narrow" id="fu-quest-new">
++<header class="fu-page-h"><p class="fu-kicker">For streamers</p><h1>Open a quest board</h1>
++<p class="fu-lede">One board for one session. Your signed-in Network username must match the Live channel; Fun checks the public Live channel before it writes anything.</p></header>
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++<form id="fu-board-form" class="fu-form fu-card" method="post" action="/quests/boards">
++<label class="fu-field"><span>Live channel</span><input name="live_username" type="text" maxlength="64" value="${esc(v.live_username || '')}" required placeholder="ada"></label>
++<label class="fu-field"><span>Board title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Saturday IRL quests"></label>
++<label class="fu-field"><span>City (optional)</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
++<label class="fu-field"><span>Closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-28T22:00:00Z"></label>
++<label class="fu-field"><span>Most suggestions</span><input name="max_suggestions" type="number" min="1" max="100" value="${esc(v.max_suggestions || 30)}"></label>
++<label class="fu-check"><input type="checkbox" name="accessible_only" value="1"${v.accessible_only ? ' checked' : ''}> Only accessible activities</label>
++<label class="fu-field"><span>Chat channel for votes</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="defaults to the Live channel"></label>
++<div class="fu-actions"><button class="fu-btn" type="submit">Open the board</button></div>
++<p class="fu-fine">The board itself is unlisted; only the quest you accept becomes public.</p>
++</form></div>`;
++}
++
++function board({ board: b, quests: list = [], live = null, signedIn = false, mine = false, staff = false, flash = null, problem = null } = {}) {
++ return `<div class="fu-wrap" id="fu-board" data-board="${esc(b.id)}">
++<header class="fu-page-h"><p class="fu-kicker">Quest board ${mine || staff ? '· yours' : ''}</p><h1>${esc(b.title)}</h1>
++<p class="fu-lede">${esc(b.live_username)} on OpenVibe.Live${b.city ? ` · ${esc(b.city)}` : ''} · ${b.accessible_only ? 'accessible activities only · ' : ''}closes ${when(b.closes_at)}. ${live && live.found && live.live ? '<strong>Live now.</strong>' : ''}</p>
++<div class="fu-actions"><a class="fu-btn" href="${esc(live && live.url ? live.url : `https://openvibe.live/@${encodeURIComponent(b.live_username)}`)}" rel="noopener">Watch @${esc(b.live_username)}</a>${mine || staff ? `<a class="fu-btn fu-btn-quiet" href="/live">Your boards</a>` : ''}</div></header>
++${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++${b.status === 'open' && signedIn ? `<section class="fu-section" id="fu-suggest" aria-labelledby="fu-suggest-h"><h2 id="fu-suggest-h">Suggest a challenge</h2>
++<form id="fu-suggest-form" class="fu-form fu-card" method="post" action="/quests/boards/${esc(b.id)}/suggest">
++<label class="fu-field"><span>The challenge</span><input name="title" type="text" maxlength="120" required placeholder="Find the best bench with a view"></label>
++<label class="fu-field"><span>How it works (optional)</span><textarea name="brief" rows="2" maxlength="600"></textarea></label>
++<label class="fu-field"><span>Kind</span><select name="category">${Object.entries(CATEGORY_LABEL).map(([k, l]) => `<option value="${esc(k)}">${esc(l)}</option>`).join('')}</select></label>
++<label class="fu-field"><span>A place (optional)</span><input name="place_name" type="text" maxlength="200"></label>
++<label class="fu-check"><input type="checkbox" name="accessible" value="1"> This is accessible</label>
++<div class="fu-actions"><button class="fu-btn" type="submit">Suggest it</button></div>
++<p class="fu-fine">Checked against the safety filter before it is stored. No dangerous, illegal, trespassing or harassment challenges.</p>
++</form></section>` : ''}
++${mine || staff ? `<section class="fu-section" id="fu-deal"><form method="post" action="/quests/boards/${esc(b.id)}/idea" class="fu-inline"><button class="fu-btn fu-btn-quiet" type="submit">Deal a safe idea</button><span class="fu-fine">from the curated deck; no AI call</span></form></section>` : ''}
++<section class="fu-section" id="fu-board-quests" aria-labelledby="fu-board-quests-h"><div class="fu-head-row"><h2 id="fu-board-quests-h">Suggestions</h2><span class="fu-count">${list.length} of ${b.max_suggestions}</span></div>
++${list.length ? `<ol class="fu-quests">${list.map((q) => `<li class="fu-quest-card${q.status === 'accepted' ? ' is-accepted' : ''}" data-quest="${esc(q.slug)}">
++<div class="fu-quest-body"><a class="fu-quest-title" href="/q/${esc(q.slug)}">${esc(q.title)}</a>
++<span class="fu-quest-meta">${statusBadge(q.status)} · ${categoryLabel(q.category)} · <strong>${Number(q.votes) || 0}</strong> ${Number(q.votes) === 1 ? 'vote' : 'votes'}${q.accessible ? ' · accessible' : ''}</span>
++${q.status === 'accepted' ? '<p class="fu-fine">This is the quest. It is public and on the Search feed.</p>' : ''}</div>
++${signedIn && q.status === 'suggested' ? `<form method="post" action="/quests/${esc(q.slug)}/vote" class="fu-inline"><button class="fu-btn fu-btn-small" type="submit">Vote</button></form>` : ''}
++${(mine || staff) && q.status === 'suggested' ? `<form method="post" action="/quests/${esc(q.slug)}/accept" class="fu-inline"><button class="fu-btn fu-btn-small fu-btn-quiet" type="submit">Accept this one</button></form>` : ''}
++</li>`).join('')}</ol>` : empty('No suggestion yet. Share the board link and let chat start.', '#fu-suggest', 'Suggest one')}
++</section>
++<p class="fu-fine">Share this board: <code>${esc(b.id)}</code>. Chat commands: <code>!quest vote &lt;slug&gt;</code> and <code>!quest suggest &lt;text&gt;</code>. Fun reads public chat history; it never posts.</p>
++</div>`;
++}
++
++function quest({ q, board: b, live = null, signedIn = false, voted = false, mine = false, staff = false, flash = null, problem = null, verifyVotes = 3 } = {}) {
++ const owner = mine || staff;
++ return `<div class="fu-wrap" id="fu-quest" data-quest="${esc(q.slug)}">
++<article class="fu-quest-detail">
++<header class="fu-page-h"><p class="fu-kicker">${categoryLabel(q.category)} · ${statusBadge(q.status)}${mine ? ' · your quest' : ''}</p><h1>${esc(q.title)}</h1>
++<p class="fu-lede">${esc(q.brief)}</p>
++<dl class="fu-facts"><div><dt>Votes</dt><dd><strong id="fu-quest-votes">${Number(q.votes) || 0}</strong></dd></div><div><dt>Verification checks</dt><dd id="fu-quest-checks">${Number(q.checks) || 0}</dd></div><div><dt>On Live</dt><dd>${q.streamer_username ? `<a href="${esc(q.stream_url)}" rel="noopener">@${esc(q.streamer_username)}</a>${live && live.live ? ' · live now' : ''}` : '—'}</dd></div><div><dt>Badge</dt><dd>${q.reward_badge ? esc(q.reward_badge) : 'paid on verification'}</dd></div></dl>
++${q.place_name ? `<p class="fu-muted">Place: ${esc(q.place_name)}${q.city ? `, ${esc(q.city)}` : ''}${q.accessible ? ' · accessible' : ''}</p>` : ''}</header>
++${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++<section class="fu-section" id="fu-quest-actions" aria-labelledby="fu-quest-actions-h"><h2 id="fu-quest-actions-h">Take part</h2>
++${q.status === 'verified' ? `<p class="fu-note" role="status">Verified. ${q.reward_badge ? 'The badge is paid.' : ''} ${Number(q.reward_coins) ? 'OpenCoins are recorded by the wallet when it is configured.' : ''}</p>` : ''}
++${signedIn && q.status !== 'verified' ? `<form method="post" action="/q/${esc(q.slug)}/vote" class="fu-inline"><button class="fu-btn" type="submit"${voted ? ' disabled' : ''}>${voted ? 'You voted' : 'Vote for this quest'}</button></form>` : ''}
++${!signedIn ? `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/q/${q.slug}`)}">Sign in</a> to vote.</p>` : ''}
++${owner ? `<div class="fu-actions">
++${q.status === 'suggested' && b && b.status === 'open' ? `<form method="post" action="/q/${esc(q.slug)}/accept" class="fu-inline"><button class="fu-btn" type="submit">Accept on stream</button></form>` : ''}
++${q.status === 'accepted' ? `<form method="post" action="/q/${esc(q.slug)}/live" class="fu-inline"><button class="fu-btn" type="submit">Mark it live</button></form>` : ''}
++${q.status === 'live' ? `<form method="post" action="/q/${esc(q.slug)}/complete" class="fu-inline"><button class="fu-btn" type="submit">Mark it completed</button></form>` : ''}
++${q.status === 'completed' ? `<form method="post" action="/q/${esc(q.slug)}/verify" class="fu-inline"><input type="hidden" name="via" value="chat"><button class="fu-btn" type="submit">Verify with the chat quorum</button></form>${staff ? `<form method="post" action="/q/${esc(q.slug)}/verify" class="fu-inline"><input type="hidden" name="via" value="moderator"><button class="fu-btn fu-btn-quiet" type="submit">Verify as moderator</button></form>` : ''}` : ''}
++</div>` : ''}
++<p class="fu-fine">Chat: <code>!quest vote ${esc(q.slug)}</code> to vote, <code>!quest verify ${esc(q.slug)}</code> after completion. A verification needs ${esc(String(verifyVotes || 3))} distinct chat checks or a moderator.</p>
++</section>
++<section class="fu-section" id="fu-quest-report" aria-labelledby="fu-quest-report-h"><h2 id="fu-quest-report-h">Report a problem</h2>
++${signedIn ? `<details class="fu-report"><summary>Report this quest</summary><form id="fu-report-form" class="fu-form" method="post" action="/q/${esc(q.slug)}/report">
++<label class="fu-field"><span>Why?</span><select name="kind"><option value="safety">Unsafe</option><option value="spam">Spam</option><option value="other">Other</option></select></label>
++<label class="fu-field"><span>What is wrong?</span><input name="reason" type="text" maxlength="2000" required></label>
++<label class="fu-field"><span>Note (optional)</span><textarea name="note" rows="2" maxlength="4000"></textarea></label>
++<button class="fu-btn fu-btn-quiet" type="submit">Send the report</button></form></details>` : `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/q/${q.slug}`)}">Sign in</a> to report.</p>`}
++</section>
++<footer class="fu-quest-foot"><a class="fu-btn fu-btn-quiet" href="/overlay/q/${esc(q.slug)}">Quest overlay for OBS</a><a class="fu-btn fu-btn-quiet" href="/quests">All quests</a>${staff ? `<form method="post" action="/q/${esc(q.slug)}/hide" class="fu-inline"><button class="fu-btn fu-btn-quiet" type="submit">Hide (staff)</button></form>` : ''}</footer>
++</article></div>`;
++}
++
++function livePage({ boards = [], quests = [], signedIn = false, flash = null, problem = null } = {}) {
++ return `<div class="fu-wrap" id="fu-live">
++<header class="fu-page-h"><p class="fu-kicker">For streamers</p><h1>Put a quest on Live</h1>
++<p class="fu-lede">Open one board for a session, let chat suggest and vote, accept the one you will do, then mark it live, completed and verified. Your Live channel is checked against the public channel before a board is written. Open the overlay as a browser source in OBS.</p>
++<div class="fu-actions"><a class="fu-btn" href="/quests/new">Open a quest board</a><a class="fu-btn fu-btn-quiet" href="/quests">See public quests</a></div></header>
++${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++${!signedIn ? `<p class="fu-note">You can read this page without an account. <a href="/auth/login?next=%2Flive">Sign in</a> to open a board.</p>` : ''}
++<section class="fu-section" id="fu-my-boards" aria-labelledby="fu-my-boards-h"><h2 id="fu-my-boards-h">Your boards</h2>
++${boards.length ? `<ol class="fu-quests">${boards.map((b) => `<li class="fu-quest-card"><a class="fu-quest-link" href="/quests/boards/${esc(b.id)}"><span class="fu-quest-title">${esc(b.title)}</span><span class="fu-quest-meta">${statusBadge(b.status)} · @${esc(b.live_username)} · closes ${when(b.closes_at)}</span></a></li>`).join('')}</ol>` : empty(signedIn ? 'No board yet. Start one for your next IRL stream.' : 'Sign in to see your boards.', '/quests/new', 'Open a board')}
++</section>
++<section class="fu-section" id="fu-my-quests" aria-labelledby="fu-my-quests-h"><h2 id="fu-my-quests-h">Your latest quests</h2>
++${quests.length ? `<ol class="fu-quests">${quests.map(questCard).join('')}</ol>` : '<p class="fu-muted">A quest appears here after you accept a suggestion or deal one from the safe deck.</p>'}
++</section>
++<section class="fu-section" id="fu-streamer-safety" aria-labelledby="fu-streamer-safety-h"><h2 id="fu-streamer-safety-h">Your limits</h2>
++<p class="fu-fine">An accessible-only board refuses any suggestion not marked accessible. The safety filter is on every suggestion and chat command, before it is stored. You can hide a public quest only with staff; you can always close the board and stop taking suggestions.</p></section>
++</div>`;
++}
++
++function planNew({ values = {}, problem = null } = {}) {
++ const v = values;
++ const opts = Array.from({ length: 3 }, (_, i) => v[`option${i + 1}_title`] || '');
++ return `<div class="fu-wrap fu-narrow" id="fu-plan-new">
++<header class="fu-page-h"><p class="fu-kicker">A group plan</p><h1>Pick a plan together</h1>
++<p class="fu-lede">Add two or three things to do, send the link to the group, and let the votes choose. The winner can go to your own Today in one press; Fun never writes to anyone else's calendar.</p></header>
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++<form id="fu-plan-form" class="fu-form fu-card" method="post" action="/plans">
++<label class="fu-field"><span>Plan title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Friday with the group"></label>
++<label class="fu-field"><span>Why / notes (optional)</span><textarea name="summary" rows="2" maxlength="1200">${esc(v.summary || '')}</textarea></label>
++<label class="fu-field"><span>City</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
++<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="2026-10-30T19:00:00Z"></label>
++<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-29T19:00:00Z"></label>
++<label class="fu-field"><span>Timezone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles"></label>
++<label class="fu-field"><span>Who can see it?</span><select name="visibility"><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
++<label class="fu-field"><span>Chat channel for votes (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="ada"></label>
++<fieldset class="fu-options"><legend>Options</legend>
++${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label>`).join('')}
++</fieldset>
++<div class="fu-actions"><button class="fu-btn" type="submit">Start the plan</button></div>
++<p class="fu-fine">Times are UTC unless the timezone says otherwise. Voting closes one hour before the start when left blank.</p>
++</form></div>`;
++}
++
++function plans({ plans: list = [] } = {}) {
++ return `<div class="fu-wrap" id="fu-plans">
++<header class="fu-page-h"><p class="fu-kicker">Group plans</p><h1>Decide it together</h1>
++<p class="fu-lede">Two or three options, one link, one vote each. The winner is chosen by the group and can go to Today. A plan with votes from public chat shows the matching command.</p>
++<div class="fu-actions"><a class="fu-btn" href="/plans/new">Start a plan</a><a class="fu-btn fu-btn-quiet" href="/guide">Find a place</a></div></header>
++${list.length ? `<ol class="fu-plans" id="fu-public-plans">${list.map(planCard).join('')}</ol>` : empty('No public plan yet. Start one and share the link with the group.', '/plans/new', 'Start a plan')}
++</div>`;
++}
++
++function plan({ p, options = [], winner = null, signedIn = false, mine = false, staff = false, flash = null, problem = null, myOptionId = null, totalVotes = 0 } = {}) {
++ const owner = mine || staff;
++ return `<div class="fu-wrap" id="fu-plan" data-plan="${esc(p.slug)}">
++<article class="fu-plan-detail">
++<header class="fu-page-h"><p class="fu-kicker">${statusBadge(p.status)} · ${p.visibility === 'public' ? 'public' : p.visibility === 'unlisted' ? 'share link' : 'private'}</p><h1>${esc(p.title)}</h1>
++<p class="fu-lede">${p.summary ? esc(p.summary) : `A group plan${p.city ? ` in ${esc(p.city)}` : ''}.`}</p>
++<dl class="fu-facts"><div><dt>Starts</dt><dd>${when(p.starts_at)}</dd></div><div><dt>Timezone</dt><dd>${esc(p.timezone)}</dd></div><div><dt>Votes</dt><dd id="fu-plan-total">${Number(totalVotes) || 0}</dd></div><div><dt>Voting closes</dt><dd>${when(p.closes_at)}</dd></div></dl></header>
++${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
++${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++<section class="fu-section" id="fu-plan-options" aria-labelledby="fu-plan-options-h"><div class="fu-head-row"><h2 id="fu-plan-options-h">The options</h2><span class="fu-count">${options.length} ${options.length === 1 ? 'option' : 'options'}</span></div>
++${options.length ? `<ol class="fu-options-list">${options.map((o) => `<li class="fu-option${o.id === (winner && winner.id) ? ' is-winner' : ''}${o.id === myOptionId ? ' is-mine' : ''}" data-option="${esc(o.id)}">
++<div class="fu-option-body"><h3>${Number(o.position)}. ${esc(o.title)}${o.id === (winner && winner.id) ? ' <span class="fu-winner">chosen</span>' : ''}</h3>
++${o.details ? `<p>${esc(o.details)}</p>` : ''}
++<p class="fu-option-meta">${esc(String(o.votes || 0))} ${Number(o.votes) === 1 ? 'vote' : 'votes'} · ${pct(o.votes, totalVotes)}%${o.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}${o.price_note ? ` · ${esc(o.price_note)}` : ''}${o.place_name ? ` · ${esc(o.place_name)}` : ''}</p>
++<div class="fu-vote-bar" role="img" aria-label="${esc(String(o.votes || 0))} votes"><span style="width:${pct(o.votes, totalVotes)}%"></span></div></div>
++${signedIn && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/vote" class="fu-inline"><input type="hidden" name="option_id" value="${esc(o.id)}"><button class="fu-btn fu-btn-small" type="submit"${o.id === myOptionId ? ' disabled' : ''}>${o.id === myOptionId ? 'Your vote' : 'Vote'}</button></form>` : ''}
++</li>`).join('')}</ol>` : empty('No option yet. The owner can add one.', `/plans/${esc(p.slug)}`, 'Back')}
++${!signedIn ? `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/plans/${p.slug}`)}">Sign in</a> to vote.</p>` : ''}
++${p.chat_username && p.status === 'voting' ? `<p class="fu-fine">Chat: <code>!plan vote ${esc(p.slug)} &lt;number&gt;</code>. Fun reads public chat history; it never posts.</p>` : ''}
++</section>
++<section class="fu-section" id="fu-plan-actions" aria-labelledby="fu-plan-actions-h"><h2 id="fu-plan-actions-h">Decide and go</h2>
++${owner && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/decide" class="fu-inline"><button class="fu-btn" type="submit">Decide the winner</button></form>` : ''}
++${p.status === 'decided' && signedIn ? `<form method="post" action="/plans/${esc(p.slug)}/today" class="fu-inline"><button class="fu-btn" type="submit">Add the winner to my Today</button></form>` : ''}
++${p.status === 'decided' && !signedIn ? `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/plans/${p.slug}`)}">Sign in</a> to add the winner to your Today.</p>` : ''}
++${owner ? `<form method="post" action="/plans/${esc(p.slug)}/options" class="fu-form fu-card fu-add-option"><h3>Add an option</h3><label class="fu-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label><label class="fu-field"><span>Place (optional)</span><input name="place_name" type="text" maxlength="200"></label><label class="fu-check"><input type="checkbox" name="accessible" value="1"> Accessible</label><button class="fu-btn fu-btn-quiet" type="submit">Add it</button></form>` : ''}
++${owner && p.status === 'voting' ? `<p class="fu-fine">Add options before you decide. The vote is one per person or public chat username.</p>` : ''}
++</section>
++<section class="fu-section" id="fu-plan-report" aria-labelledby="fu-plan-report-h"><h2 id="fu-plan-report-h">Report</h2>
++${signedIn ? `<details class="fu-report"><summary>Report this plan</summary><form id="fu-plan-report-form" class="fu-form" method="post" action="/plans/${esc(p.slug)}/report">
++<label class="fu-field"><span>Why?</span><select name="kind"><option value="safety">Unsafe</option><option value="spam">Spam</option><option value="other">Other</option></select></label>
++<label class="fu-field"><span>What is wrong?</span><input name="reason" type="text" maxlength="2000" required></label>
++<button class="fu-btn fu-btn-quiet" type="submit">Send the report</button></form></details>` : `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/plans/${p.slug}`)}">Sign in</a> to report.</p>`}
++${staff ? `<form method="post" action="/plans/${esc(p.slug)}/hide" class="fu-inline"><button class="fu-btn fu-btn-quiet" type="submit">Hide (staff)</button></form>` : ''}
++</section>
++<footer class="fu-plan-foot"><a class="fu-btn fu-btn-quiet" href="/plans">All plans</a><span class="fu-fine">Share link: ${esc(p.slug)}</span></footer>
++</article></div>`;
++}
++
++function developers() {
++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
++ return `<div class="fu-wrap" id="fu-developers">
++<header class="fu-page-h"><p class="fu-kicker">Developers</p><h1>The Fun API</h1>
++<p class="fu-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>fun_at</code> session cookie or a Network user token as a Bearer. No route needs a service token; the one outbound write uses the person's own token.</p></header>
++<section><h2>Local guide</h2><dl class="fu-eps">
++${ep('GET', '/api/v1/guide?q=&kind=&accessible=&rainy=&kids=&cheap=&radius=', 'OSM places with attribution and a map link; Ticketmaster events only when a key is configured.')}
++</dl></section>
++<section><h2>IRL quests</h2><dl class="fu-eps">
++${ep('GET', '/api/v1/quests?limit=&cursor=&status=&city=', 'Public IRL quests, newest first.')}
++${ep('GET', '/api/v1/quests/:slug', 'One quest with its vote and chat-check counts.')}
++${ep('GET', '/api/v1/quests/boards/:id', 'One board and its suggestions (the share link).')}
++${ep('POST', '/api/v1/quests/boards', 'A streamer opens a board; the Live channel and Network username must match.')}
++${ep('POST', '/api/v1/quests/boards/:id/suggest', '<code>{ title, brief?, category?, accessible? }</code>, safety-filtered.')}
++${ep('POST', '/api/v1/quests/:slug/vote', 'One vote per person (or one per public chat username via the read-only chat job).')}
++${ep('POST', '/api/v1/quests/:slug/accept|live|complete|verify', 'The streamer, or staff for verify; <code>{ via: "chat"|"moderator" }</code>.')}
++${ep('POST', '/api/v1/quests/:slug/hide', 'Staff hides a public quest and tombstones its Search document.')}
++</dl></section>
++<section><h2>Group plans</h2><dl class="fu-eps">
++${ep('GET', '/api/v1/plans?limit=&cursor=&city=', 'Public plans, newest first.')}
++${ep('GET', '/api/v1/plans/:slug', 'One plan with options, vote counts and the winner.')}
++${ep('POST', '/api/v1/plans', '<code>{ title, starts_at, timezone, options[] }</code>; the owner decides.')}
++${ep('POST', '/api/v1/plans/:slug/options|vote|decide', 'Add an option, cast one vote, choose the winner (tie is refused).')}
++${ep('POST', '/api/v1/plans/:slug/today', 'Adds the winner to the caller\'s own OpenVibe.Today with their token and an idempotency key.')}
++${ep('POST', '/api/v1/plans/:slug/hide', 'Staff.')}
++</dl></section>
++<section id="fu-search"><h2>Search</h2><p class="fu-fine">Each public quest is one <code>search.index-document@1</code> (owner <code>fun</code>, type <code>quest</code>, <code>/q/&lt;slug&gt;</code>); each public plan is type <code>plan</code> (<code>/plans/&lt;slug&gt;</code>). They wait in <code>fun_outbox</code>; hiding or cancelling queues a tombstone. The relay needs the <code>search.document.write</code> grant and is not in v1.</p></section>
++</div>`;
++}
++
++function signIn({ title, text, next }) {
++ return `<div class="fu-wrap fu-page-pad fu-narrow"><div class="fu-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="fu-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
++}
++
++function notFound() {
++ return `<div class="fu-wrap fu-page-pad fu-narrow"><div class="fu-empty" id="fu-not-found"><p class="fu-kicker">404</p><h1>Nothing fun here</h1><p>This quest or plan may be hidden, its board may be closed, or the address was never one of ours.</p><a class="fu-btn" href="/">Back to Fun</a></div></div>`;
++}
++
++/** A minimal, no-JS OBS overlay: a meta refresh is the only moving part. */
++function overlay({ q, votes = 0, live = null } = {}) {
++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<meta http-equiv="refresh" content="15"><meta name="robots" content="noindex,nofollow"><title>Quest overlay</title>
++<link rel="stylesheet" href="/css/fun.css"></head><body class="fu-overlay-body">
++<main class="fu-overlay" id="fu-overlay" data-quest="${esc(q.slug)}">
++<p class="fu-overlay-kicker">IRL quest${live && live.live ? ' · live now' : ''}</p>
++<h1 class="fu-overlay-title">${esc(q.title)}</h1>
++<p class="fu-overlay-meta">${statusBadge(q.status)} · <strong>${Number(votes) || 0}</strong> ${Number(votes) === 1 ? 'vote' : 'votes'}</p>
++${q.place_name ? `<p class="fu-overlay-place">${esc(q.place_name)}</p>` : ''}
++<p class="fu-overlay-fine">Chat: <code>!quest vote ${esc(q.slug)}</code> · openvibe.fun/q/${esc(q.slug)}</p>
++</main></body></html>`;
++}
++
++module.exports = {
++ home, guide, quests, questNew, board, quest, livePage, plans, plan, planNew, developers, signIn, notFound, overlay,
++ questCard, planCard, miniMap, placeCard, eventCard, statusBadge, categoryLabel, cityMotif,
++ STATUS_LABEL, CATEGORY_LABEL, KIND_LABEL,
++};
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..ed7c3af
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,160 @@
++'use strict';
++/**
++ * ADR-033: Fun's part of an account export and deletion, applied through /internal/events with a stand-in Network.
++ * A person's boards, suggestions, votes, checks, badges, rewards, plans, options, plan votes and reports are
++ * exported. On deletion the personal rows are erased; a public quest/board and a shared plan stay as the
++ * community's record with the subject column NULL. Another person's rows are untouched. A redelivery changes
++ * nothing; a bad signature and a proxied request are refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++
++const A = ids.newId('user');
++const B = ids.newId('user');
++const EXP = `exp_${ids.ulid()}`;
++const DEL = `del_${ids.ulid()}`;
++const SECRET = `fixture_${'account'.repeat(6)}`;
++const WRONG = `fixture_${'mismatch'.repeat(6)}`;
++
++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_fun', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++(async () => {
++ const stub = await startNetworkStub();
++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'fun', clientSecret: 'fun-secret' });
++ const t = await boot({ env: { FUN_EVENTS_SECRET: SECRET }, accountSend: sender });
++ const db = t.ctx.s.db;
++ const boardA = `qbd_${ids.ulid()}`;
++ const boardB = `qbd_${ids.ulid()}`;
++ const questA = `qst_${ids.ulid()}`;
++ const questB = `qst_${ids.ulid()}`;
++ const planA = `pln_${ids.ulid()}`;
++ const planB = `pln_${ids.ulid()}`;
++ const optA = `pop_${ids.ulid()}`;
++ const optB = `pop_${ids.ulid()}`;
++ try {
++ const close = new Date(Date.now() + 7_200_000).toISOString();
++ const start = new Date(Date.now() + 172_800_000).toISOString();
++ const voteClose = new Date(Date.now() + 169_200_000).toISOString();
++ await db.exec(
++ `INSERT INTO fun_quest_boards (id, streamer_subject, live_username, chat_username, title, closes_at)
++ VALUES ($1, $2, 'ada', 'ada', 'A board', $4::timestamptz), ($3, $5, 'bo', 'bo', 'B board', $4::timestamptz)`,
++ [boardA, A, boardB, close, B]);
++ await db.exec(
++ `INSERT INTO fun_quests (id, board_id, slug, title, brief, category, source, streamer_username, chat_username, stream_url, created_by)
++ VALUES ($1, $2, 'a-quest', 'A quest', 'A brief', 'chill', 'suggestion', 'ada', 'ada', 'https://openvibe.live/@ada', $3),
++ ($4, $5, 'b-quest', 'B quest', 'B brief', 'chill', 'suggestion', 'bo', 'bo', 'https://openvibe.live/@bo', $6)`,
++ [questA, boardA, A, questB, boardB, B]);
++ await db.exec(
++ `INSERT INTO fun_quest_votes (quest_id, voter, subject) VALUES ($1, $2, $2), ($1, $3, $3)`,
++ [questA, A, B]);
++ await db.exec(
++ `INSERT INTO fun_quest_checks (quest_id, voter, subject, via) VALUES ($1, 'chat:zoe', $2, 'chat'), ($1, 'staff:b', $3, 'moderator')`,
++ [questA, A, B]);
++ await db.exec(
++ `INSERT INTO fun_badges (quest_id, subject, badge_id, name) VALUES ($1, $2, 'fun_irl_a', 'IRL quest: A quest'), ($1, $3, 'fun_irl_b', 'IRL quest: B badge')`,
++ [questA, A, B]);
++ const rewardA = `qrw_${ids.ulid()}`;
++ await db.exec(
++ `INSERT INTO fun_quest_rewards (id, quest_id, subject, coins, state) VALUES ($1, $2, $3, 25, 'skipped'), ($4, $2, $5, 25, 'skipped')`,
++ [rewardA, questA, A, `qrw_${ids.ulid()}`, B]);
++ await db.exec(
++ `INSERT INTO fun_plans (id, slug, owner_subject, owner_username, title, starts_at, closes_at, visibility)
++ VALUES ($1, 'a-plan', $2, 'ada', 'A plan', $4::timestamptz, $5::timestamptz, 'unlisted'),
++ ($3, 'b-plan', $6, 'bo', 'B plan', $4::timestamptz, $5::timestamptz, 'unlisted')`,
++ [planA, A, planB, start, voteClose, B]);
++ await db.exec(
++ `INSERT INTO fun_plan_options (id, plan_id, title, created_by, position) VALUES ($1, $2, 'A option', $3, 1), ($4, $5, 'B option', $6, 1)`,
++ [optA, planA, A, optB, planB, B]);
++ await db.exec(
++ `INSERT INTO fun_plan_votes (plan_id, voter, subject, option_id) VALUES ($1, $2, $2, $3), ($1, $4, $4, $3)`,
++ [planA, A, optA, B]);
++ const repA = `frp_${ids.ulid()}`;
++ const repB = `frp_${ids.ulid()}`;
++ await db.exec(
++ `INSERT INTO fun_reports (id, target_kind, target_id, subject, kind, reason) VALUES ($1, 'quest', $3, $4, 'safety', 'A report'), ($2, 'quest', $3, $5, 'safety', 'B report')`,
++ [repA, repB, questA, A, B]);
++
++ await check('an export carries only A\'s rows', async () => {
++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
++ assert.strictEqual(res.status, 200, res.text);
++ assert.strictEqual(res.json().outcome, 'exported');
++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
++ assert.ok(part, 'the part was pushed to Network');
++ assert.strictEqual(part.auth, 'Bearer tok_fun');
++ assert.strictEqual(part.body.subject, A);
++ const names = part.body.files.map((f) => f.name).sort();
++ for (const name of ['quest-boards.json', 'quest-suggestions.json', 'quest-votes.json', 'quest-checks.json', 'badges.json', 'rewards.json', 'plans.json', 'plan-options.json', 'plan-votes.json', 'reports.json']) assert.ok(names.includes(name), `${name}: ${names.join(',')}`);
++ const all = JSON.stringify(part.body);
++ assert.ok(all.includes('A quest'));
++ assert.ok(!all.includes('B quest'), 'nobody else\'s quest');
++ assert.ok(all.includes('A plan'));
++ assert.ok(!all.includes('B plan'), 'nobody else\'s plan');
++ assert.ok(all.includes('A report'));
++ assert.ok(!all.includes('B report'));
++ });
++
++ await check('a deletion erases A\'s personal rows, anonymizes the community records and leaves B alone', async () => {
++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
++ assert.strictEqual(res.status, 200, res.text);
++ assert.strictEqual(res.json().outcome, 'erased');
++ for (const [table, column] of [['fun_quest_votes', 'subject'], ['fun_quest_checks', 'subject'], ['fun_badges', 'subject'], ['fun_quest_rewards', 'subject'], ['fun_plan_votes', 'subject'], ['fun_reports', 'subject']]) {
++ assert.strictEqual(Number(await db.value(`SELECT COUNT(*)::int FROM ${table} WHERE ${column} = $1`, [A])), 0, table);
++ assert.ok(Number(await db.value(`SELECT COUNT(*)::int FROM ${table} WHERE ${column} = $1`, [B])) >= 1, `${table} keeps B`);
++ }
++ assert.strictEqual(await db.value('SELECT streamer_subject FROM fun_quest_boards WHERE id = $1', [boardA]), null);
++ assert.strictEqual(await db.value('SELECT created_by FROM fun_quests WHERE id = $1', [questA]), null);
++ assert.strictEqual(await db.value('SELECT owner_subject FROM fun_plans WHERE id = $1', [planA]), null);
++ assert.strictEqual(await db.value('SELECT owner_username FROM fun_plans WHERE id = $1', [planA]), null);
++ assert.strictEqual(await db.value('SELECT created_by FROM fun_plan_options WHERE id = $1', [optA]), null);
++ assert.strictEqual(await db.value('SELECT streamer_subject FROM fun_quest_boards WHERE id = $1', [boardB]), B);
++ assert.strictEqual(await db.value('SELECT created_by FROM fun_quests WHERE id = $1', [questB]), B);
++ assert.strictEqual(await db.value('SELECT owner_subject FROM fun_plans WHERE id = $1', [planB]), B);
++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
++ assert.ok(confirmation, 'the confirmation was sent');
++ assert.ok(Number(confirmation.body.erased.fun_quest_votes) >= 1);
++ assert.ok(Number(confirmation.body.erased.fun_plan_votes) >= 1);
++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
++ });
++
++ await check('a redelivery changes nothing', async () => {
++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
++ assert.strictEqual(res.status, 200);
++ assert.strictEqual(res.json().outcome, 'unchanged');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int FROM fun_quest_boards WHERE id = $1', [boardA])), 1);
++ });
++
++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
++ assert.strictEqual(bad.status, 401);
++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
++ assert.strictEqual(forwarded.status, 403);
++ });
++ } finally {
++ await t.close();
++ await stub.close();
++ }
++ done();
++})();
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..c4f0fce
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * The JSON API's refusals: application/problem+json with a stable code, an unreadable cursor, a bad limit/status,
++ * an anonymous write and an app token that is not a person. The public read shapes are exercised by the feature
++ * tests; this file is the edge contract.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('problem+json is used for a malformed body and an unknown route', async () => {
++ const bad = await t.get('/api/v1/plans', { as: t.network.addUser('kim'), body: '{', headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(bad.status, 400);
++ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(bad.json().code, 'request.malformed_json');
++ const nope = await t.get('/api/v1/nope');
++ assert.strictEqual(nope.status, 404);
++ assert.strictEqual(nope.json().code, 'route.not_found');
++ });
++
++ await check('a bad limit, status or cursor is a 400 fun.invalid', async () => {
++ for (const p of ['/api/v1/quests?limit=0', '/api/v1/quests?status=hidden', '/api/v1/quests?cursor=garbage', '/api/v1/plans?limit=0', '/api/v1/plans?cursor=garbage']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 400, `${p}: ${r.text}`);
++ assert.strictEqual(r.json().code, 'fun.invalid', p);
++ }
++ const ideas = await t.get('/api/v1/quests/ideas');
++ assert.strictEqual(ideas.status, 200);
++ assert.ok(ideas.json().count >= 8);
++ assert.ok(ideas.json().ideas.every((i) => i.title && i.brief && i.category));
++ });
++
++ await check('an anonymous write is 401; an app token is 403, never silently a person', async () => {
++ const anon = await t.get('/api/v1/quests/anything/vote', { json: {} });
++ assert.strictEqual(anon.status, 401);
++ assert.strictEqual(anon.json().code, 'fun.sign_in_required');
++ const user = t.network.addUser('kim');
++ const now = Math.floor(Date.now() / 1000);
++ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.fun'], cap: ['x.y.z'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production', jti: 'app-test', subject_id: user.subject, iat: now, exp: now + 300 });
++ const r = await t.get('/api/v1/plans', { bearer: app, json: { title: 'No' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'fun.person_required');
++ });
++
++ await check('a missing guide query or place is an application problem', async () => {
++ const noQuery = await t.get('/api/v1/guide');
++ assert.strictEqual(noQuery.status, 422);
++ assert.strictEqual(noQuery.json().code, 'fun.guide.invalid');
++ const missing = await t.get('/api/v1/guide?q=nowhere');
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.json().code, 'fun.guide.not_found');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/chat-sync.test.js b/test/chat-sync.test.js
nnew file mode 100644
iindex 0000000..622a006
---- /dev/null
++++ b/test/chat-sync.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * Chat votes are read, never posted. One public page of a channel's history creates at most one vote, suggestion,
++ * check or plan vote per message id; a re-read changes nothing. The commands are the ones the pages document:
++ * !quest vote, !quest suggest, !quest verify, !plan vote. A deleted message and an unsafe suggestion are skipped.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { parseCommand } = require('../server/chat-sync');
++
++(async () => {
++ await check('the command parser accepts only the documented, bounded forms', async () => {
++ assert.deepStrictEqual(parseCommand('!quest vote find-the-best-bench', 'kim'), { kind: 'quest', verb: 'vote', slug: 'find-the-best-bench' });
++ assert.deepStrictEqual(parseCommand('!plan vote friday-plan 2', 'kim'), { kind: 'plan', verb: 'vote', slug: 'friday-plan', position: 2 });
++ assert.strictEqual(parseCommand('!plan vote friday-plan 11', 'kim'), null);
++ assert.strictEqual(parseCommand('!quest suggest', 'kim'), null);
++ assert.strictEqual(parseCommand('hello', 'kim'), null);
++ assert.strictEqual(parseCommand('!quest vote find-the-best-bench', ''), null);
++ });
++
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('ada');
++ const kim = t.network.addUser('kim');
++ let board = null;
++ let quest = null;
++ await check('a first read creates the chat vote and the chat suggestion', async () => {
++ const b = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Chat board' } });
++ assert.strictEqual(b.status, 201);
++ board = b.json();
++ const s = await t.get(`/api/v1/quests/boards/${board.id}/suggest`, { as: kim, json: { title: 'Find the best bench', brief: 'Walk ten minutes.' } });
++ assert.strictEqual(s.status, 201);
++ quest = s.json();
++ assert.strictEqual(quest.slug, 'find-the-best-bench');
++ const out = await t.ctx.chatSync.runOnce();
++ assert.strictEqual(out.channels, 1);
++ assert.strictEqual(out.votes, 1);
++ assert.strictEqual(out.suggestions, 1);
++ assert.strictEqual(out.checks, 0, 'the check arrived before the quest was completed');
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quest_votes WHERE quest_id = $1', [quest.id])), 1);
++ const mural = await t.ctx.s.db.maybe("SELECT * FROM fun_quests WHERE title = 'Find a mural nobody has shown chat'");
++ assert.ok(mural, 'the chat suggestion was stored');
++ assert.strictEqual(mural.source, 'chat');
++ assert.match(mural.chat_author, /^[0-9a-f]{32}$/);
++ assert.ok(!mural.chat_author.includes('bo'), 'the public chat username is hashed, not copied');
++ assert.strictEqual(mural.visibility, 'unlisted');
++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int FROM fun_chat_seen")), 4);
++ });
++
++ await check('a second read of the same page adds nothing', async () => {
++ const out = await t.ctx.chatSync.runOnce();
++ assert.strictEqual(out.messages, 0);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quest_votes WHERE quest_id = $1', [quest.id])), 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quests WHERE board_id = $1', [board.id])), 2);
++ });
++
++ await check('three distinct chat verifications make the chat quorum and the streamer can verify', async () => {
++ await t.get(`/api/v1/quests/${quest.slug}/accept`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${quest.slug}/live`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${quest.slug}/complete`, { as: ada, json: {} });
++ for (const [i, name] of ['zoe', 'li', 'max'].entries()) {
++ t.upstreams.addMessage({ id: 200 + i, username: name, channel: 'ada', message: `!quest verify ${quest.slug}`, is_deleted: 0, timestamp: '2026-10-28 11:00:00' });
++ const out = await t.ctx.chatSync.runOnce();
++ assert.strictEqual(out.checks, 1, JSON.stringify(out));
++ }
++ const v = await t.get(`/api/v1/quests/${quest.slug}/verify`, { as: ada, json: { via: 'chat' } });
++ assert.strictEqual(v.status, 200, v.text);
++ assert.strictEqual(v.json().verification, 'chat');
++ });
++
++ await check('a plan chat vote is one per public chat username and is idempotent', async () => {
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++ const p = await t.get('/api/v1/plans', { as: kim, json: { title: 'Friday plan', starts_at: future, timezone: 'UTC', visibility: 'public', chat_username: 'ada', options: [{ title: 'Mural walk' }, { title: 'Bowling' }] } });
++ assert.strictEqual(p.status, 201, p.text);
++ const plan = p.json();
++ t.upstreams.addMessage({ id: 300, username: 'bo', channel: 'ada', message: `!plan vote ${plan.slug} 2`, is_deleted: 0, timestamp: '2026-10-28 11:10:00' });
++ const out = await t.ctx.chatSync.runOnce();
++ assert.strictEqual(out.planVotes, 1, JSON.stringify(out));
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_plan_votes WHERE plan_id = $1', [plan.id])), 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT position FROM fun_plan_options WHERE id = (SELECT option_id FROM fun_plan_votes WHERE plan_id = $1)', [plan.id])), 2);
++ const again = await t.ctx.chatSync.runOnce();
++ assert.strictEqual(again.messages, 0);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_plan_votes WHERE plan_id = $1', [plan.id])), 1);
++ });
++
++ await check('an unsafe or deleted chat suggestion writes no quest', async () => {
++ const before = Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quests'));
++ t.upstreams.addMessage({ id: 400, username: 'bo', channel: 'ada', message: '!quest suggest climb a bridge and jump off', is_deleted: 0, timestamp: '2026-10-28 11:20:00' });
++ t.upstreams.addMessage({ id: 401, username: 'bo', channel: 'ada', message: '!quest suggest steal a sign from the bar', is_deleted: 1, timestamp: '2026-10-28 11:21:00' });
++ await t.ctx.chatSync.runOnce();
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quests')), before);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/coins.test.js b/test/coins.test.js
nnew file mode 100644
iindex 0000000..595d2bf
---- /dev/null
++++ b/test/coins.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * OpenCoins are opt-in and are loyalty points, never money. With FUN_COINS=on and the Network grant, a verified
++ * quest credits 25 OpenCoins through Network's internal API with Fun's own service token, the subject's Network
++ * user id, app_id fun, and the reward id as the idempotency key. The token and the key are never in a response.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot({ env: { FUN_COINS: 'on' } });
++ try {
++ const ada = t.network.addUser('ada');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ const b = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Coins board' } });
++ const s = await t.get(`/api/v1/quests/boards/${b.json().id}/suggest`, { as: ada, json: { title: 'Find the best bench', brief: 'Walk ten minutes.' } });
++ const q = s.json();
++ await t.get(`/api/v1/quests/${q.slug}/accept`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${q.slug}/live`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${q.slug}/complete`, { as: ada, json: {} });
++
++ await check('verifying credits OpenCoins once, with app_id fun and the reward id as the idempotency key', async () => {
++ const v = await t.get(`/api/v1/quests/${q.slug}/verify`, { as: mod, json: { via: 'moderator' } });
++ assert.strictEqual(v.status, 200, v.text);
++ assert.strictEqual(v.json().status, 'verified');
++ assert.strictEqual(v.json().reward.state, 'credited');
++ assert.strictEqual(t.network.coinCredits.length, 1);
++ const credit = t.network.coinCredits[0];
++ assert.strictEqual(credit.app_id, 'fun');
++ assert.strictEqual(credit.amount, 25);
++ assert.strictEqual(credit.user_id, 42);
++ assert.strictEqual(credit.reason, `fun:${q.slug}`);
++ assert.strictEqual(credit.ref, `https://openvibe.fun/q/${q.slug}`);
++ const row = await t.ctx.s.db.maybe('SELECT * FROM fun_quest_rewards WHERE quest_id = $1', [q.id]);
++ assert.strictEqual(credit.idempotency_key, row.id);
++ assert.strictEqual(row.state, 'credited');
++ const tokens = t.network.tokenRequests.filter((x) => x.grant_type === 'client_credentials');
++ assert.strictEqual(tokens.length, 1);
++ assert.strictEqual(tokens[0].audience, 'openvibe.network');
++ assert.match(tokens[0].scope, /identity\.subject\.resolve/);
++ assert.match(tokens[0].scope, /network\.coins\.credit/);
++ assert.ok(!v.text.includes('tok_'), 'no token in the response');
++ assert.ok(!t.logs().includes('tok_'), 'no token in the log');
++ });
++
++ await check('a replayed verification changes nothing and credits once', async () => {
++ const v = await t.get(`/api/v1/quests/${q.slug}/verify`, { as: mod, json: { via: 'moderator' } });
++ assert.strictEqual(v.status, 200);
++ assert.strictEqual(t.network.coinCredits.length, 1);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/guide.test.js b/test/guide.test.js
nnew file mode 100644
iindex 0000000..a18e22e
---- /dev/null
++++ b/test/guide.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * The local guide: OpenStreetMap through Nominatim/Overpass with a contact User-Agent, a bounded around: query and a
++ * cache; the accessibility/rainy/kids/cheap filters; Ticketmaster off by default and only read when a key is set;
++ * and the pacer's one-request-a-second contract. No API key ever appears in a response or a log line.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createPacer } = require('../server/guide/upstream');
++
++(async () => {
++ await check('the pacer leaves at least the configured gap between calls', async () => {
++ let clock = 0; const sleeps = [];
++ const pacer = createPacer({ minIntervalMs: 1000, now: () => clock, sleep: async (ms) => { sleeps.push(ms); clock += ms; } });
++ await pacer.run(() => { clock += 10; return 'a'; });
++ await pacer.run(() => { clock += 10; return 'b'; });
++ assert.deepStrictEqual(sleeps, [990]);
++ });
++
++ const t = await boot();
++ try {
++ await check('the guide geocodes once, queries a bounded area and caches both answers', async () => {
++ const first = await t.get('/api/v1/guide?q=portland');
++ assert.strictEqual(first.status, 200, first.text);
++ const b = first.json();
++ assert.strictEqual(b.location.display_name.includes('Portland'), true);
++ assert.strictEqual(b.results.length, 4);
++ assert.strictEqual(b.attribution.text, '© OpenStreetMap contributors');
++ assert.strictEqual(b.attribution.license, 'ODbL 1.0');
++ assert.ok(b.results.every((r) => /^https:\/\/www\.openstreetmap\.org\//.test(r.osm_url)));
++ assert.ok(b.results.every((r) => r.distance_km >= 0 && r.map_url.includes('openstreetmap.org')));
++ assert.strictEqual(b.events_configured, false);
++ assert.deepStrictEqual(b.events, []);
++ const geo = t.upstreams.requestsFor('/search');
++ const over = t.upstreams.requestsFor('/interpreter');
++ assert.strictEqual(geo.length, 1);
++ assert.strictEqual(over.length, 1);
++ assert.match(geo[0].headers['user-agent'], /^OpenVibeFun\/0\.1 \(\+https:\/\/openvibe\.fun/);
++ assert.match(geo[0].headers['user-agent'], /contact@openvibe\.network/);
++ assert.match(over[0].body, /around%3A/);
++ assert.match(over[0].body, /out%20center%20tags/);
++ const second = await t.get('/api/v1/guide?q=portland');
++ assert.strictEqual(second.status, 200);
++ assert.strictEqual(second.json().cached, true);
++ assert.strictEqual(t.upstreams.requestsFor('/search').length, 1, 'the second lookup is cached');
++ assert.strictEqual(t.upstreams.requestsFor('/interpreter').length, 1, 'the second area query is cached');
++ });
++
++ await check('accessible, rainy, kids and cheap narrow the same answer', async () => {
++ const accessible = (await t.get('/api/v1/guide?q=portland&accessible=1')).json();
++ assert.ok(accessible.results.every((r) => r.wheelchair === true), JSON.stringify(accessible.results.map((r) => [r.name, r.wheelchair])));
++ assert.strictEqual(accessible.results.length, 3);
++ const rainy = (await t.get('/api/v1/guide?q=portland&rainy=1')).json();
++ assert.ok(rainy.results.every((r) => r.indoor));
++ assert.deepStrictEqual(rainy.results.map((r) => r.name).sort(), ['City Museum', 'Rainy Day Café']);
++ const kids = (await t.get('/api/v1/guide?q=portland&kids=1')).json();
++ assert.deepStrictEqual(kids.results.map((r) => r.name).sort(), ['City Museum', 'Playground']);
++ const cheap = (await t.get('/api/v1/guide?q=portland&cheap=1')).json();
++ assert.ok(cheap.results.every((r) => r.fee !== true));
++ });
++
++ await check('unknown places are 404, a bad kind is 422, and Ticketmaster is never called without a key', async () => {
++ const missing = await t.get('/api/v1/guide?q=nowhere');
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.json().code, 'fun.guide.not_found');
++ const bad = await t.get('/api/v1/guide?q=portland&kind=nope');
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual(bad.json().code, 'fun.guide.invalid');
++ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path.includes('discovery')).length, 0);
++ const noQuery = await t.get('/api/v1/guide');
++ assert.strictEqual(noQuery.status, 422);
++ });
++
++ await check('the page has the form, the result, the tile-free map and the OSM credit', async () => {
++ const r = await t.get('/guide?q=portland');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('id="fu-guide"'));
++ assert.ok(r.text.includes('id="fu-guide-form"'));
++ assert.ok(r.text.includes('id="fu-guide-results"'));
++ assert.ok(r.text.includes('id="fu-map"'));
++ assert.ok(r.text.includes('City Museum'));
++ assert.ok(r.text.includes('© OpenStreetMap'));
++ assert.ok(r.text.includes('Riverside Park'));
++ assert.ok(r.text.includes('/plans/new?title=City%20Museum'));
++ assert.ok(r.text.includes('Ticketmaster is not configured'));
++ });
++ } finally {
++ await t.close();
++ }
++
++ const tm = await boot({ env: { TICKETMASTER_API_KEY: 'tm-test-key' } });
++ try {
++ await check('with a key, Ticketmaster events are read once, shown with local times and a link, and the key is never returned', async () => {
++ const r = await tm.get('/api/v1/guide?q=portland');
++ assert.strictEqual(r.status, 200, r.text);
++ const b = r.json();
++ assert.strictEqual(b.events_configured, true);
++ assert.strictEqual(b.events.length, 1);
++ assert.strictEqual(b.events[0].name, 'Night Market');
++ assert.strictEqual(b.events[0].venue, 'Hall');
++ assert.strictEqual(b.events[0].city, 'Portland');
++ assert.ok(b.events[0].starts_at.startsWith('2026-11-01'));
++ assert.strictEqual(b.events[0].url, 'https://www.ticketmaster.com/event/tm1');
++ assert.ok(!r.text.includes('tm-test-key'));
++ const calls = tm.upstreams.state.requests.filter((x) => x.path.includes('discovery'));
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0].query.apikey, 'tm-test-key');
++ assert.ok(!tm.logs().includes('tm-test-key'), 'the key is never logged');
++ const page = await tm.get('/guide?q=portland');
++ assert.ok(page.text.includes('id="fu-guide-events"'));
++ assert.ok(page.text.includes('Night Market'));
++ assert.ok(page.text.includes('Tickets and details'));
++ assert.strictEqual(tm.upstreams.state.requests.filter((x) => x.path.includes('discovery')).length, 1, 'the event answer is cached');
++ });
++ } finally {
++ await tm.close();
++ }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..7364ac0
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,103 @@
++'use strict';
++// Many requests per test file: the per-address limits would answer 429 part-way.
++process.env.FUN_RATE_LIMIT_PER_MIN = process.env.FUN_RATE_LIMIT_PER_MIN || '100000';
++process.env.FUN_API_RATE_LIMIT_PER_MIN = process.env.FUN_API_RATE_LIMIT_PER_MIN || '100000';
++process.env.FUN_LIMITS_MINUTE = process.env.FUN_LIMITS_MINUTE || '100000';
++process.env.FUN_LIMITS_HOUR = process.env.FUN_LIMITS_HOUR || '100000';
++/**
++ * Boots OpenVibe.Fun on a temp database with a mock Network (test/helpers/mocks.js) and public upstreams
++ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client. Every test file gets its own.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
++ * t.signIn(user) → cookie header value (fun_at = a Network user token, as after /auth/callback)
++ * t.logs(), t.dbDump()
++ */
++const http = require('http');
++const { startNetwork } = require('./mocks');
++const { startUpstreams } = require('./upstreams');
++
++const captured = [];
++for (const m of ['log', 'info', 'warn', 'error']) {
++ const orig = console[m].bind(console);
++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork(opts.network || {});
++ const upstreams = await startUpstreams(opts.upstreams || {});
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.fun', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'fun', OV_OAUTH_CLIENT_SECRET: 'fun-secret', COOKIE_SECURE: 'false',
++ FUN_DRILL: '1',
++ FUN_EVENTS_URL: network.url,
++ OV_LIVE_URL: upstreams.url, OV_CHAT_URL: upstreams.url, OV_TODAY_URL: upstreams.url,
++ FUN_NOMINATIM_URL: upstreams.url, FUN_OVERPASS_URL: `${upstreams.url}/interpreter`,
++ FUN_TICKETMASTER_URL: `${upstreams.url}/discovery/v2/events.json`,
++ FUN_OSM_MIN_INTERVAL_MS: '0', FUN_TICKETMASTER_MIN_INTERVAL_MS: '0',
++ ...(opts.env || {}),
++ };
++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
++
++ const config = configLib.load(env);
++ const { createStore } = require('../../server/db');
++ const testdb = await require('./db').testDb();
++ const store = createStore(testdb.db, { now: opts.now });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
++ await built.ctx.keys.ensure();
++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const signIn = (user) => `fun_at=${network.userToken(user)}`;
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ const method = o.method || (body ? 'POST' : 'GET');
++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
++ if (o.origin) headers.origin = o.origin;
++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
++ const buf = Buffer.from(await res.arrayBuffer());
++ const text = buf.toString('utf8');
++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
++ }
++
++ async function dbDump() {
++ const db = built.ctx.s.db;
++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
++ }
++
++ const t = {
++ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,
++ logs: () => captured.join('\n'),
++ async close() {
++ built.ctx.chatSync.stop();
++ built.ctx.coins.stop();
++ await new Promise((r) => server.close(r));
++ built.ctx.keys.client.stop();
++ await built.ctx.telemetry.stop().catch(() => {});
++ await testdb.close();
++ await network.close();
++ await upstreams.close();
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); 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..444f2c1
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * FUN_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++const testDb = ({ store = process.env.FUN_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'fun', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..a1e7b5e
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `fun`, refresh_token,
++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Fun, kept for parity);
++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
++ *
++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++async function startNetwork() {
++ 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 st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], coinCredits: [], down: false };
++ let issuer = null;
++ let nextUserId = 1;
++
++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ st.users.set(u.id, u); st.byUsername.set(username, u);
++ return u;
++ }
++ function userToken(u, { ttl = 3600, iat = null } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
++ }
++ function userFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now()) return 'expired';
++ return st.users.get(c.sub) || null;
++ }
++
++ const srv = await listen(async (req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
++
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
++ if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/oauth/token' && req.method === 'POST') {
++ const ct = String(req.headers['content-type'] || '');
++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
++ if (body.client_id !== 'fun' || body.client_secret !== 'fun-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (body.grant_type === 'authorization_code') {
++ const c = st.codes.get(body.code);
++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
++ st.codes.delete(body.code);
++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, c.user);
++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'refresh_token') {
++ const user = st.refresh.get(body.refresh_token);
++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
++ st.refresh.delete(body.refresh_token);
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, user);
++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'client_credentials') {
++ const now = Math.floor(Date.now() / 1000);
++ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return json(200, {
++ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:fun', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
++ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),
++ });
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++ }
++
++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { items: [], next_cursor: null });
++ }
++
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ return json(200, { subject_id: url.searchParams.get('subject_id'), network_user_id: 42 });
++ }
++ if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ st.coinCredits.push(body);
++ return json(200, { balance: 25 });
++ }
++
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++
++ return {
++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests, coinCredits: st.coinCredits,
++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
++ setDown(v) { st.down = v; },
++ close: srv.close,
++ };
++}
++
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
nnew file mode 100644
iindex 0000000..58d94d1
---- /dev/null
++++ b/test/helpers/upstreams.js
@@@ -0,0 +1,149 @@
++'use strict';
++
++/**
++ * In-process stand-ins for OpenVibe.Live, OpenVibe.Chat, OpenVibe.Today, Nominatim, Overpass and Ticketmaster on one
++ * HTTP server. The boot helper points every URL at it.
++ *
++ * Defaults: Live channel @ada is live; two public chat lines; a Portland geocode; four OSM elements (museum, park,
++ * café, playground); one Ticketmaster event; Today records an event and answers 201.
++ *
++ * A test changes state.channels / state.messages / state.elements / state.ticketmaster and reads state.requests
++ * (headers included) to check the User-Agent, caching and the one Today write.
++ */
++const http = require('http');
++
++const DEFAULT_CHANNELS = {
++ ada: { display_name: 'Ada', live: true, streams: [{ title: 'IRL Saturday', viewer_count: 42 }] },
++ bo: { display_name: 'Bo', live: false, streams: [] },
++};
++
++const DEFAULT_MESSAGES = [
++ { id: 100, username: 'kim', channel: 'ada', message: 'hello', is_deleted: 0, timestamp: '2026-10-28 10:00:00' },
++ { id: 101, username: 'kim', channel: 'ada', message: '!quest vote find-the-best-bench', is_deleted: 0, timestamp: '2026-10-28 10:01:00' },
++ { id: 102, username: 'bo', channel: 'ada', message: '!quest suggest Find a mural nobody has shown chat', is_deleted: 0, timestamp: '2026-10-28 10:02:00' },
++ { id: 103, username: 'zoe', channel: 'ada', message: '!quest verify find-the-best-bench', is_deleted: 0, timestamp: '2026-10-28 10:03:00' },
++ { id: 104, username: 'deleted_user', channel: 'ada', message: '!quest suggest something unsafe', is_deleted: 1, timestamp: '2026-10-28 10:04:00' },
++];
++
++const DEFAULT_GEOCODE = {
++ portland: { lat: 45.5152, lon: -122.6784, display_name: 'Portland, Multnomah County, Oregon, United States' },
++ seattle: { lat: 47.6062, lon: -122.3321, display_name: 'Seattle, King County, Washington, United States' },
++};
++
++const DEFAULT_ELEMENTS = [
++ { type: 'node', id: 1, lat: 45.517, lon: -122.68, tags: { name: 'City Museum', tourism: 'museum', wheelchair: 'yes', opening_hours: 'Tu-Su 10:00-17:00', website: 'https://museum.example/' } },
++ { type: 'node', id: 2, lat: 45.519, lon: -122.676, tags: { name: 'Riverside Park', leisure: 'park', wheelchair: 'no', fee: 'no' } },
++ { type: 'way', id: 3, center: { lat: 45.512, lon: -122.674 }, tags: { name: 'Rainy Day Café', amenity: 'cafe', wheelchair: 'yes', fee: 'yes', opening_hours: 'Mo-Fr 08:00-18:00' } },
++ { type: 'node', id: 4, lat: 45.52, lon: -122.67, tags: { name: 'Playground', leisure: 'playground', wheelchair: 'yes', fee: 'no' } },
++];
++
++const DEFAULT_TICKETMASTER = [
++ {
++ id: 'tm1', name: 'Night Market', url: 'https://www.ticketmaster.com/event/tm1',
++ dates: { start: { localDate: '2026-11-01', localTime: '19:00:00', dateTime: '2026-11-01T19:00:00Z' }, timezone: 'America/Los_Angeles' },
++ _embedded: { venues: [{ name: 'Hall', city: { name: 'Portland' }, address: { line1: '1 Main St' }, location: { latitude: 45.52, longitude: -122.67 } }] },
++ classifications: [{ segment: { name: 'Music' }, genre: { name: 'Rock' } }],
++ info: 'An all-ages night market.',
++ },
++];
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ 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, headers = {}) => {
++ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
++ res.end(JSON.stringify(obj));
++ };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++async function startUpstreams() {
++ const state = {
++ channels: JSON.parse(JSON.stringify(DEFAULT_CHANNELS)),
++ messages: DEFAULT_MESSAGES.map((m) => ({ ...m })),
++ geocode: { ...DEFAULT_GEOCODE },
++ elements: JSON.parse(JSON.stringify(DEFAULT_ELEMENTS)),
++ ticketmaster: JSON.parse(JSON.stringify(DEFAULT_TICKETMASTER)),
++ todayEvents: [],
++ todayStatus: 201,
++ requests: [],
++ };
++
++ const srv = await listen(async (req, raw, json, res) => {
++ const url = new URL(req.url, 'http://x');
++ const p = url.pathname;
++ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: raw });
++
++ const live = p.match(/^\/api\/streams\/channel\/([^/]+)\/live$/);
++ if (live) {
++ const username = decodeURIComponent(live[1]);
++ const ch = state.channels[username];
++ if (!ch) return json(404, { error: 'Channel not found' });
++ return json(200, { channel: { username, display_name: ch.display_name, user_id: 7 }, streams: ch.live ? ch.streams : [] });
++ }
++
++ if (p === '/api/chat/global/history') {
++ const username = String(url.searchParams.get('username') || '');
++ const afterId = url.searchParams.get('after_id');
++ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 200)));
++ let list = state.messages.filter((m) => !username || m.channel === username || m.username === username);
++ if (afterId != null) list = list.filter((m) => Number(m.id) > Number(afterId));
++ list = [...list].sort((a, b) => Number(a.id) - Number(b.id)).slice(0, limit);
++ return json(200, { messages: list, latest_id: list.length ? list[list.length - 1].id : null });
++ }
++
++ if (p === '/api/v1/events' && req.method === 'POST') {
++ const auth = String(req.headers.authorization || '');
++ const body = JSON.parse(raw || '{}');
++ if (!auth.startsWith('Bearer ')) return json(401, { code: 'today.sign_in_required', message: 'token required' });
++ if (state.todayStatus >= 400) return json(state.todayStatus, { code: 'today.refused', message: 'Today refused this event' });
++ const id = `evt_test_${state.todayEvents.length + 1}`;
++ state.todayEvents.push({ id, auth, idempotency_key: req.headers['idempotency-key'] || null, body });
++ return json(201, { id, kind: body.kind, title: body.title, starts_at: body.starts_at }, { Location: `/api/v1/events/${id}` });
++ }
++
++ if (p === '/search') {
++ const q = String(url.searchParams.get('q') || '').toLowerCase();
++ if (q === 'nowhere') return json(200, []);
++ const hit = state.geocode[q] || state.geocode[q.split(',')[0]] || null;
++ return json(200, hit ? [hit] : []);
++ }
++
++ if (p === '/interpreter' || p === '/api/interpreter') {
++ return json(200, { elements: state.elements, version: 0.6 });
++ }
++
++ if (p === '/discovery/v2/events.json') {
++ return json(200, { _embedded: { events: state.ticketmaster }, page: { size: 20, totalElements: state.ticketmaster.length } });
++ }
++
++ return json(404, { error: 'not found' });
++ });
++
++ return {
++ url: srv.url,
++ state,
++ close: srv.close,
++ requestsFor(path) { return state.requests.filter((r) => r.path === path); },
++ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },
++ setChannel(username, ch) { state.channels[username] = ch; },
++ addMessage(m) { state.messages.push(m); },
++ };
++}
++
++module.exports = {
++ startUpstreams, DEFAULT_CHANNELS, DEFAULT_MESSAGES, DEFAULT_GEOCODE, DEFAULT_ELEMENTS, DEFAULT_TICKETMASTER,
++};
ddiff --git a/test/migration.test.js b/test/migration.test.js
nnew file mode 100644
iindex 0000000..49e796e
---- /dev/null
++++ b/test/migration.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The expand migration applies in one pass and creates the tables the plan names, plus the SDK's account receipts
++ * and telemetry. The constraints are real: a suggested quest may not be public, a bad id/slug/status is refused, and
++ * one person can vote only once per quest and plan.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('0001_initial.sql applies and creates the Fun tables', async () => {
++ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
++ for (const table of ['fun_quest_boards', 'fun_quests', 'fun_quest_votes', 'fun_quest_checks', 'fun_badges', 'fun_quest_rewards',
++ 'fun_plans', 'fun_plan_options', 'fun_plan_votes', 'fun_reports', 'fun_chat_seen', 'fun_chat_cursors',
++ 'fun_geo_cache', 'fun_outbox', 'account_data_events', 'telemetry_samples']) {
++ assert.ok(names.includes(table), `missing ${table}`);
++ }
++ });
++
++ await check('a suggested quest cannot be public; a bad status, id or slug is refused', async () => {
++ const db = t.ctx.s.db;
++ const now = new Date().toISOString();
++ const boardId = `qbd_${ids.ulid()}`;
++ await db.exec(`INSERT INTO fun_quest_boards (id, live_username, chat_username, title, closes_at) VALUES ($1, 'ada', 'ada', 'Board', $2::timestamptz)`, [boardId, new Date(Date.now() + 3_600_000).toISOString()]);
++ const id = `qst_${ids.ulid()}`;
++ await assert.rejects(db.exec(
++ `INSERT INTO fun_quests (id, board_id, slug, title, brief, category, source, status, visibility, streamer_username, chat_username, stream_url)
++ VALUES ($1, $2, 'public-suggestion', 'A title', 'A brief', 'chill', 'suggestion', 'suggested', 'public', 'ada', 'ada', 'https://openvibe.live/@ada')`,
++ [id, boardId],
++ ), /public_only_when_accepted/);
++ await assert.rejects(db.exec(
++ `INSERT INTO fun_quests (id, board_id, slug, title, brief, category, source, status, visibility, streamer_username, chat_username, stream_url)
++ VALUES ('bad_id', $1, 'x', 'A title', 'A brief', 'chill', 'suggestion', 'suggested', 'unlisted', 'ada', 'ada', 'https://openvibe.live/@ada')`, [boardId],
++ ), /fun_quests_id/);
++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int FROM fun_quests WHERE slug = 'public-suggestion'")), 0);
++ assert.ok(now);
++ });
++
++ await check('one vote per person per quest and per plan is a primary key', async () => {
++ const db = t.ctx.s.db;
++ const now = new Date().toISOString();
++ const boardId = `qbd_${ids.ulid()}`;
++ await db.exec(`INSERT INTO fun_quest_boards (id, live_username, chat_username, title, closes_at) VALUES ($1, 'ada', 'ada', 'Vote board', $2::timestamptz)`, [boardId, new Date(Date.now() + 3_600_000).toISOString()]);
++ const qid = `qst_${ids.ulid()}`;
++ await db.exec(`INSERT INTO fun_quests (id, board_id, slug, title, brief, category, source, streamer_username, chat_username, stream_url)
++ VALUES ($1, $2, 'votable-quest', 'A title', 'A brief', 'chill', 'suggestion', 'ada', 'ada', 'https://openvibe.live/@ada')`, [qid, boardId]);
++ const subject = ids.newId('user');
++ await db.exec('INSERT INTO fun_quest_votes (quest_id, voter, subject) VALUES ($1, $2, $2)', [qid, subject]);
++ await assert.rejects(db.exec('INSERT INTO fun_quest_votes (quest_id, voter, subject) VALUES ($1, $2, $2)', [qid, subject]), /duplicate key|unique/i);
++ const planId = `pln_${ids.ulid()}`;
++ await db.exec(`INSERT INTO fun_plans (id, slug, title, starts_at, closes_at) VALUES ($1, 'votable-plan', 'A plan', $2::timestamptz, $3::timestamptz)`,
++ [planId, new Date(Date.now() + 172_800_000).toISOString(), new Date(Date.now() + 169_200_000).toISOString()]);
++ const optId = `pop_${ids.ulid()}`;
++ await db.exec("INSERT INTO fun_plan_options (id, plan_id, title, position) VALUES ($1, $2, 'Option', 1)", [optId, planId]);
++ await db.exec('INSERT INTO fun_plan_votes (plan_id, voter, subject, option_id) VALUES ($1, $2, $2, $3)', [planId, subject, optId]);
++ await assert.rejects(db.exec('INSERT INTO fun_plan_votes (plan_id, voter, subject, option_id) VALUES ($1, $2, $2, $3)', [planId, subject, optId]), /duplicate key|unique/i);
++ });
++
++ await check('cached OSM JSON is jsonb and a public quest/plan has a Search outbox row', async () => {
++ const db = t.ctx.s.db;
++ await db.exec("INSERT INTO fun_geo_cache (key, body, expires_at) VALUES ('k', '{\"elements\":[]}'::jsonb, now() + interval '1 day')");
++ assert.deepStrictEqual((await db.maybe("SELECT body FROM fun_geo_cache WHERE key = 'k'")).body, { elements: [] });
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/no-looks.test.js b/test/no-looks.test.js
nnew file mode 100644
iindex 0000000..6a92d62
---- /dev/null
++++ b/test/no-looks.test.js
@@@ -0,0 +1,24 @@
++'use strict';
++/**
++ * No looks in public AI/curated text: a summary that names looks becomes the item's title; an ordinary summary is
++ * kept. A person's own suggestion is not rewritten by this module — the safety filter and reports handle it.
++ */
++const assert = require('assert');
++const { cleanSummary, hasLooks } = require('../server/text/no-looks');
++const { check, done } = require('./helpers/boot');
++
++(async () => {
++ await check('a summary that names looks becomes the title', async () => {
++ assert.strictEqual(cleanSummary('Find the best bench', 'A shirtless man in a kitchen laughs.'), 'Find the best bench');
++ assert.ok(hasLooks('A woman with long red hair smiles.'));
++ });
++
++ await check('an ordinary summary survives and the empty case becomes the title', async () => {
++ const s = cleanSummary('Find the best bench', 'A quiet bench above the river, ten minutes from the station.');
++ assert.strictEqual(s, 'A quiet bench above the river, ten minutes from the station.');
++ assert.strictEqual(cleanSummary('Find the best bench', ''), 'Find the best bench');
++ assert.strictEqual(cleanSummary(null, null), 'Something to do');
++ });
++
++ done();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..c81a08e
---- /dev/null
++++ b/test/pages.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++/**
++ * Server-rendered pages: home (featured quests, plans, guide teaser), the quests list, a board share page, one
++ * public quest/plan, the OBS overlay, developers, 404, cache headers and content-hashed assets. Public pages are
++ * public, max-age=60; a page drawn for a signed-in person is private, no-store; a board share page is no-store.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { assetVersion } = require('../server/render/layout');
++
++function jsonLd(html) {
++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++}
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('ada');
++ const kim = t.network.addUser('kim');
++ try {
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++ const b = await t.get('/api/v1/quests/boards', { as: alice, json: { live_username: 'ada', title: 'Page board' } });
++ const s = await t.get(`/api/v1/quests/boards/${b.json().id}/suggest`, { as: kim, json: { title: 'Find the best bench with a view', brief: 'Walk ten minutes and sit.', category: 'chill', accessible: true } });
++ const quest = s.json();
++ await t.get(`/api/v1/quests/${quest.slug}/vote`, { as: kim, json: {} });
++ await t.get(`/api/v1/quests/${quest.slug}/accept`, { as: alice, json: {} });
++ const p = await t.get('/api/v1/plans', { as: kim, json: { title: 'Friday with the group', summary: 'Two choices.', city: 'Portland', starts_at: future, timezone: 'UTC', visibility: 'public', options: [{ title: 'Mural walk', accessible: true }, { title: 'Bowling' }] } });
++ const plan = p.json();
++
++ await check('home: semantic, the featured quests and plans, the guide teaser, public cache with Vary: Cookie', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(r.headers.get('vary'), 'Cookie');
++ for (const marker of ['id="fu-home"', 'id="fu-featured"', 'id="fu-plans"', 'id="fu-guide-teaser"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Find the best bench with a view', '/guide']) assert.ok(r.text.includes(marker), marker);
++ const types = jsonLd(r.text).map((x) => x['@type']);
++ assert.ok(types.includes('WebSite'), types.join(','));
++ assert.ok(types.includes('WebApplication'), types.join(','));
++ });
++
++ await check('a page drawn for a signed-in person is private, no-store', async () => {
++ const r = await t.get('/', { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual(r.headers.get('vary'), 'Cookie');
++ });
++
++ await check('/quests lists the public quest and the safe idea deck; a board share page is no-store', async () => {
++ const r = await t.get('/quests');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('id="fu-quests"'));
++ assert.ok(r.text.includes('id="fu-public-quests"'));
++ assert.ok(r.text.includes('id="fu-ideas"'));
++ assert.ok(r.text.includes('Find the best bench with a view'));
++ assert.ok(r.text.includes('Find the oldest bar on this street'));
++ const b2 = await t.get('/api/v1/quests/boards', { as: alice, json: { live_username: 'ada', title: 'Open board' } });
++ const board = await t.get(`/quests/boards/${b2.json().id}`, { as: kim });
++ assert.strictEqual(board.status, 200);
++ assert.ok(board.text.includes('id="fu-board"'));
++ assert.ok(board.text.includes('id="fu-board-quests"'));
++ assert.ok(board.text.includes('id="fu-suggest-form"'));
++ assert.strictEqual(board.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('one quest page carries the actions, the report form and the overlay link', async () => {
++ const r = await t.get(`/q/${quest.slug}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.ok(r.text.includes('id="fu-quest"'));
++ assert.ok(r.text.includes('id="fu-quest-actions"'));
++ assert.ok(r.text.includes('id="fu-quest-report"'));
++ assert.ok(r.text.includes(`/overlay/q/${quest.slug}`));
++ assert.ok(r.text.includes('Find the best bench with a view'));
++ const ov = await t.get(`/overlay/q/${quest.slug}`);
++ assert.strictEqual(ov.status, 200);
++ assert.ok(ov.text.includes('id="fu-overlay"'));
++ assert.ok(ov.text.includes('http-equiv="refresh"'));
++ assert.strictEqual(ov.headers.get('x-robots-tag'), 'noindex, nofollow');
++ });
++
++ await check('one plan page carries the options, votes and the Today action', async () => {
++ const r = await t.get(`/plans/${plan.slug}`, { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('id="fu-plan"'));
++ assert.ok(r.text.includes('id="fu-plan-options"'));
++ assert.ok(r.text.includes('id="fu-plan-actions"'));
++ assert.ok(r.text.includes('Mural walk'));
++ assert.ok(r.text.includes('Bowling'));
++ assert.ok(r.text.includes('Decide the winner'));
++ });
++
++ await check('/developers, the 404 and no-JS page structure', async () => {
++ const dev = await t.get('/developers');
++ assert.strictEqual(dev.status, 200);
++ assert.strictEqual(dev.headers.get('cache-control'), 'public, max-age=60');
++ assert.ok(dev.text.includes('id="fu-developers"'));
++ assert.ok(dev.text.includes('/api/v1/quests'));
++ const nf = await t.get('/no/such/page');
++ assert.strictEqual(nf.status, 404);
++ assert.match(nf.headers.get('content-type'), /^text\/html/);
++ assert.ok(nf.text.includes('id="fu-not-found"'));
++ for (const p of ['/', '/guide', '/quests', '/plans', '/developers']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<noscript><nav/);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++ });
++
++ await check('assets are content-hashed; robots, sitemap and llms list the public pages', async () => {
++ const v = assetVersion('css/fun.css');
++ const hashed = await t.get(`/css/fun.css?v=${v}`);
++ assert.strictEqual(hashed.status, 200);
++ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ const wrong = await t.get('/css/fun.css?v=deadbeefdeadbeef');
++ assert.strictEqual(wrong.status, 200);
++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
++ const css = await t.get('/css/fun.css');
++ assert.match(css.text, /OpenVibe\.Fun: the room's own pieces/, 'the designed stylesheet');
++ const js = await t.get('/js/fun.js');
++ assert.match(js.headers.get('content-type'), /javascript/);
++ assert.strictEqual((js.text.match(/function/g) || []).length, 0, 'fun.js is a one-comment placeholder');
++ const robots = await t.get('/robots.txt');
++ assert.match(robots.text, /Disallow: \/api\//);
++ assert.match(robots.text, /Disallow: \/overlay\//);
++ const map = await t.get('/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ assert.ok(map.text.includes(`https://openvibe.fun/q/${quest.slug}`));
++ assert.ok(map.text.includes(`https://openvibe.fun/plans/${plan.slug}`));
++ const llms = await t.get('/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.ok(llms.text.includes('OpenVibe.Fun'));
++ const full = await t.get('/llms-full.txt');
++ assert.strictEqual(full.status, 200);
++ assert.ok(full.text.includes('Find the best bench with a view'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/plans.test.js b/test/plans.test.js
nnew file mode 100644
iindex 0000000..7e6ba0b
---- /dev/null
++++ b/test/plans.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/**
++ * Group plans: options, one vote per person, a tie refused, the owner decides, the winner goes to the viewer's own
++ * Today through Today's public API with the viewer's token and an idempotency key. No service token, no posting to
++ * anyone else's calendar. Public plans are listed; a hidden one is 404.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const kim = t.network.addUser('kim');
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++ let plan = null;
++
++ await check('a signed-in person starts a public plan with options; unlisted stays out of the list', async () => {
++ const r = await t.get('/api/v1/plans', { as: kim, json: { title: 'Friday with the group', summary: 'Two choices.', city: 'Portland', starts_at: future, timezone: 'UTC', visibility: 'public', options: [{ title: 'Mural walk', accessible: true }, { title: 'Bowling' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ plan = r.json();
++ assert.strictEqual(plan.status, 'voting');
++ assert.strictEqual(plan.options.length, 2);
++ assert.strictEqual(plan.visibility, 'public');
++ const list = await t.get('/api/v1/plans');
++ assert.ok(list.json().plans.some((p) => p.slug === plan.slug));
++ const unlisted = await t.get('/api/v1/plans', { as: kim, json: { title: 'A private share link', starts_at: future, timezone: 'UTC', options: [{ title: 'Option' }] } });
++ assert.strictEqual(unlisted.status, 201);
++ assert.ok(!(await t.get('/api/v1/plans')).json().plans.some((p) => p.slug === unlisted.json().slug));
++ assert.strictEqual((await t.get(`/api/v1/plans/${unlisted.json().slug}`)).status, 200);
++ });
++
++ await check('one vote per person, a second vote changes it, and a tie is refused', async () => {
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: kim, json: { option_id: plan.options[0].id } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: kim, json: { option_id: plan.options[1].id } })).status, 200);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_plan_votes WHERE plan_id = $1', [plan.id])), 1);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: ada, json: { option_id: plan.options[0].id } })).status, 200);
++ const tie = await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: kim, json: {} });
++ assert.strictEqual(tie.status, 409);
++ assert.strictEqual(tie.json().code, 'fun.plan_tie');
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: bo, json: { position: 2 } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: ada, json: {} })).status, 403);
++ const decided = await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: kim, json: {} });
++ assert.strictEqual(decided.status, 200, decided.text);
++ assert.strictEqual(decided.json().status, 'decided');
++ assert.strictEqual(decided.json().winner_option_id, plan.options[1].id);
++ });
++
++ await check('the winner is added to the viewer\'s own Today with their token and an idempotency key', async () => {
++ const r = await t.get(`/api/v1/plans/${plan.slug}/today`, { as: kim, json: {} });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.json().url, /^https:\/\/openvibe\.today\/e\/evt_test_/);
++ assert.strictEqual(t.upstreams.state.todayEvents.length, 1);
++ const call = t.upstreams.state.todayEvents[0];
++ assert.match(call.auth, /^Bearer /);
++ assert.strictEqual(call.idempotency_key, `fun-plan-${plan.id}`);
++ assert.strictEqual(call.body.kind, 'event');
++ assert.strictEqual(call.body.title, 'Fun: Friday with the group');
++ assert.match(call.body.description, /Bowling/);
++ assert.strictEqual(call.body.visibility, 'public');
++ assert.strictEqual(call.body.tz, 'UTC');
++ assert.ok(call.body.starts_at.startsWith(future.slice(0, 16)));
++ });
++
++ await check('an undecided plan, a cross-site write and a not-signed-in call are refused before Today', async () => {
++ const future2 = new Date(Date.now() + 190_000_000).toISOString();
++ const p = await t.get('/api/v1/plans', { as: kim, json: { title: 'Not decided yet', starts_at: future2, timezone: 'UTC', options: [{ title: 'One' }] } });
++ assert.strictEqual(p.status, 201);
++ const early = await t.get(`/api/v1/plans/${p.json().slug}/today`, { as: kim, json: {} });
++ assert.strictEqual(early.status, 409);
++ assert.strictEqual(early.json().code, 'fun.plan_not_decided');
++ const cross = await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: kim, json: { option_id: plan.options[0].id }, origin: 'https://evil.example' });
++ assert.strictEqual(cross.status, 403);
++ assert.strictEqual(cross.json().code, 'fun.cross_site');
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/today`, { json: {} })).status, 401);
++ assert.strictEqual(t.upstreams.state.todayEvents.length, 1);
++ });
++
++ await check('the plan page has the options, vote forms, the winner and the Today form', async () => {
++ const r = await t.get(`/plans/${plan.slug}`, { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('id="fu-plan"'));
++ assert.ok(r.text.includes('id="fu-plan-options"'));
++ assert.ok(r.text.includes('id="fu-plan-actions"'));
++ assert.ok(r.text.includes('Mural walk'));
++ assert.ok(r.text.includes('Bowling'));
++ assert.ok(r.text.includes('Add the winner to my Today'));
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ const pub = await t.get(`/plans/${plan.slug}`);
++ assert.strictEqual(pub.headers.get('cache-control'), 'public, max-age=60');
++ });
++
++ await check('staff can hide; a report is stored; the plan page and document are gone', async () => {
++ const rep = await t.get(`/api/v1/plans/${plan.slug}/report`, { as: bo, json: { kind: 'spam', reason: 'This is a duplicate plan' } });
++ assert.strictEqual(rep.status, 201, rep.text);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/hide`, { as: kim, method: 'POST' })).status, 403);
++ const hide = await t.get(`/api/v1/plans/${plan.slug}/hide`, { as: mod, method: 'POST' });
++ assert.strictEqual(hide.status, 200);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}`)).status, 404);
++ assert.ok(!(await t.get('/api/v1/plans')).json().plans.some((p) => p.slug === plan.slug));
++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
++ assert.strictEqual(tomb.payload.deleted, true);
++ assert.strictEqual(tomb.payload.type, 'plan');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/quests.test.js b/test/quests.test.js
nnew file mode 100644
iindex 0000000..4cad169
---- /dev/null
++++ b/test/quests.test.js
@@@ -0,0 +1,154 @@
++'use strict';
++/**
++ * IRL quests on Live: a streamer opens a board (the public Live channel and Network username must match), viewers
++ * suggest, chat or the page votes, the owner accepts exactly one, marks it live/completed, and a chat quorum or a
++ * moderator verifies it. The safety filter refuses dangerous/illegal/harassment suggestions before they are stored;
++ * staff can hide a public quest and report handling stays staff-only.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const kim = t.network.addUser('kim');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++
++ await check('a board needs the signed-in username to match the public Live channel', async () => {
++ const wrongUser = await t.get('/api/v1/quests/boards', { as: bo, json: { live_username: 'ada', title: 'Not mine' } });
++ assert.strictEqual(wrongUser.status, 403);
++ assert.strictEqual(wrongUser.json().code, 'fun.streamer_username');
++ const missing = await t.get('/api/v1/quests/boards', { as: bo, json: { live_username: 'nobody', title: 'No channel' } });
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.json().code, 'fun.channel_not_found');
++ const notFromHere = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Cross site' }, origin: 'https://evil.example' });
++ assert.strictEqual(notFromHere.status, 403);
++ assert.strictEqual(notFromHere.json().code, 'fun.cross_site');
++ });
++
++ let board = null;
++ let first = null;
++ await check('a board opens, an accessible-only board refuses a non-accessible suggestion, and a safe one is born unlisted', async () => {
++ const r = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Saturday IRL quests', city: 'Portland, Oregon', max_suggestions: 5 } });
++ assert.strictEqual(r.status, 201, r.text);
++ board = r.json();
++ assert.strictEqual(board.status, 'open');
++ assert.strictEqual(board.chat_username, 'ada');
++ const acc = await t.get('/api/v1/quests/boards', { as: bo, json: { live_username: 'bo', title: 'Accessible board', accessible_only: true } });
++ assert.strictEqual(acc.status, 201);
++ const no = await t.get(`/api/v1/quests/boards/${acc.json().id}/suggest`, { as: kim, json: { title: 'Find the best bench with a view', accessible: false } });
++ assert.strictEqual(no.status, 422);
++ assert.strictEqual(no.json().code, 'fun.quest.accessible_only');
++ const s = await t.get(`/api/v1/quests/boards/${board.id}/suggest`, { as: kim, json: { title: 'Find the best bench with a view', brief: 'Walk ten minutes and sit.', category: 'chill', accessible: true } });
++ assert.strictEqual(s.status, 201, s.text);
++ first = s.json();
++ assert.strictEqual(first.status, 'suggested');
++ assert.strictEqual(first.visibility, 'unlisted');
++ const list = await t.get('/api/v1/quests');
++ assert.ok(!list.json().quests.some((q) => q.slug === first.slug), 'a suggestion is not public');
++ });
++
++ await check('the safety filter refuses danger, illegality and harassment before a row is written', async () => {
++ for (const title of ['Climb a bridge and jump off', 'Trespass into the old factory', 'Harass a stranger until they react', 'Steal a sign from the bar']) {
++ const r = await t.get(`/api/v1/quests/boards/${board.id}/suggest`, { as: kim, json: { title } });
++ assert.strictEqual(r.status, 422, `${title}: ${r.text}`);
++ assert.match(r.json().code, /^fun\.quest\.(danger|illegal|harassment)$/);
++ }
++ const count = Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM fun_quests WHERE board_id = $1', [board.id]));
++ assert.strictEqual(count, 1);
++ });
++
++ await check('one vote per person; the owner accepts exactly one and it becomes public and searchable', async () => {
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/vote`, { as: kim, json: {} })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/vote`, { as: bo, json: {} })).status, 200);
++ const again = await t.get(`/api/v1/quests/${first.slug}/vote`, { as: kim, json: {} });
++ assert.strictEqual(again.json().votes, 2);
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/accept`, { as: bo, json: {} })).status, 403);
++ const acc = await t.get(`/api/v1/quests/${first.slug}/accept`, { as: ada, json: {} });
++ assert.strictEqual(acc.status, 200, acc.text);
++ assert.strictEqual(acc.json().visibility, 'public');
++ assert.strictEqual(acc.json().status, 'accepted');
++ const pub = await t.get('/api/v1/quests');
++ assert.ok(pub.json().quests.some((q) => q.slug === first.slug));
++ const row = await t.ctx.s.db.maybe("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
++ assert.strictEqual(contracts.validate('search.index-document@1', row.payload).valid, true);
++ assert.strictEqual(row.payload.type, 'quest');
++ assert.strictEqual(row.payload.canonical_url, `https://openvibe.fun/q/${first.slug}`);
++ assert.strictEqual(row.payload.facets.accessible, true);
++ });
++
++ await check('the streamer moves it live and completed; the chat quorum or a moderator verifies and pays the badge', async () => {
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/complete`, { as: ada, json: {} })).status, 409);
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/live`, { as: ada, json: {} })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/quests/${first.slug}/complete`, { as: ada, json: {} })).status, 200);
++ const noQuorum = await t.get(`/api/v1/quests/${first.slug}/verify`, { as: ada, json: { via: 'chat' } });
++ assert.strictEqual(noQuorum.status, 409);
++ assert.strictEqual(noQuorum.json().code, 'fun.quest_quorum');
++ for (const name of ['zoe', 'li', 'max']) {
++ const out = await t.ctx.store.recordQuestCheck({ slug: first.slug, chatUsername: name, via: 'chat', chatMessageId: `m-${name}` });
++ assert.strictEqual(out.ok, true, JSON.stringify(out));
++ }
++ const verify = await t.get(`/api/v1/quests/${first.slug}/verify`, { as: ada, json: { via: 'chat' } });
++ assert.strictEqual(verify.status, 200, verify.text);
++ assert.strictEqual(verify.json().status, 'verified');
++ assert.strictEqual(verify.json().verification, 'chat');
++ assert.ok(verify.json().reward_badge);
++ assert.strictEqual(verify.json().reward.state, 'skipped', 'OpenCoins are off by default');
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_badges WHERE quest_id = $1', [first.id])), 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_quest_rewards WHERE quest_id = $1', [first.id])), 1);
++ });
++
++ await check('a moderator can verify directly; staff can hide and the Search document becomes a tombstone', async () => {
++ const b2 = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Second board' } });
++ assert.strictEqual(b2.status, 201);
++ const s = await t.get(`/api/v1/quests/boards/${b2.json().id}/suggest`, { as: kim, json: { title: 'Find a shop that sells only one thing', brief: 'Ask what it is and why.' } });
++ assert.strictEqual(s.status, 201);
++ const q = s.json();
++ await t.get(`/api/v1/quests/${q.slug}/accept`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${q.slug}/live`, { as: ada, json: {} });
++ await t.get(`/api/v1/quests/${q.slug}/complete`, { as: ada, json: {} });
++ const v = await t.get(`/api/v1/quests/${q.slug}/verify`, { as: mod, json: { via: 'moderator' } });
++ assert.strictEqual(v.status, 200, v.text);
++ assert.strictEqual(v.json().verification, 'moderator');
++ assert.strictEqual((await t.get(`/api/v1/quests/${q.slug}/hide`, { as: kim, method: 'POST' })).status, 403);
++ const hide = await t.get(`/api/v1/quests/${q.slug}/hide`, { as: mod, method: 'POST' });
++ assert.strictEqual(hide.status, 200);
++ assert.strictEqual((await t.get(`/api/v1/quests/${q.slug}`)).status, 404);
++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
++ assert.strictEqual(tomb.payload.deleted, true);
++ assert.strictEqual(tomb.payload.type, 'quest');
++ });
++
++ await check('a report is stored for a signed-in person and listed only for staff', async () => {
++ const rep = await t.get(`/api/v1/quests/${first.slug}/report`, { as: kim, json: { kind: 'safety', reason: 'The place is not public', note: 'Check the address' } });
++ assert.strictEqual(rep.status, 201, rep.text);
++ assert.strictEqual((await t.get('/api/v1/reports', { as: kim })).status, 403);
++ const list = await t.get('/api/v1/reports', { as: mod });
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.json().reports.some((r) => r.target_slug === first.slug && r.kind === 'safety'));
++ });
++
++ await check('the quest page has the vote, the streamer actions, the report form and the overlay', async () => {
++ const r = await t.get(`/q/${first.slug}`, { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('id="fu-quest"'));
++ assert.ok(r.text.includes('id="fu-quest-actions"'));
++ assert.ok(r.text.includes('id="fu-report-form"'));
++ assert.ok(r.text.includes(`/overlay/q/${first.slug}`));
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ const pub = await t.get(`/q/${first.slug}`);
++ assert.strictEqual(pub.headers.get('cache-control'), 'public, max-age=60');
++ const ov = await t.get(`/overlay/q/${first.slug}`);
++ assert.strictEqual(ov.status, 200);
++ assert.ok(ov.text.includes('id="fu-overlay"'));
++ assert.ok(ov.text.includes('http-equiv="refresh"'));
++ assert.ok(ov.text.includes('noindex'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..3a7d4f5
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,21 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
++ * in-process mock of OpenVibe.Network, and in-process Live/Chat/Today/OSM/Ticketmaster fixtures; none needs the
++ * network.
++ *
++ * npm test # everything
++ * npm test -- importer # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ */
++'use strict';
++const { run } = require('openvibe-shared/test-runner');
++
++// VERBOSE=1 also prints every file's output, then the summary again.
++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/search.test.js b/test/search.test.js
nnew file mode 100644
iindex 0000000..d957934
---- /dev/null
++++ b/test/search.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The Search Fabric feed: a public quest is owner fun / type quest / /q/<slug>; a public plan is type plan /
++ * /plans/<slug>. Both validate against search.index-document@1, carry a revision, and hiding or cancelling one
++ * queues a tombstone that wins. A suggestion or an unlisted plan has no document.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('ada');
++ const kim = t.network.addUser('kim');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++
++ let quest = null;
++ let plan = null;
++ await check('a suggestion and an unlisted plan queue no Search document', async () => {
++ const b = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Search board' } });
++ const s = await t.get(`/api/v1/quests/boards/${b.json().id}/suggest`, { as: kim, json: { title: 'Find the best bench', brief: 'Walk ten minutes.' } });
++ quest = s.json();
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_outbox')), 0);
++ const p = await t.get('/api/v1/plans', { as: kim, json: { title: 'Unlisted plan', starts_at: future, timezone: 'UTC', options: [{ title: 'One' }] } });
++ assert.strictEqual(p.status, 201);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM fun_outbox')), 0);
++ plan = p.json();
++ });
++
++ await check('accepting a quest writes a valid search.index-document@1 for /q/<slug>', async () => {
++ const r = await t.get(`/api/v1/quests/${quest.slug}/accept`, { as: ada, json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM fun_outbox WHERE topic = 'fun.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
++ assert.ok(row);
++ const v = contracts.validate('search.index-document@1', row.payload);
++ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
++ assert.strictEqual(row.payload.owner, 'fun');
++ assert.strictEqual(row.payload.type, 'quest');
++ assert.strictEqual(row.payload.id, quest.id);
++ assert.strictEqual(row.payload.revision, 2);
++ assert.strictEqual(row.payload.canonical_url, `https://openvibe.fun/q/${quest.slug}`);
++ assert.strictEqual(row.payload.publication_state, 'published');
++ assert.strictEqual(row.payload.indexability.decision, 'index');
++ assert.strictEqual(row.payload.facets.category, 'near_me');
++ assert.strictEqual(row.payload.provenance[0].service, 'live');
++ assert.strictEqual(row.payload.provenance[0].id, 'ada');
++ assert.ok(row.payload.body.includes('Find the best bench'));
++ });
++
++ await check('a public plan queues a valid plan document with its options and no voter data', async () => {
++ const publicPlan = await t.get('/api/v1/plans', { as: kim, json: { title: 'Public plan', starts_at: future, timezone: 'UTC', visibility: 'public', options: [{ title: 'Mural walk', accessible: true }, { title: 'Bowling' }] } });
++ assert.strictEqual(publicPlan.status, 201);
++ const row = await t.ctx.s.db.maybe("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.upserted' AND payload->>'type' = 'plan' ORDER BY created_at DESC LIMIT 1");
++ assert.ok(row);
++ const v = contracts.validate('search.index-document@1', row.payload);
++ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
++ assert.strictEqual(row.payload.canonical_url, `https://openvibe.fun/plans/${publicPlan.json().slug}`);
++ assert.strictEqual(row.payload.facets.accessible, true);
++ assert.ok(row.payload.body.includes('Mural walk'));
++ assert.ok(!row.payload.body.includes(kim.subject), 'no owner subject in a public document');
++ });
++
++ await check('a vote does not bump the revision; deciding does, and hiding writes a valid tombstone', async () => {
++ const publicPlan = (await t.ctx.s.db.many("SELECT * FROM fun_plans WHERE slug = 'public-plan'"))[0];
++ const options = await t.ctx.s.db.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [publicPlan.id]);
++ await t.get(`/api/v1/plans/${publicPlan.slug}/vote`, { as: kim, json: { option_id: options[0].id } });
++ await t.get(`/api/v1/plans/${publicPlan.slug}/vote`, { as: ada, json: { option_id: options[1].id } });
++ await t.get(`/api/v1/plans/${publicPlan.slug}/vote`, { as: t.network.addUser('bo'), json: { option_id: options[0].id } });
++ const before = await t.ctx.s.db.value("SELECT revision FROM fun_plans WHERE id = $1", [publicPlan.id]);
++ const dec = await t.get(`/api/v1/plans/${publicPlan.slug}/decide`, { as: kim, json: {} });
++ assert.strictEqual(dec.status, 200, dec.text);
++ const after = await t.ctx.s.db.value('SELECT revision FROM fun_plans WHERE id = $1', [publicPlan.id]);
++ assert.strictEqual(Number(after), Number(before) + 1);
++ const ups = await t.ctx.s.db.many("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.upserted' AND payload->>'type' = 'plan' ORDER BY created_at");
++ assert.strictEqual(Number(ups[ups.length - 1].payload.revision), Number(after));
++ assert.strictEqual((await t.get(`/api/v1/plans/${publicPlan.slug}/hide`, { as: mod, method: 'POST' })).status, 200);
++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM fun_outbox WHERE topic = 'fun.index_document.deleted' AND payload->>'type' = 'plan' ORDER BY created_at DESC LIMIT 1");
++ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true);
++ assert.strictEqual(tomb.payload.deleted, true);
++ assert.strictEqual(tomb.payload.id, publicPlan.id);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/sso.test.js b/test/sso.test.js
nnew file mode 100644
iindex 0000000..3dff320
---- /dev/null
++++ b/test/sso.test.js
@@@ -0,0 +1,152 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256 (client id fun, cookies fun_at/fun_rt/fun_oauth), the session-token rules (a FedCM
++ * assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback metrics,
++ * pages without JavaScript, and cross-site sign-out.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++const { sanitizeNext } = require('../server/auth/sso');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++
++ await check('login redirects to Network with state and an S256 code challenge, client id fun', async () => {
++ const r = await t.get('/auth/login?next=/quests');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
++ assert.strictEqual(loc.searchParams.get('client_id'), 'fun');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
++ assert.match(r.headers.get('set-cookie'), /fun_oauth=.*Path=\/auth.*HttpOnly/i);
++ });
++
++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
++ const login = await t.get('/auth/login?next=/quests');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(r.status, 302, r.text);
++ assert.strictEqual(r.headers.get('location'), '/quests');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /fun_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /fun_rt=[^;]+;.*HttpOnly/i);
++ assert.match(set, /fun_oauth=;/);
++ const at = set.match(/fun_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `fun_at=${at}` });
++ assert.strictEqual(me.json().user.username, 'kim');
++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
++ });
++
++ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {
++ const login = await t.get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
++ assert.strictEqual(bad.status, 400);
++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(mismatch.status, 400);
++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
++ assert.strictEqual(noCookie.status, 400);
++ });
++
++ await check('next= only accepts same-site paths', async () => {
++ const r = await t.get('/auth/login?next=//evil.example/x');
++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/fun_oauth=([^;]+)/)[1]);
++ assert.strictEqual(JSON.parse(flow).next, '/');
++ const config = { baseUrl: 'https://openvibe.fun' };
++ assert.strictEqual(sanitizeNext('/quests?x=1', config), '/quests?x=1');
++ assert.strictEqual(sanitizeNext('https://openvibe.fun/q/x?y=1', config), '/q/x?y=1');
++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.fun.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
++ });
++
++ await check('readiness is truthful: db required; JWKS, OAuth, Ticketmaster, coins and Valkey optional', async () => {
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(b.checks.db.status, 'ok');
++ assert.strictEqual(b.checks.db.required, true);
++ assert.strictEqual(b.checks.network_jwks.status, 'ok');
++ assert.strictEqual(b.checks.network_jwks.required, false);
++ assert.strictEqual(b.checks.oauth_client.status, 'ok');
++ assert.strictEqual(b.checks.oauth_client.required, false);
++ assert.ok('valkey' in b.checks);
++ assert.ok('ticketmaster' in b.checks);
++ assert.ok('coins' in b.checks);
++ });
++
++ await check('/release.json and /metrics (loopback only)', async () => {
++ const rel = await t.get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json().service, 'fun');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
++ const m = await t.get('/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.match(m.text, /http_requests_total|release_info/);
++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
++ });
++
++ await check('public pages are complete without JavaScript', async () => {
++ for (const p of ['/', '/guide', '/quests', '/plans', '/developers']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<noscript><nav/);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++ });
++
++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
++ const ping = await t.get('/api/v1/ping');
++ assert.strictEqual(ping.status, 200);
++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'fun' });
++ const gone = await t.get('/api/v1/nope');
++ assert.strictEqual(gone.status, 404);
++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(gone.json().code, 'route.not_found');
++ });
++
++ await check('a FedCM assertion and an app token are not sessions', async () => {
++ const now = Math.floor(Date.now() / 1000);
++ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
++ assert.strictEqual((await t.get('/auth/me', { cookie: `fun_at=${fedcm}` })).status, 401);
++ const page = await t.get('/', { cookie: `fun_at=${fedcm}` });
++ assert.ok(!page.text.includes('Signed in as <strong>kim'));
++ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
++ assert.strictEqual((await t.get('/auth/me', { cookie: `fun_at=${app}` })).status, 401);
++ });
++
++ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
++ const guest = await t.get('/auth/me');
++ assert.strictEqual(guest.status, 200);
++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/auth/me', { cookie: 'fun_at=garbage' })).status, 401);
++ assert.strictEqual((await t.get('/auth/me', { cookie: 'fun_rt=stale' })).status, 401);
++ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
++ });
++
++ await check('another site cannot sign a person out; this site can', async () => {
++ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 200);
++ assert.ok(!/fun_at=;|fun_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));
++ assert.match(cross.text, /method="post"/);
++ const crossPost = await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(crossPost.status, 403);
++ const same = await t.get('/auth/logout?next=/quests', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(same.headers.get('location'), '/quests');
++ assert.match(String(same.headers.get('set-cookie') || ''), /fun_at=;/);
++ });
++
++ await t.close();
++ done();
++})();