openvibers/openvibe.fun Commits
OpenVibe.Fun v1
Changes
nnew file mode 100644@@@ -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=truennew file mode 100644@@@ -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@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++/data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.key++!server/data/nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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/>.nnew file mode 100644@@@ -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).nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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"++}nnew file mode 100644@@@ -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;++ }++}nnew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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; }nnew file mode 100644@@@ -0,0 +1 @@++/* OpenVibe.Fun: what the pages are complete without. The main session designs this file; no page needs JavaScript. */nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 <slug></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 <slug></code> and <code>!quest suggest <text></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)} <number></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/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></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,++};nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+/data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
+!server/data/
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -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/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -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).
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,363 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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; }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+/* OpenVibe.Fun: what the pages are complete without. The main session designs this file; no page needs JavaScript. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,347 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,379 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,713 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,414 @@
@@ -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 <slug></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 <slug></code> and <code>!quest suggest <text></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)} <number></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/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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();
+})();